From 49e7638abff66af96bcfa3a416208b6bf45eb28a Mon Sep 17 00:00:00 2001 From: barry Date: Fri, 3 Jul 2026 18:56:45 +0800 Subject: [PATCH 01/20] Update Airtable alignment status to in progress and enhance CSV export/import task details. Add ViewExportSettings component to various database view settings for improved export functionality. --- docs/database-airtable-alignment.md | 8 +- packages/client/src/lib/database-csv.ts | 355 ++++++++++++++++++ packages/client/src/lib/index.ts | 1 + packages/client/test/database-csv.test.ts | 151 ++++++++ .../databases/boards/board-view-settings.tsx | 2 + .../calendars/calendar-view-settings.tsx | 2 + .../databases/tables/table-view-settings.tsx | 2 + .../databases/view-basic-settings.tsx | 8 +- .../databases/view-csv-export-menu-item.tsx | 75 ++++ .../databases/view-csv-import-menu-item.tsx | 122 ++++++ .../databases/view-export-settings.tsx | 18 + packages/ui/src/lib/database-csv-export.ts | 129 +++++++ packages/ui/src/lib/database-csv-import.ts | 187 +++++++++ 13 files changed, 1053 insertions(+), 7 deletions(-) create mode 100644 packages/client/src/lib/database-csv.ts create mode 100644 packages/client/test/database-csv.test.ts create mode 100644 packages/ui/src/components/databases/view-csv-export-menu-item.tsx create mode 100644 packages/ui/src/components/databases/view-csv-import-menu-item.tsx create mode 100644 packages/ui/src/components/databases/view-export-settings.tsx create mode 100644 packages/ui/src/lib/database-csv-export.ts create mode 100644 packages/ui/src/lib/database-csv-import.ts diff --git a/docs/database-airtable-alignment.md b/docs/database-airtable-alignment.md index 6eee38cbe..98337dc88 100644 --- a/docs/database-airtable-alignment.md +++ b/docs/database-airtable-alignment.md @@ -1,6 +1,6 @@ # Database — Airtable Alignment Plan -> Status: **planned** (not started) +> Status: **in progress** (Phase A — A1 CSV export ✓ on branch) > Branch target: `feat/airtable-phase-a` → `main` > Related: [record-query.md](./record-query.md), [page-meta-types.md](./page-meta-types.md), [file-meta-types.md](./file-meta-types.md), [roadmap.md](./roadmap.md) @@ -124,8 +124,8 @@ Scheduled Tasks: schedule, query match, event triggers (`database_records_create | ID | Task | Description | Primary touchpoints | |----|------|-------------|---------------------| -| A1 | CSV export | Export current view columns + types; UTF-8 CSV download | UI: database menu; map `CollectionRow` → CSV | -| A2 | CSV import | Map headers → fields; validate types; `record.create` batch | New mutation or service; import dialog | +| A1 | CSV export | Export current view columns + types; UTF-8 CSV download | UI: database menu; map `CollectionRow` → CSV | ✓ | +| A2 | CSV import | Map headers → fields; validate types; `record.create` batch | New mutation or service; import dialog | ✓ | | A3 | `autonumber` field | Per-database monotonic ID; schema + immutability on create | `packages/core/src/registry/nodes/field.ts`, record create path | | A4 | `lookup` field | Pull field from related row via `relationFieldId` + `lookedUpFieldId` | New field type; UI like rollup config; read-only cell | | A5 | Bidirectional relation | Option on relation create: add reverse column on target DB | Extend relation field attrs or paired-field convention | @@ -257,7 +257,7 @@ Prefer extending Scheduled Task `check` + new `actions[]` on the same record sha | Area | Paths | |------|-------| | Field schemas | `packages/core/src/registry/nodes/field.ts`, `field-value.ts` | -| CSV | `packages/client/src/lib/database-csv.ts` (new), handlers/mutations | +| CSV | `packages/client/src/lib/database-csv.ts`, `packages/ui/src/lib/database-csv-export.ts`, `packages/ui/src/lib/database-csv-import.ts`, `packages/ui/src/components/databases/view-csv-export-menu-item.tsx`, `view-csv-import-menu-item.tsx`, `view-export-settings.tsx` | | Lookup / autonumber UI | `packages/ui/src/components/databases/fields/`, `records/values/` | | Bulk edit | `packages/ui/src/components/databases/tables/` | | Form / chart / timeline views | `packages/ui/src/components/databases/`, `database-view.ts` layout enum | diff --git a/packages/client/src/lib/database-csv.ts b/packages/client/src/lib/database-csv.ts new file mode 100644 index 000000000..c2921f423 --- /dev/null +++ b/packages/client/src/lib/database-csv.ts @@ -0,0 +1,355 @@ +import { + FieldAttributes, + FieldValue, + MultiSelectFieldAttributes, + SelectFieldAttributes, +} from '@colanode/core'; + +export type DatabaseCsvRow = { + id: string; + name: string; + fields: Record; + createdAt: string; + createdBy: string; + updatedAt?: string | null; + updatedBy?: string | null; +}; + +export type DatabaseCsvColumn = { + id: string; + header: string; + field: FieldAttributes | null; +}; + +export type BuildDatabaseCsvColumnsInput = { + nameHeader: string; + includeRowId: boolean; + rowIdHeader?: string; + fields: FieldAttributes[]; +}; + +const normalizeDateValue = (raw: string): string => { + const trimmed = raw.trim(); + if (!trimmed) { + return ''; + } + + const dateOnly = trimmed.match(/^(\d{4}-\d{2}-\d{2})/); + if (dateOnly) { + return dateOnly[1]!; + } + + const parsed = new Date(trimmed); + if (!Number.isNaN(parsed.getTime())) { + return parsed.toISOString().slice(0, 10); + } + + return trimmed; +}; + +const selectOptionLabel = ( + field: SelectFieldAttributes, + optionId: string +): string => { + return field.options?.[optionId]?.name ?? optionId; +}; + +const multiSelectLabels = ( + field: MultiSelectFieldAttributes, + optionIds: string[] +): string => { + return optionIds + .map((optionId) => field.options?.[optionId]?.name ?? optionId) + .join(', '); +}; + +export const escapeCsvCell = (value: string): string => { + if (/[",\r\n]/.test(value)) { + return `"${value.replace(/"/g, '""')}"`; + } + + return value; +}; + +export const buildDatabaseCsvColumns = ( + input: BuildDatabaseCsvColumnsInput +): DatabaseCsvColumn[] => { + const columns: DatabaseCsvColumn[] = []; + + if (input.includeRowId) { + columns.push({ + id: '__row_id__', + header: input.rowIdHeader ?? 'ID', + field: null, + }); + } + + columns.push({ + id: '__name__', + header: input.nameHeader, + field: null, + }); + + for (const field of input.fields) { + columns.push({ + id: field.id, + header: field.name, + field, + }); + } + + return columns; +}; + +export const getDatabaseCsvCellValue = ( + row: DatabaseCsvRow, + column: DatabaseCsvColumn +): string => { + if (column.id === '__row_id__') { + return row.id; + } + + if (column.id === '__name__') { + return row.name; + } + + const field = column.field; + if (!field) { + return ''; + } + + switch (field.type) { + case 'created_at': + return row.createdAt; + case 'created_by': + return row.createdBy; + case 'updated_at': + return row.updatedAt ?? ''; + case 'updated_by': + return row.updatedBy ?? ''; + case 'boolean': { + const value = row.fields[field.id]; + if (value?.type === 'boolean') { + return value.value ? 'true' : 'false'; + } + return ''; + } + case 'number': { + const value = row.fields[field.id]; + if (value?.type === 'number') { + return Number.isFinite(value.value) ? String(value.value) : ''; + } + return ''; + } + case 'date': { + const value = row.fields[field.id]; + if (value?.type === 'string') { + return normalizeDateValue(value.value); + } + return ''; + } + case 'select': { + const value = row.fields[field.id]; + if (value?.type === 'string') { + return selectOptionLabel(field, value.value); + } + return ''; + } + case 'multi_select': { + const value = row.fields[field.id]; + if (value?.type === 'string_array' && value.value.length > 0) { + return multiSelectLabels(field, value.value); + } + return ''; + } + case 'collaborator': + case 'relation': { + const value = row.fields[field.id]; + if (value?.type === 'string_array' && value.value.length > 0) { + return value.value.join(', '); + } + return ''; + } + case 'text': { + const value = row.fields[field.id]; + if (value?.type === 'text') { + return value.value; + } + return ''; + } + case 'email': + case 'phone': + case 'url': { + const value = row.fields[field.id]; + if (value?.type === 'string') { + return value.value; + } + return ''; + } + case 'button': + case 'file': + case 'formula': + case 'resource': + case 'rollup': + return ''; + default: + return ''; + } +}; + +export const rowsToDatabaseCsv = ( + rows: DatabaseCsvRow[], + columns: DatabaseCsvColumn[] +): string => { + const headerLine = columns.map((column) => escapeCsvCell(column.header)).join(','); + const dataLines = rows.map((row) => + columns + .map((column) => escapeCsvCell(getDatabaseCsvCellValue(row, column))) + .join(',') + ); + + return [headerLine, ...dataLines].join('\n'); +}; + +export const sanitizeDatabaseCsvFileName = ( + databaseName: string, + viewName: string +): string => { + const sanitize = (value: string) => + value + .trim() + .replace(/[\\/:*?"<>|]+/g, '-') + .replace(/\s+/g, ' ') + .slice(0, 80) || 'export'; + + return `${sanitize(databaseName)} - ${sanitize(viewName)}.csv`; +}; + +const normalizeCsvHeader = (value: string): string => + value.trim().toLowerCase(); + +export const parseCsvText = (content: string): string[][] => { + const rows: string[][] = []; + let row: string[] = []; + let cell = ''; + let inQuotes = false; + + for (let index = 0; index < content.length; index += 1) { + const char = content[index]!; + const next = content[index + 1]; + + if (inQuotes) { + if (char === '"' && next === '"') { + cell += '"'; + index += 1; + } else if (char === '"') { + inQuotes = false; + } else { + cell += char; + } + continue; + } + + if (char === '"') { + inQuotes = true; + continue; + } + + if (char === ',') { + row.push(cell); + cell = ''; + continue; + } + + if (char === '\n' || (char === '\r' && next === '\n')) { + row.push(cell); + cell = ''; + if (row.some((value) => value.length > 0)) { + rows.push(row); + } + row = []; + if (char === '\r') { + index += 1; + } + continue; + } + + if (char !== '\r') { + cell += char; + } + } + + row.push(cell); + if (row.some((value) => value.length > 0)) { + rows.push(row); + } + + return rows; +}; + +export type CsvImportHeaderMapping = { + nameColumnIndex: number | null; + rowIdColumnIndex: number | null; + fieldColumnIndexes: Record; +}; + +export const mapCsvImportHeaders = (input: { + headers: string[]; + fields: FieldAttributes[]; + nameHeader: string; + rowIdHeader?: string; +}): CsvImportHeaderMapping => { + const fieldByName = new Map(); + for (const field of input.fields) { + fieldByName.set(normalizeCsvHeader(field.name), field); + } + + const nameKey = normalizeCsvHeader(input.nameHeader); + const rowIdKey = input.rowIdHeader + ? normalizeCsvHeader(input.rowIdHeader) + : null; + + let nameColumnIndex: number | null = null; + let rowIdColumnIndex: number | null = null; + const fieldColumnIndexes: Record = {}; + + input.headers.forEach((header, index) => { + const key = normalizeCsvHeader(header); + if (key === nameKey) { + nameColumnIndex = index; + return; + } + + if (rowIdKey && key === rowIdKey) { + rowIdColumnIndex = index; + return; + } + + const field = fieldByName.get(key); + if (field) { + fieldColumnIndexes[field.id] = index; + } + }); + + return { + nameColumnIndex, + rowIdColumnIndex, + fieldColumnIndexes, + }; +}; + +const IMPORT_SKIP_FIELD_TYPES = new Set([ + 'button', + 'created_at', + 'created_by', + 'updated_at', + 'updated_by', + 'file', + 'formula', + 'resource', + 'rollup', +]); + +export const isCsvImportableField = (field: FieldAttributes): boolean => { + return !IMPORT_SKIP_FIELD_TYPES.has(field.type); +}; diff --git a/packages/client/src/lib/index.ts b/packages/client/src/lib/index.ts index af667dbe8..030bf38b9 100644 --- a/packages/client/src/lib/index.ts +++ b/packages/client/src/lib/index.ts @@ -11,6 +11,7 @@ export * from './scheduled-task-checks'; export * from './scheduled-task-check-utils'; export * from './scheduled-task-templates'; export * from './asset-library'; +export * from './database-csv'; export * from './todo-list'; export * from './types'; export * from './utils'; diff --git a/packages/client/test/database-csv.test.ts b/packages/client/test/database-csv.test.ts new file mode 100644 index 000000000..a922efeb0 --- /dev/null +++ b/packages/client/test/database-csv.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildDatabaseCsvColumns, + escapeCsvCell, + getDatabaseCsvCellValue, + mapCsvImportHeaders, + parseCsvText, + rowsToDatabaseCsv, + sanitizeDatabaseCsvFileName, +} from '@colanode/client/lib/database-csv'; +import { + FieldAttributes, + MultiSelectFieldAttributes, + SelectFieldAttributes, +} from '@colanode/core'; + +const statusField: SelectFieldAttributes = { + id: 'status', + type: 'select', + name: 'Status', + index: 'a', + options: { + open: { id: 'open', name: 'Open', index: 'a', color: 'blue' }, + done: { id: 'done', name: 'Done', index: 'b', color: 'green' }, + }, +}; + +const tagsField: MultiSelectFieldAttributes = { + id: 'tags', + type: 'multi_select', + name: 'Tags', + index: 'b', + options: { + bug: { id: 'bug', name: 'Bug', index: 'a', color: 'red' }, + feat: { id: 'feat', name: 'Feature', index: 'b', color: 'blue' }, + }, +}; + +const qtyField: FieldAttributes = { + id: 'qty', + type: 'number', + name: 'Qty', + index: 'c', +}; + +describe('database-csv', () => { + it('escapes commas and quotes', () => { + expect(escapeCsvCell('plain')).toBe('plain'); + expect(escapeCsvCell('a,b')).toBe('"a,b"'); + expect(escapeCsvCell('say "hi"')).toBe('"say ""hi"""'); + }); + + it('builds columns with optional row id', () => { + const columns = buildDatabaseCsvColumns({ + nameHeader: 'Name', + includeRowId: true, + rowIdHeader: 'Record ID', + fields: [statusField, qtyField], + }); + + expect(columns.map((column) => column.header)).toEqual([ + 'Record ID', + 'Name', + 'Status', + 'Qty', + ]); + }); + + it('serializes row values for export', () => { + const columns = buildDatabaseCsvColumns({ + nameHeader: 'Name', + includeRowId: false, + fields: [statusField, tagsField, qtyField], + }); + + const csv = rowsToDatabaseCsv( + [ + { + id: 'rec-1', + name: 'Widget, large', + createdAt: '2026-01-02T10:00:00.000Z', + createdBy: 'user-1', + fields: { + status: { type: 'string', value: 'open' }, + tags: { type: 'string_array', value: ['bug', 'feat'] }, + qty: { type: 'number', value: 3 }, + }, + }, + ], + columns + ); + + expect(csv).toBe( + 'Name,Status,Tags,Qty\n"Widget, large",Open,"Bug, Feature",3' + ); + }); + + it('reads system timestamp fields from row metadata', () => { + const createdAtField: FieldAttributes = { + id: 'created', + type: 'created_at', + name: 'Created', + index: 'z', + }; + const columns = buildDatabaseCsvColumns({ + nameHeader: 'Name', + includeRowId: false, + fields: [createdAtField], + }); + const createdColumn = columns.find((column) => column.id === 'created')!; + + expect( + getDatabaseCsvCellValue( + { + id: 'rec-1', + name: 'Row', + createdAt: '2026-03-01T08:00:00.000Z', + createdBy: 'user-1', + fields: {}, + }, + createdColumn + ) + ).toBe('2026-03-01T08:00:00.000Z'); + }); + + it('sanitizes export file names', () => { + expect(sanitizeDatabaseCsvFileName('Tasks/Docs', 'Q1 View')).toBe( + 'Tasks-Docs - Q1 View.csv' + ); + }); + + it('parses quoted csv rows', () => { + expect(parseCsvText('Name,Status\n"Widget, large",Open\n')).toEqual([ + ['Name', 'Status'], + ['Widget, large', 'Open'], + ]); + }); + + it('maps import headers to fields', () => { + const mapping = mapCsvImportHeaders({ + headers: ['Name', 'Status', 'Qty'], + fields: [statusField, qtyField], + nameHeader: 'Name', + }); + + expect(mapping.nameColumnIndex).toBe(0); + expect(mapping.fieldColumnIndexes.status).toBe(1); + expect(mapping.fieldColumnIndexes.qty).toBe(2); + }); +}); diff --git a/packages/ui/src/components/databases/boards/board-view-settings.tsx b/packages/ui/src/components/databases/boards/board-view-settings.tsx index bee7aeafb..57506a51b 100644 --- a/packages/ui/src/components/databases/boards/board-view-settings.tsx +++ b/packages/ui/src/components/databases/boards/board-view-settings.tsx @@ -2,6 +2,7 @@ import { Lock, LockOpen, Trash2 } from 'lucide-react'; import { Fragment, useState } from 'react'; import { ViewAvatarInput } from '@colanode/ui/components/databases/view-avatar-input'; +import { ViewExportSettings } from '@colanode/ui/components/databases/view-export-settings'; import { ViewFieldSettings } from '@colanode/ui/components/databases/view-field-settings'; import { ViewRenameInput } from '@colanode/ui/components/databases/view-rename-input'; import { ViewSettingsButton } from '@colanode/ui/components/databases/view-settings-button'; @@ -45,6 +46,7 @@ export const BoardViewSettings = () => { + {database.canEdit && ( diff --git a/packages/ui/src/components/databases/calendars/calendar-view-settings.tsx b/packages/ui/src/components/databases/calendars/calendar-view-settings.tsx index 2adc8bb11..e814b9a6c 100644 --- a/packages/ui/src/components/databases/calendars/calendar-view-settings.tsx +++ b/packages/ui/src/components/databases/calendars/calendar-view-settings.tsx @@ -2,6 +2,7 @@ import { Lock, LockOpen, Trash2 } from 'lucide-react'; import { Fragment, useState } from 'react'; import { ViewAvatarInput } from '@colanode/ui/components/databases/view-avatar-input'; +import { ViewExportSettings } from '@colanode/ui/components/databases/view-export-settings'; import { ViewFieldSettings } from '@colanode/ui/components/databases/view-field-settings'; import { ViewRenameInput } from '@colanode/ui/components/databases/view-rename-input'; import { ViewSettingsButton } from '@colanode/ui/components/databases/view-settings-button'; @@ -45,6 +46,7 @@ export const CalendarViewSettings = () => { + {database.canEdit && ( diff --git a/packages/ui/src/components/databases/tables/table-view-settings.tsx b/packages/ui/src/components/databases/tables/table-view-settings.tsx index 6f5a4850c..c1d5b2445 100644 --- a/packages/ui/src/components/databases/tables/table-view-settings.tsx +++ b/packages/ui/src/components/databases/tables/table-view-settings.tsx @@ -2,6 +2,7 @@ import { Lock, LockOpen, Trash2 } from 'lucide-react'; import { Fragment, useState } from 'react'; import { ViewAvatarInput } from '@colanode/ui/components/databases/view-avatar-input'; +import { ViewExportSettings } from '@colanode/ui/components/databases/view-export-settings'; import { ViewFieldSettings } from '@colanode/ui/components/databases/view-field-settings'; import { ViewGroupSettings } from '@colanode/ui/components/databases/view-group-settings'; import { ViewRenameInput } from '@colanode/ui/components/databases/view-rename-input'; @@ -46,6 +47,7 @@ export const TableViewSettings = () => { + {database.canEdit && ( diff --git a/packages/ui/src/components/databases/view-basic-settings.tsx b/packages/ui/src/components/databases/view-basic-settings.tsx index 258d4a844..2f79a8763 100644 --- a/packages/ui/src/components/databases/view-basic-settings.tsx +++ b/packages/ui/src/components/databases/view-basic-settings.tsx @@ -2,6 +2,7 @@ import { Lock, LockOpen, Trash2 } from 'lucide-react'; import { Fragment, useState } from 'react'; import { ViewAvatarInput } from '@colanode/ui/components/databases/view-avatar-input'; +import { ViewExportSettings } from '@colanode/ui/components/databases/view-export-settings'; import { ViewFieldSettings } from '@colanode/ui/components/databases/view-field-settings'; import { ViewGroupSettings } from '@colanode/ui/components/databases/view-group-settings'; import { ViewRenameInput } from '@colanode/ui/components/databases/view-rename-input'; @@ -44,9 +45,10 @@ export const ViewBasicSettings = () => { readOnly={!database.canEdit || database.isLocked} /> - - - {view.layout === 'list' && database.canEdit && ( + + + + {view.layout === 'list' && database.canEdit && ( diff --git a/packages/ui/src/components/databases/view-csv-export-menu-item.tsx b/packages/ui/src/components/databases/view-csv-export-menu-item.tsx new file mode 100644 index 000000000..51f21b2f4 --- /dev/null +++ b/packages/ui/src/components/databases/view-csv-export-menu-item.tsx @@ -0,0 +1,75 @@ +import { FileDown, Loader2 } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +import { useDatabase } from '@colanode/ui/contexts/database'; +import { useDatabaseView } from '@colanode/ui/contexts/database-view'; +import { useWorkspace } from '@colanode/ui/contexts/workspace'; +import { exportDatabaseViewToCsv } from '@colanode/ui/lib/database-csv-export'; +import { cn } from '@colanode/ui/lib/utils'; + +export const ViewCsvExportMenuItem = () => { + const workspace = useWorkspace(); + const database = useDatabase(); + const view = useDatabaseView(); + const [pending, setPending] = useState(false); + + const metaTypeIdLabel = database.isFileMetaType + ? 'File ID' + : database.isPageMetaType + ? 'Page ID' + : null; + + const handleExport = async () => { + if (pending) { + return; + } + + setPending(true); + try { + const result = await exportDatabaseViewToCsv({ + userId: workspace.userId, + databaseId: database.id, + databaseName: database.name, + viewName: view.name, + nameHeader: database.nameField?.name ?? 'Name', + includeRowId: metaTypeIdLabel !== null, + rowIdHeader: metaTypeIdLabel ?? undefined, + viewFields: view.fields, + filters: view.filters, + sorts: view.sorts, + }); + + toast.success( + `Exported ${result.rowCount} row${result.rowCount === 1 ? '' : 's'}`, + { description: result.fileName } + ); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Could not export CSV.'; + toast.error('CSV export failed', { description: message }); + } finally { + setPending(false); + } + }; + + return ( + + ); +}; diff --git a/packages/ui/src/components/databases/view-csv-import-menu-item.tsx b/packages/ui/src/components/databases/view-csv-import-menu-item.tsx new file mode 100644 index 000000000..bf7529cce --- /dev/null +++ b/packages/ui/src/components/databases/view-csv-import-menu-item.tsx @@ -0,0 +1,122 @@ +import { FileUp, Loader2 } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { toast } from 'sonner'; + +import { useDatabase } from '@colanode/ui/contexts/database'; +import { useWorkspace } from '@colanode/ui/contexts/workspace'; +import { + importDatabaseRecordsFromCsv, + previewDatabaseCsvImport, +} from '@colanode/ui/lib/database-csv-import'; +import { cn } from '@colanode/ui/lib/utils'; + +export const ViewCsvImportMenuItem = () => { + const workspace = useWorkspace(); + const database = useDatabase(); + const inputRef = useRef(null); + const [pending, setPending] = useState(false); + + const isRecordsDatabase = (database.purpose ?? 'records') === 'records'; + + const handlePickFile = () => { + if (!isRecordsDatabase || pending) { + return; + } + + inputRef.current?.click(); + }; + + const handleFileChange = async ( + event: React.ChangeEvent + ) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) { + return; + } + + setPending(true); + try { + const csvText = await file.text(); + const preview = previewDatabaseCsvImport({ + csvText, + fields: database.fields, + nameHeader: database.nameField?.name ?? 'Name', + }); + + if (!preview) { + toast.error('CSV import failed', { + description: 'The file is empty.', + }); + return; + } + + if (preview.missingNameColumn) { + toast.error('CSV import failed', { + description: `Missing a "${database.nameField?.name ?? 'Name'}" column.`, + }); + return; + } + + const result = importDatabaseRecordsFromCsv({ + workspace, + database: { + id: database.id, + rootId: database.rootId, + fields: database.fields, + }, + csvText, + nameHeader: database.nameField?.name ?? 'Name', + }); + + toast.success( + `Imported ${result.importedCount} row${result.importedCount === 1 ? '' : 's'}`, + { + description: + result.skippedCount > 0 + ? `${result.skippedCount} row${result.skippedCount === 1 ? '' : 's'} skipped (missing name).` + : file.name, + } + ); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Could not import CSV.'; + toast.error('CSV import failed', { description: message }); + } finally { + setPending(false); + } + }; + + if (!isRecordsDatabase) { + return null; + } + + return ( + <> + { + void handleFileChange(event); + }} + /> + + + ); +}; diff --git a/packages/ui/src/components/databases/view-export-settings.tsx b/packages/ui/src/components/databases/view-export-settings.tsx new file mode 100644 index 000000000..63da8de7b --- /dev/null +++ b/packages/ui/src/components/databases/view-export-settings.tsx @@ -0,0 +1,18 @@ +import { Fragment } from 'react'; + +import { ViewCsvExportMenuItem } from '@colanode/ui/components/databases/view-csv-export-menu-item'; +import { ViewCsvImportMenuItem } from '@colanode/ui/components/databases/view-csv-import-menu-item'; +import { Separator } from '@colanode/ui/components/ui/separator'; + +export const ViewExportSettings = () => { + return ( + + +
+

Data

+ + +
+
+ ); +}; diff --git a/packages/ui/src/lib/database-csv-export.ts b/packages/ui/src/lib/database-csv-export.ts new file mode 100644 index 000000000..29556e9f3 --- /dev/null +++ b/packages/ui/src/lib/database-csv-export.ts @@ -0,0 +1,129 @@ +import { ViewField } from '@colanode/client/types'; +import { + buildDatabaseCsvColumns, + DatabaseCsvRow, + rowsToDatabaseCsv, + sanitizeDatabaseCsvFileName, +} from '@colanode/client/lib'; +import { + DatabaseQueryInput, + DatabaseQueryRow, +} from '@colanode/client/queries'; +import { + DatabaseViewFilterAttributes, + DatabaseViewSortAttributes, +} from '@colanode/core'; + +const EXPORT_PAGE_SIZE = 500; + +const mapQueryRow = (row: DatabaseQueryRow): DatabaseCsvRow => ({ + id: row.id, + name: row.name, + fields: row.fields, + createdAt: row.createdAt, + createdBy: row.createdBy, + updatedAt: row.updatedAt, + updatedBy: row.updatedBy, +}); + +export const fetchAllDatabaseQueryRows = async ( + input: Omit +): Promise => { + const rows: DatabaseQueryRow[] = []; + let offset = 0; + + while (true) { + const queryInput: DatabaseQueryInput = { + ...input, + limit: EXPORT_PAGE_SIZE, + offset, + }; + const result = await window.colanode.executeQuery(queryInput); + + if (!result || result.mode !== 'rows') { + break; + } + + rows.push(...result.rows); + + if (result.rows.length < EXPORT_PAGE_SIZE) { + break; + } + + offset += result.rows.length; + } + + return rows; +}; + +export const buildDatabaseCsvFromView = (input: { + databaseName: string; + nameHeader: string; + includeRowId: boolean; + rowIdHeader?: string; + viewFields: ViewField[]; + rows: DatabaseCsvRow[]; +}): string => { + const columns = buildDatabaseCsvColumns({ + nameHeader: input.nameHeader, + includeRowId: input.includeRowId, + rowIdHeader: input.rowIdHeader, + fields: input.viewFields.map((viewField) => viewField.field), + }); + + return rowsToDatabaseCsv(input.rows, columns); +}; + +export const downloadDatabaseCsvFile = (fileName: string, csv: string) => { + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName.endsWith('.csv') ? fileName : `${fileName}.csv`; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +}; + +export const exportDatabaseViewToCsv = async (input: { + userId: string; + databaseId: string; + databaseName: string; + viewName: string; + nameHeader: string; + includeRowId: boolean; + rowIdHeader?: string; + viewFields: ViewField[]; + filters: DatabaseViewFilterAttributes[]; + sorts: DatabaseViewSortAttributes[]; +}): Promise<{ fileName: string; rowCount: number }> => { + const queryRows = await fetchAllDatabaseQueryRows({ + type: 'database.query', + userId: input.userId, + databaseId: input.databaseId, + where: input.filters, + orderBy: input.sorts, + }); + + const csv = buildDatabaseCsvFromView({ + databaseName: input.databaseName, + nameHeader: input.nameHeader, + includeRowId: input.includeRowId, + rowIdHeader: input.rowIdHeader, + viewFields: input.viewFields, + rows: queryRows.map(mapQueryRow), + }); + + const fileName = sanitizeDatabaseCsvFileName( + input.databaseName, + input.viewName + ); + downloadDatabaseCsvFile(fileName, csv); + + return { + fileName, + rowCount: queryRows.length, + }; +}; diff --git a/packages/ui/src/lib/database-csv-import.ts b/packages/ui/src/lib/database-csv-import.ts new file mode 100644 index 000000000..b0e6ba38a --- /dev/null +++ b/packages/ui/src/lib/database-csv-import.ts @@ -0,0 +1,187 @@ +import { + FieldAttributes, + FieldValue, + generateId, + IdType, +} from '@colanode/core'; +import { LocalRecordNode } from '@colanode/client/types'; +import { + CsvImportHeaderMapping, + isCsvImportableField, + mapCsvImportHeaders, + parseCsvText, +} from '@colanode/client/lib'; +import { WorkspaceCollections } from '@colanode/ui/collections'; +import { yamlValueToFieldValue, resolveSelectOptionId } from '@colanode/ui/lib/page-markdown-fields'; +import type { SelectFieldAttributes } from '@colanode/core'; + +type WorkspaceLike = { + userId: string; + collections: WorkspaceCollections; +}; + +type DatabaseLike = { + id: string; + rootId: string; + fields: FieldAttributes[]; +}; + +export type CsvImportPreview = { + headers: string[]; + rowCount: number; + mapping: CsvImportHeaderMapping; + missingNameColumn: boolean; +}; + +export const previewDatabaseCsvImport = (input: { + csvText: string; + fields: FieldAttributes[]; + nameHeader: string; + rowIdHeader?: string; +}): CsvImportPreview | null => { + const table = parseCsvText(input.csvText); + if (table.length === 0) { + return null; + } + + const headers = table[0] ?? []; + const mapping = mapCsvImportHeaders({ + headers, + fields: input.fields.filter(isCsvImportableField), + nameHeader: input.nameHeader, + rowIdHeader: input.rowIdHeader, + }); + + return { + headers, + rowCount: Math.max(table.length - 1, 0), + mapping, + missingNameColumn: mapping.nameColumnIndex === null, + }; +}; + +const parseFieldCell = ( + field: FieldAttributes, + raw: string +): FieldValue | null => { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + + if (field.type === 'select') { + const optionId = resolveSelectOptionId(field as SelectFieldAttributes, trimmed); + return optionId ? { type: 'string', value: optionId } : null; + } + + if (field.type === 'multi_select') { + const parts = trimmed + .split(',') + .map((part) => part.trim()) + .filter(Boolean); + const optionIds = parts + .map((part) => + resolveSelectOptionId( + field as unknown as SelectFieldAttributes, + part + ) + ) + .filter((value): value is string => Boolean(value)); + + return optionIds.length > 0 + ? { type: 'string_array', value: optionIds } + : null; + } + + if (field.type === 'collaborator') { + const parts = trimmed + .split(',') + .map((part) => part.trim()) + .filter(Boolean); + return parts.length > 0 ? { type: 'string_array', value: parts } : null; + } + + if (field.type === 'boolean') { + const lowered = trimmed.toLowerCase(); + if (lowered === 'true' || lowered === 'yes' || lowered === '1') { + return { type: 'boolean', value: true }; + } + if (lowered === 'false' || lowered === 'no' || lowered === '0') { + return { type: 'boolean', value: false }; + } + return null; + } + + return yamlValueToFieldValue(field, trimmed); +}; + +export const importDatabaseRecordsFromCsv = (input: { + workspace: WorkspaceLike; + database: DatabaseLike; + csvText: string; + nameHeader: string; + rowIdHeader?: string; +}): { importedCount: number; skippedCount: number } => { + const table = parseCsvText(input.csvText); + if (table.length <= 1) { + return { importedCount: 0, skippedCount: 0 }; + } + + const headers = table[0] ?? []; + const importableFields = input.database.fields.filter(isCsvImportableField); + const mapping = mapCsvImportHeaders({ + headers, + fields: importableFields, + nameHeader: input.nameHeader, + rowIdHeader: input.rowIdHeader, + }); + + let importedCount = 0; + let skippedCount = 0; + + for (const row of table.slice(1)) { + const name = + mapping.nameColumnIndex === null + ? '' + : (row[mapping.nameColumnIndex] ?? '').trim(); + + if (!name) { + skippedCount += 1; + continue; + } + + const fields: Record = {}; + for (const field of importableFields) { + const columnIndex = mapping.fieldColumnIndexes[field.id]; + if (columnIndex === undefined) { + continue; + } + + const value = parseFieldCell(field, row[columnIndex] ?? ''); + if (value) { + fields[field.id] = value; + } + } + + const record: LocalRecordNode = { + id: generateId(IdType.Record), + type: 'record', + parentId: input.database.id, + rootId: input.database.rootId, + databaseId: input.database.id, + name, + fields, + createdAt: new Date().toISOString(), + createdBy: input.workspace.userId, + updatedAt: null, + updatedBy: null, + localRevision: '0', + serverRevision: '0', + }; + + input.workspace.collections.nodes.insert(record); + importedCount += 1; + } + + return { importedCount, skippedCount }; +}; From 204d04d169d6a626b9660f47c2d5ad22bf973bd4 Mon Sep 17 00:00:00 2001 From: barry Date: Tue, 7 Jul 2026 13:22:29 +0800 Subject: [PATCH 02/20] Enhance documentation and UI components for Colanode. Update shipped features list to include `form-view.md`, improve loading indicators in the web app, and upgrade Vite to version 8.1.3. Introduce new scheduled task actions and logs, ensuring better handling of task execution results. Update Airtable alignment documentation to reflect progress and add new fields for scheduled tasks. --- .cursor/skills/colanode/SKILL.md | 2 +- .cursor/skills/colanode/reference.md | 3 + AGENTS.md | 2 +- apps/web/index.html | 17 +- apps/web/package.json | 2 +- apps/web/src/root.tsx | 14 +- apps/web/src/workers/dedicated.ts | 148 +++--- apps/web/vite.config.js | 8 +- docs/database-airtable-alignment.md | 56 +- docs/database-airtable-test-plan.md | 497 ++++++++++++++++++ docs/form-view.md | 154 ++++++ package-lock.json | 446 +++++++++++++++- .../src/lib/apply-collection-actions.ts | 24 + packages/client/src/lib/autonumber-fields.ts | 67 +++ .../client/src/lib/bidirectional-relation.ts | 163 ++++++ packages/client/src/lib/database-csv.ts | 11 + .../client/src/lib/format-number-field.ts | 38 ++ packages/client/src/lib/index.ts | 7 + .../client/src/lib/lookup-field-display.ts | 86 +++ packages/client/src/lib/mappers.ts | 2 + packages/client/src/lib/records.ts | 3 +- .../client/src/lib/rollup-field-display.ts | 136 +++++ .../client/src/lib/scheduled-task-actions.ts | 383 ++++++++++++++ .../src/lib/scheduled-task-meta-type.ts | 44 ++ .../src/lib/scheduled-task-record-mapper.ts | 16 + .../src/services/scheduled-task-service.ts | 38 +- packages/client/src/types/scheduled-tasks.ts | 53 ++ .../client/test/autonumber-fields.test.ts | 56 ++ .../test/bidirectional-relation.test.ts | 67 +++ .../client/test/format-number-field.test.ts | 24 + .../client/test/lookup-field-display.test.ts | 77 +++ .../client/test/rollup-field-display.test.ts | 148 ++++++ .../test/scheduled-task-actions.test.ts | 98 ++++ packages/core/src/lib/field-defaults.ts | 9 + packages/core/src/lib/scheduled-tasks.ts | 2 + .../core/src/registry/nodes/database-view.ts | 6 +- packages/core/src/registry/nodes/field.ts | 31 ++ packages/ui/src/components/app/app-error.tsx | 26 + .../ui/src/components/app/app-loading.tsx | 15 +- .../ui/src/components/app/app-provider.tsx | 40 +- .../databases/fields/field-create-popover.tsx | 173 +++++- .../databases/fields/field-delete-dialog.tsx | 31 ++ .../databases/fields/field-icon.tsx | 5 + .../databases/fields/field-select.tsx | 3 +- .../databases/fields/field-type-select.tsx | 8 + .../databases/fields/lookup-config.tsx | 103 ++++ .../fields/number-format-settings.tsx | 77 +++ .../databases/fields/rollup-config.tsx | 15 +- .../components/databases/forms/form-view.tsx | 211 ++++++++ .../databases/search/view-filters.tsx | 17 +- .../databases/tables/table-view-body.tsx | 15 + .../tables/table-view-bulk-action-bar.tsx | 117 +++++ .../tables/table-view-bulk-set-dialog.tsx | 302 +++++++++++ .../tables/table-view-field-header.tsx | 7 + .../databases/tables/table-view-footer.tsx | 129 +++++ .../databases/tables/table-view-group.tsx | 31 +- .../tables/table-view-grouped-by-field.tsx | 22 + .../table-view-grouped-collaborator.tsx | 135 +++++ .../tables/table-view-grouped-created-by.tsx | 100 ++++ .../tables/table-view-grouped-select.tsx | 78 +++ .../databases/tables/table-view-grouped.tsx | 102 ++-- .../databases/tables/table-view-header.tsx | 21 + .../tables/table-view-nested-grouped.tsx | 160 ++++++ .../databases/tables/table-view-row.tsx | 26 +- .../tables/table-view-selection-provider.tsx | 127 +++++ .../databases/tables/table-view.tsx | 76 +-- .../databases/view-create-dialog.tsx | 11 +- .../databases/view-group-settings.tsx | 128 +++-- .../ui/src/components/databases/view-icon.tsx | 5 + packages/ui/src/components/databases/view.tsx | 3 + .../components/nodes/node-reminder-helpers.ts | 1 + .../pages/meta-type-page-create-dialog.tsx | 23 +- .../components/records/record-field-value.tsx | 6 + .../values/record-autonumber-value.tsx | 21 + .../records/values/record-lookup-value.tsx | 129 +++++ .../records/values/record-number-value.tsx | 16 + .../records/values/record-rollup-value.tsx | 201 +++---- .../scheduled-task-actions-section.tsx | 179 +++++++ .../scheduled-tasks/scheduled-task-form.tsx | 5 + .../scheduled-tasks/scheduled-task-helpers.ts | 2 + .../scheduled-tasks/scheduled-task-list.tsx | 12 + .../workspace-scheduled-tasks-container.tsx | 2 + packages/ui/src/contexts/database-view.ts | 1 + .../ui/src/contexts/table-view-selection.ts | 32 ++ .../use-database-sql-collection-query.tsx | 38 +- packages/ui/src/hooks/use-entity-field.tsx | 26 +- packages/ui/src/lib/bidirectional-relation.ts | 78 +++ packages/ui/src/lib/bulk-record-operations.ts | 78 +++ packages/ui/src/lib/collection-view-query.ts | 112 ++++ packages/ui/src/lib/database-csv-import.ts | 22 +- packages/ui/src/lib/databases.ts | 8 +- packages/ui/src/lib/page-markdown-fields.ts | 2 + packages/ui/src/lib/record-create.ts | 31 +- packages/ui/src/lib/record-duplicate.ts | 103 ++++ packages/ui/src/lib/table-view-selection.ts | 19 + packages/ui/src/lib/view-grouping.ts | 49 ++ .../collection-view-query-formula.test.ts | 71 +++ packages/ui/test/table-view-selection.test.ts | 27 + 98 files changed, 6105 insertions(+), 446 deletions(-) create mode 100644 docs/database-airtable-test-plan.md create mode 100644 docs/form-view.md create mode 100644 packages/client/src/lib/autonumber-fields.ts create mode 100644 packages/client/src/lib/bidirectional-relation.ts create mode 100644 packages/client/src/lib/format-number-field.ts create mode 100644 packages/client/src/lib/lookup-field-display.ts create mode 100644 packages/client/src/lib/rollup-field-display.ts create mode 100644 packages/client/src/lib/scheduled-task-actions.ts create mode 100644 packages/client/test/autonumber-fields.test.ts create mode 100644 packages/client/test/bidirectional-relation.test.ts create mode 100644 packages/client/test/format-number-field.test.ts create mode 100644 packages/client/test/lookup-field-display.test.ts create mode 100644 packages/client/test/rollup-field-display.test.ts create mode 100644 packages/client/test/scheduled-task-actions.test.ts create mode 100644 packages/ui/src/components/app/app-error.tsx create mode 100644 packages/ui/src/components/databases/fields/lookup-config.tsx create mode 100644 packages/ui/src/components/databases/fields/number-format-settings.tsx create mode 100644 packages/ui/src/components/databases/forms/form-view.tsx create mode 100644 packages/ui/src/components/databases/tables/table-view-bulk-action-bar.tsx create mode 100644 packages/ui/src/components/databases/tables/table-view-bulk-set-dialog.tsx create mode 100644 packages/ui/src/components/databases/tables/table-view-footer.tsx create mode 100644 packages/ui/src/components/databases/tables/table-view-grouped-by-field.tsx create mode 100644 packages/ui/src/components/databases/tables/table-view-grouped-collaborator.tsx create mode 100644 packages/ui/src/components/databases/tables/table-view-grouped-created-by.tsx create mode 100644 packages/ui/src/components/databases/tables/table-view-grouped-select.tsx create mode 100644 packages/ui/src/components/databases/tables/table-view-nested-grouped.tsx create mode 100644 packages/ui/src/components/databases/tables/table-view-selection-provider.tsx create mode 100644 packages/ui/src/components/records/values/record-autonumber-value.tsx create mode 100644 packages/ui/src/components/records/values/record-lookup-value.tsx create mode 100644 packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-actions-section.tsx create mode 100644 packages/ui/src/contexts/table-view-selection.ts create mode 100644 packages/ui/src/lib/bidirectional-relation.ts create mode 100644 packages/ui/src/lib/bulk-record-operations.ts create mode 100644 packages/ui/src/lib/record-duplicate.ts create mode 100644 packages/ui/src/lib/table-view-selection.ts create mode 100644 packages/ui/src/lib/view-grouping.ts create mode 100644 packages/ui/test/collection-view-query-formula.test.ts create mode 100644 packages/ui/test/table-view-selection.test.ts diff --git a/.cursor/skills/colanode/SKILL.md b/.cursor/skills/colanode/SKILL.md index fbdcf0866..edb6d993c 100644 --- a/.cursor/skills/colanode/SKILL.md +++ b/.cursor/skills/colanode/SKILL.md @@ -46,7 +46,7 @@ description: >- | Assets library | `settings/assets` | `ASSET_LIBRARY_SYSTEM_KIND`, `META_TYPES_SPACE_MARKER` | | Notifications | `settings/notifications` + sidebar bell | `notification.*` mutations | -See `docs/page-meta-types.md`, `docs/todo-list.md`, `docs/asset-library.md`. Database Airtable alignment plan: `docs/database-airtable-alignment.md`. +See `docs/page-meta-types.md`, `docs/todo-list.md`, `docs/form-view.md`, `docs/asset-library.md`. Database Airtable alignment plan: `docs/database-airtable-alignment.md`. ## Adding a client operation diff --git a/.cursor/skills/colanode/reference.md b/.cursor/skills/colanode/reference.md index 6df29bcbe..6b8b5be38 100644 --- a/.cursor/skills/colanode/reference.md +++ b/.cursor/skills/colanode/reference.md @@ -48,6 +48,9 @@ Mutations change user data — not exposed via MCP server (read-only MVP). |-----|-------| | `docs/page-meta-types.md` | Page meta type schemas | | `docs/todo-list.md` | TodoList system task database | +| `docs/form-view.md` | Database Form view (field types, limits) | +| `docs/database-airtable-alignment.md` | Airtable alignment plan (Phase A–C) | +| `docs/database-airtable-test-plan.md` | Manual QA for Airtable features | | `docs/asset-library.md` | Global Assets + folder filters | | `docs/local-sync.md` | Local sync | | `docs/tags-implementation-guide.md` | Workspace tags (**feat/sync_local only**; not on Colapp `main`) | diff --git a/AGENTS.md b/AGENTS.md index c24d044a7..1aabdac07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ Colanode is a local-first collaboration workspace. Clients use local SQLite stor ## Planning & feature docs - Product roadmap and candidate directions: `docs/roadmap.md` -- Shipped features: `docs/page-meta-types.md`, `docs/todo-list.md`, `docs/local-sync.md`, `docs/asset-library.md`, etc. +- Shipped features: `docs/page-meta-types.md`, `docs/todo-list.md`, `docs/local-sync.md`, `docs/asset-library.md`, `docs/form-view.md`, etc. - Planned: `docs/database-airtable-alignment.md` (database / 多维表格 → Airtable). Workspace tags: see `docs/tags-implementation-guide.md` (**not on this branch**; `feat/sync_local` only). ## AI agents (MCP + Skills) diff --git a/apps/web/index.html b/apps/web/index.html index f8ff73e2a..592d186ef 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -30,7 +30,22 @@ -
+
+

+ Loading Colanode… +

+
diff --git a/apps/web/package.json b/apps/web/package.json index c083ec14a..88417420e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,7 +24,7 @@ "@vitejs/plugin-react": "^5.2.0", "jsdom": "^28.1.0", "tailwindcss": "^4.2.2", - "vite": "^7.3.1", + "vite": "^8.1.3", "vite-plugin-pwa": "^1.2.0", "vitest": "^4.1.2" } diff --git a/apps/web/src/root.tsx b/apps/web/src/root.tsx index 41f89665c..5d4368ebe 100644 --- a/apps/web/src/root.tsx +++ b/apps/web/src/root.tsx @@ -8,8 +8,18 @@ const App = lazy(() => ); const RootLoading = () => ( -
-

Loading…

+
+

+ Loading… +

); diff --git a/apps/web/src/workers/dedicated.ts b/apps/web/src/workers/dedicated.ts index 1d5868816..75ad07102 100644 --- a/apps/web/src/workers/dedicated.ts +++ b/apps/web/src/workers/dedicated.ts @@ -44,45 +44,16 @@ broadcast.onmessage = (event) => { handleMessage(event.data); }; -navigator.locks.request('colanode', async () => { - const appMeta: AppMeta = { - type: 'web', - platform: navigator.userAgent, - }; - - const bootstrap = await WebBootstrapService.create(path, fs); - if (bootstrap.needsFreshInstall) { - appInitOutput = 'reset'; - - if (pendingPromises.has('init')) { - const promise = pendingPromises.get('init'); - if (promise && promise.type === 'init') { - promise.resolve(appInitOutput); - } - } - - broadcastMessage({ - type: 'init_result', - result: appInitOutput, - }); - - return; - } - - app = new AppService(appMeta, fs, new WebKyselyService(), path); - - await app.migrate(); - await app.init(); - await bootstrap.updateVersion(build.version); - - await app.metadata.set('app', 'version', build.version); - await app.metadata.set('app', 'platform', appMeta.platform); +const broadcastMessage = (message: BroadcastMessage) => { + broadcast.postMessage(message); +}; - appInitOutput = 'success'; +const finishInit = (result: AppInitOutput) => { + appInitOutput = result; broadcastMessage({ type: 'init_result', - result: appInitOutput, + result, }); const ids = Array.from(pendingPromises.keys()); @@ -93,40 +64,76 @@ navigator.locks.request('colanode', async () => { } if (promise.type === 'init') { - promise.resolve(appInitOutput); - } else if (promise.type === 'query') { - const result = await app.mediator.executeQuery(promise.input); - promise.resolve(result); - } else if (promise.type === 'query_and_subscribe') { - const result = await app.mediator.executeQueryAndSubscribe( - promise.key, - promise.windowId, - promise.input - ); - promise.resolve(result); - } else if (promise.type === 'mutation') { - const result = await app.mediator.executeMutation(promise.input); promise.resolve(result); + pendingPromises.delete(id); } - - pendingPromises.delete(id); } +}; - eventBus.subscribe((event) => { - broadcastMessage({ - type: 'event', - windowId, - event, +navigator.locks.request('colanode', async () => { + try { + const appMeta: AppMeta = { + type: 'web', + platform: navigator.userAgent, + }; + + const bootstrap = await WebBootstrapService.create(path, fs); + if (bootstrap.needsFreshInstall) { + finishInit('reset'); + return; + } + + app = new AppService(appMeta, fs, new WebKyselyService(), path); + + await app.migrate(); + await app.init(); + await bootstrap.updateVersion(build.version); + + await app.metadata.set('app', 'version', build.version); + await app.metadata.set('app', 'platform', appMeta.platform); + + finishInit('success'); + + const ids = Array.from(pendingPromises.keys()); + for (const id of ids) { + const promise = pendingPromises.get(id); + if (!promise) { + continue; + } + + if (promise.type === 'query') { + const result = await app.mediator.executeQuery(promise.input); + promise.resolve(result); + } else if (promise.type === 'query_and_subscribe') { + const result = await app.mediator.executeQueryAndSubscribe( + promise.key, + promise.windowId, + promise.input + ); + promise.resolve(result); + } else if (promise.type === 'mutation') { + const result = await app.mediator.executeMutation(promise.input); + promise.resolve(result); + } + + pendingPromises.delete(id); + } + + eventBus.subscribe((event) => { + broadcastMessage({ + type: 'event', + windowId, + event, + }); }); - }); + } catch (error) { + console.error('Colanode worker init failed', error); + finishInit('error'); + } - await new Promise(() => { }); + await new Promise(() => {}); }); -const broadcastMessage = (message: BroadcastMessage) => { - broadcast.postMessage(message); -}; - const handleMessage = async (message: BroadcastMessage) => { if (message.type === 'init') { if (!appInitOutput) { @@ -247,11 +254,26 @@ const api: ColanodeWorkerApi = { type: 'init', }; + const INIT_TIMEOUT_MS = 120_000; + const promise = new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + if (pendingPromises.has('init')) { + pendingPromises.delete('init'); + reject('App initialization timed out'); + } + }, INIT_TIMEOUT_MS); + pendingPromises.set('init', { type: 'init', - resolve, - reject, + resolve: (result) => { + clearTimeout(timeoutId); + resolve(result); + }, + reject: (error) => { + clearTimeout(timeoutId); + reject(error); + }, }); }); broadcastMessage(message); diff --git a/apps/web/vite.config.js b/apps/web/vite.config.js index c8532b13d..5581849ee 100644 --- a/apps/web/vite.config.js +++ b/apps/web/vite.config.js @@ -6,6 +6,11 @@ import { VitePWA } from 'vite-plugin-pwa'; // https://vitejs.dev/config/ export default defineConfig({ + server: { + warmup: { + clientFiles: ['./index.html', './src/main.tsx', './src/root.tsx'], + }, + }, test: { globals: true, environment: 'jsdom', @@ -34,8 +39,7 @@ export default defineConfig({ base: '/', includeAssets: ['favicon.ico'], devOptions: { - enabled: true, - type: 'module', + enabled: false, }, srcDir: 'src/workers', filename: 'service.ts', diff --git a/docs/database-airtable-alignment.md b/docs/database-airtable-alignment.md index 98337dc88..49091ec35 100644 --- a/docs/database-airtable-alignment.md +++ b/docs/database-airtable-alignment.md @@ -1,6 +1,7 @@ # Database — Airtable Alignment Plan -> Status: **in progress** (Phase A — A1 CSV export ✓ on branch) +> Status: **Phase C MVP complete** on branch `feat/airtable-phase-a` +> Manual test plan: [database-airtable-test-plan.md](./database-airtable-test-plan.md) > Branch target: `feat/airtable-phase-a` → `main` > Related: [record-query.md](./record-query.md), [page-meta-types.md](./page-meta-types.md), [file-meta-types.md](./file-meta-types.md), [roadmap.md](./roadmap.md) @@ -110,7 +111,7 @@ Scheduled Tasks: schedule, query match, event triggers (`database_records_create | CSV in/out | Missing | A | | Bulk edit | Missing | A | | Column summaries | Missing | A | -| Automations | Partial | C | +| Automations | Partial | C (MVP ✓) | | Interfaces | Missing | D (thin) | | Record comments | Missing | D | @@ -126,12 +127,12 @@ Scheduled Tasks: schedule, query match, event triggers (`database_records_create |----|------|-------------|---------------------| | A1 | CSV export | Export current view columns + types; UTF-8 CSV download | UI: database menu; map `CollectionRow` → CSV | ✓ | | A2 | CSV import | Map headers → fields; validate types; `record.create` batch | New mutation or service; import dialog | ✓ | -| A3 | `autonumber` field | Per-database monotonic ID; schema + immutability on create | `packages/core/src/registry/nodes/field.ts`, record create path | -| A4 | `lookup` field | Pull field from related row via `relationFieldId` + `lookedUpFieldId` | New field type; UI like rollup config; read-only cell | -| A5 | Bidirectional relation | Option on relation create: add reverse column on target DB | Extend relation field attrs or paired-field convention | -| A6 | Row multi-select | Checkbox column; selection state in view context | `table-view-row.tsx`, bulk action bar | -| A7 | Bulk operations | Bulk set field, bulk delete (editor role) | Mutations wrapping `collection.applyActions` / `record.delete` | -| A8 | Column footer stats | count / sum / avg for visible numeric columns | Reuse `database.query` aggregate for current filters | +| A3 | `autonumber` field | Per-database monotonic ID; schema + immutability on create | `packages/core/src/registry/nodes/field.ts`, record create path | ✓ | +| A4 | `lookup` field | Pull field from related row via `relationFieldId` + `lookedUpFieldId` | New field type; UI like rollup config; read-only cell | ✓ | +| A5 | Bidirectional relation | Option on relation create: add reverse column on target DB | Extend relation field attrs or paired-field convention | ✓ | +| A6 | Row multi-select | Checkbox column; selection state in view context | `table-view-row.tsx`, bulk action bar | ✓ | +| A7 | Bulk operations | Bulk set field, bulk delete (editor role) | Mutations wrapping `collection.applyActions` / `record.delete` | ✓ | +| A8 | Column footer stats | count / sum / avg for visible numeric columns | Reuse `database.query` aggregate for current filters | ✓ | **Suggested branch:** `feat/airtable-phase-a` @@ -147,17 +148,17 @@ Scheduled Tasks: schedule, query match, event triggers (`database_records_create ### Phase B — Fields & views (est. 2–3 weeks) -| ID | Task | Description | -|----|------|-------------| -| B1 | Rollup functions | `show_values`, `unique`, `concatenate` (display), beyond numeric aggs | -| B2 | Number formats | `format`: `plain` \| `percent` \| `currency` on number fields | -| B3 | Formula in filters | Phase 1: client-side post-filter on view rows; Phase 2: optional materialized column | -| B4 | Form view | New `database_view.layout: 'form'`; ordered fields → create record (workspace-only) | -| B5 | Duplicate row | Copy `name` + `fields` (+ optional document) | -| B6 | Multi-level grouping | `groupBy: string[]` (start with 2 levels in table) | -| B7 | Table group fields | Align table grouping with board where sensible (e.g. `collaborator`) | +| ID | Task | Description | Status | +|----|------|-------------|--------| +| B1 | Rollup functions | `show_values`, `unique`, `concatenate` (display), beyond numeric aggs | ✓ | +| B2 | Number formats | `format`: `plain` \| `percent` \| `currency` on number fields | ✓ | +| B3 | Formula in filters | Phase 1: client-side post-filter on view rows; Phase 2: optional materialized column | ✓ | +| B4 | Form view | New `database_view.layout: 'form'`; ordered fields → create record (workspace-only) | ✓ — see [form-view.md](./form-view.md) | +| B5 | Duplicate row | Copy `name` + `fields` (+ optional document) | ✓ | +| B6 | Multi-level grouping | `groupBy: string[]` (start with 2 levels in table) | ✓ | +| B7 | Table group fields | Align table grouping with board where sensible (e.g. `collaborator`) | ✓ | -**Acceptance test:** Form view creates rows with defaults; duplicated row editable; rollup shows concatenated labels from linked records. +**Acceptance test:** Form view creates rows with defaults; duplicated row editable; rollup shows concatenated labels from linked records. Form field matrix: [form-view.md](./form-view.md). --- @@ -171,15 +172,15 @@ Extend **Scheduled Tasks** rather than a parallel system. Trigger → (optional) Condition → Action(s) ``` -| ID | Task | Description | -|----|------|-------------| -| C1 | Automation wizard UI | When → If → Then; wraps existing checks | -| C2 | Actions: `setField` | Update fields on triggering or related record | -| C3 | Actions: `createRecord` | Create row in target database with field map | -| C4 | Actions: `linkRecords` | Append relation IDs | -| C5 | Actions: `runScript` | Reuse `database.script.run` (advanced) | -| C6 | Execution log | Persist run id, trigger payload, action results on task run record | -| C7 | Instant triggers | Reuse `eventBus` for record/meta type events (already partial) | +| ID | Task | Description | Status | +|----|------|-------------|--------| +| C1 | Automation wizard UI | When → If → Then; wraps existing checks | ✓ (actions section in task form) | +| C2 | Actions: `setField` | Update fields on triggering or related record | ✓ | +| C3 | Actions: `createRecord` | Create row in target database with field map | ✓ (engine; UI partial) | +| C4 | Actions: `linkRecords` | Append relation IDs | ✓ (engine; UI partial) | +| C5 | Actions: `runScript` | Reuse `database.script.run` (advanced) | deferred | +| C6 | Execution log | Persist run id, trigger payload, action results on task run record | ✓ | +| C7 | Instant triggers | Reuse `eventBus` for record/meta type events (already partial) | ✓ (existing) | **Existing code to extend:** @@ -257,6 +258,7 @@ Prefer extending Scheduled Task `check` + new `actions[]` on the same record sha | Area | Paths | |------|-------| | Field schemas | `packages/core/src/registry/nodes/field.ts`, `field-value.ts` | +| Bidirectional relation | `packages/client/src/lib/bidirectional-relation.ts`, `packages/ui/src/lib/bidirectional-relation.ts`, `packages/ui/src/hooks/use-entity-field.tsx` | | CSV | `packages/client/src/lib/database-csv.ts`, `packages/ui/src/lib/database-csv-export.ts`, `packages/ui/src/lib/database-csv-import.ts`, `packages/ui/src/components/databases/view-csv-export-menu-item.tsx`, `view-csv-import-menu-item.tsx`, `view-export-settings.tsx` | | Lookup / autonumber UI | `packages/ui/src/components/databases/fields/`, `records/values/` | | Bulk edit | `packages/ui/src/components/databases/tables/` | diff --git a/docs/database-airtable-test-plan.md b/docs/database-airtable-test-plan.md new file mode 100644 index 000000000..90229b713 --- /dev/null +++ b/docs/database-airtable-test-plan.md @@ -0,0 +1,497 @@ +# Database Airtable Alignment — 完整测试流程 + +> 分支:`feat/airtable-phase-a` +> 覆盖:**Phase A**、**Phase B**、**Phase C (MVP)** +> 推荐环境:**桌面本地包**(`LOCAL_ONLY=true`) +> 计划文档:[database-airtable-alignment.md](./database-airtable-alignment.md) + +--- + +## 0. 测试前准备 + +### 0.1 启动应用 + +**推荐:已打好的桌面包** + +```text +/Users/barry/git/colanode/apps/desktop/out/Colapp-darwin-arm64/Colapp.app +``` + +或 DMG:`apps/desktop/out/make/Colapp-1.0.0-arm64.dmg` + +**开发模式(可选)** + +```bash +cd apps/desktop && LOCAL_ONLY=true npm run dev +``` + +> Web 端(`apps/web`)当前有空白页问题,本轮测试请优先用桌面端。 + +### 0.2 创建工作区 + +1. 打开 Colapp,登录或创建本地账号 +2. 进入任意 **Space**,确认有 **编辑** 权限 +3. 记下 Space 名称,后续步骤均在此 Space 内操作 + +### 0.3 准备测试数据库 + +#### 数据库 1:`Contacts`(先建) + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| Name | text | | +| Email | email | 可选 | + +手动添加 2–3 条记录,例如:`Acme Inc`、`Beta LLC`。 + +#### 数据库 2:`Customers`(主测库) + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| Name | text | | +| Status | select | 选项:`Todo`、`Done` | +| Owner | collaborator | | +| Amount | number | 先 plain,B2 再改格式 | +| Completed date | date | Phase C 自动化用 | +| Company | relation | 指向 `Contacts` | +| Company name | lookup | 通过 Company → Name | +| Record # | autonumber | | +| Score | formula | 例:`prop("Amount")` | +| Tags | multi_select | 选项:`A`、`B`、`C` | + +先手动录入 3–5 条样例数据,或等 A2 导入后再补字段。 + +#### 测试用 CSV(A2 / 端到端) + +保存为 `customers-import.csv`: + +```csv +Name,Status,Amount +Acme Corp,Todo,1200 +Beta LLC,Done,800 +Gamma Co,Todo,350 +``` + +### 0.4 测试记录表(建议打印或复制到笔记) + +每条用 `[ ]` 勾选,记录 **通过 / 失败 / 备注**。 + +| ID | 功能 | 结果 | 备注 | +|----|------|------|------| +| A1 | CSV 导出 | | | +| A2 | CSV 导入 | | | +| A3 | Autonumber | | | +| A4 | Lookup | | | +| A5 | 双向 Relation | | | +| A6 | 行多选 | | | +| A7 | 批量设字段 / 删除 | | | +| A8 | 列底统计 | | | +| B1 | Rollup 扩展 | | | +| B2 | 数字格式 | | | +| B3 | 公式筛选 | | | +| B4 | Form 视图 | | | +| B5 | 复制行 | | | +| B6 | 二级分组 | | | +| B7 | Collaborator 分组 | | | +| C1 | 自动化 Set field | | | +| C6 | 执行日志 | | | +| REG | 回归冒烟 | | | +| E2E | 端到端场景 | | | + +--- + +## 1. 推荐测试顺序(约 60–90 分钟) + +```text +准备数据 → Phase A (A1–A8) → Phase B (B1–B7) → Phase C → 端到端 → 回归 +``` + +按此顺序测,后面步骤会复用前面建好的字段和关系。 + +--- + +## 2. Phase A — 像 Base 一样用 + +### A1 — CSV 导出 + +1. 打开 `Customers` 的 **Table** 视图 +2. 设置筛选:Status = `Todo`;任意排序 +3. 视图设置 / 数据库菜单 → **Export CSV** +4. 保存文件并用文本编辑器打开 + +**预期** + +- [ ] UTF-8,中文不乱码 +- [ ] 仅导出当前视图可见列 +- [ ] lookup / formula / rollup 列导出为空或合理占位 +- [ ] number 为原始数值(非格式化字符串) + +--- + +### A2 — CSV 导入 + +1. `Customers` → 视图设置 → **Import CSV** +2. 选择 `customers-import.csv` +3. 映射列:Name → Name,Status → Status,Amount → Amount +4. 确认导入 + +**预期** + +- [ ] 新增 3 行(或跳过重复策略符合产品设计) +- [ ] select 按选项名匹配(`Todo` / `Done`) +- [ ] number 正确写入 +- [ ] autonumber 自动分配(若字段已存在) +- [ ] **meta_type** 库不显示导入入口 + +--- + +### A3 — Autonumber + +1. 若尚无 `Record #` 字段:添加 **autonumber** 字段 +2. 新建一条记录 + +**预期** + +- [ ] 新记录获得递增编号 +- [ ] 网格中该列只读,不可手改 + +--- + +### A4 — Lookup + +1. 确认 `Company` relation 指向 `Contacts` +2. 为某 Customer 链接一条 Contact +3. 添加 **lookup**:relation = Company,looked-up = Name(显示为 Company name) + +**预期** + +- [ ] Lookup 显示所链 Contact 的 Name +- [ ] 只读,不可直接编辑 lookup 单元格 +- [ ] 更换链接后 lookup 同步更新 + +--- + +### A5 — Bidirectional relation(双向关联) + +1. 在 `Customers` 新建或编辑 relation → `Contacts` +2. 勾选 **Link records from both sides**(双向链接) +3. 在 Customer 行链接 Contact +4. 打开 `Contacts` 表,查看反向列 +5. 在 Contacts 侧取消链接 +6. (可选)删除 relation 字段 + +**预期** + +- [ ] Contacts 表出现反向 relation 列 +- [ ] 任一侧链接/取消,另一侧同步 +- [ ] 删除 relation 时反向字段一并移除 + +--- + +### A6 — Row multi-select(行多选) + +1. `Customers` Table 视图(需编辑权限) +2. 确认左侧出现 **复选框列** +3. 勾选 2–3 行 +4. 点击表头复选框(全选当前可见行) + +**预期** + +- [ ] 选中行高亮 +- [ ] 底部出现批量操作栏,显示选中数量 +- [ ] 无编辑权限或 **locked** 库不出现复选框 + +--- + +### A7 — Bulk operations(批量操作) + +1. 多选 3 行 +2. 批量栏 → **Set field** → 选 `Status` 或 `Owner` → 应用 +3. 再次多选 → **Delete** → 确认 + +**预期** + +- [ ] 所有选中行字段一并更新 +- [ ] 批量删除成功,行从表中消失 +- [ ] 操作后视图刷新正常 + +--- + +### A8 — Column footer stats(列底统计) + +1. 使用 **未分组** 的 Table 视图 +2. 确保 `Amount` 列可见 +3. 滚动到表格最底部统计行 + +**预期** + +- [ ] 显示记录总数 +- [ ] number 列显示 Sum / Avg +- [ ] 有筛选时统计反映筛选结果(或符合当前实现范围) + +--- + +## 3. Phase B — 字段与视图 + +### B1 — Rollup 扩展 + +1. `Customers` 添加 rollup 字段 +2. relation = Company(或能链到多行的 relation) +3. 分别测试 aggregation:**Show values**、**Unique**、**Concatenate** +4. 为同一 Customer 链接多个 Contact + +**预期** + +- [ ] 非数字聚合能显示文本列表 +- [ ] Unique 去重 +- [ ] Concatenate 用合理分隔符拼接 + +--- + +### B2 — Number formats(数字格式) + +1. 编辑 `Amount` 字段 → **Format** +2. 依次设为 **Percent**、**Currency (USD)** +3. 查看单元格显示与 A8 列底统计 + +**预期** + +- [ ] 显示带 `%` 或 `$`(或项目约定格式) +- [ ] 编辑时仍为原始数字 +- [ ] 列底统计与格式一致 + +--- + +### B3 — Formula in filters(公式参与筛选) + +1. 确认 `Score` formula = `prop("Amount")`(或等价) +2. 视图筛选:Score **大于** `500` +3. 对比关闭筛选时的行数 + +**预期** + +- [ ] 仅显示满足条件的行 +- [ ] 公式列未进入 SQL 时,行为为客户端 post-filter(大数据量可能略慢) + +--- + +### B4 — Form view(表单视图) + +> 字段类型与限制详见 [form-view.md](./form-view.md) + +1. `Customers` → 新建视图 → 类型 **Form** +2. 确认表单显示:**Name**(记录标题)+ 基础 schema 字段(text / number / select / date 等) +3. 确认 **不显示**:relation、lookup、formula、rollup、collaborator、multi_select、autonumber +4. 填写 Name、Status、Amount 等 → **Create record** +5. (可选)视图设置 → Fields → 用眼睛图标隐藏某字段,确认表单同步更新 + +**预期** + +- [ ] 成功创建新行 +- [ ] `select` 下拉选项正确写入 +- [ ] `date` 字段可手填 ISO 日期(如 `2026-07-07`),暂无日历控件 +- [ ] relation / lookup 等复杂类型不出现在表单中 +- [ ] 切回 Table 可见新记录 + +**不应测(MVP 范围外)** + +- [ ] Form 里选择关联记录(relation) +- [ ] Form 里编辑已有记录 +- [ ] 公开分享表单链接 + +--- + +### B5 — Duplicate row(复制行) + +1. Table 视图多选 1–2 条 **record** 行 +2. 批量栏 → **Duplicate** + +**预期** + +- [ ] 生成副本,名称带 ` (copy)` 后缀(或项目约定) +- [ ] autonumber 为新值 +- [ ] 副本可独立编辑 + +--- + +### B6 — Multi-level grouping(二级分组) + +1. Table 视图设置 → **Group by** = `Status` +2. **Then by** = `Tags` +3. 展开各级分组 + +**预期** + +- [ ] 先按 Status,再按 Tags 嵌套 +- [ ] 折叠/展开正常 +- [ ] 组内记录归属正确 + +--- + +### B7 — Table group by collaborator + +1. Table 视图 **Group by** = `Owner` 或 `Created by` + +**预期** + +- [ ] 按用户分组,带头像/名称(与 Board 风格一致) +- [ ] 组内记录正确 + +--- + +## 4. Phase C — 自动化(MVP) + +### 4.1 获取字段 ID(Set field 需要) + +1. 打开 `Customers` 数据库 schema / 字段设置 +2. 找到 **Completed date** 字段的内部 ID(形如普通 field id) +3. 记下该 ID,填入自动化 Action + +### C1 + C2 — 字段变更 → 设今天日期 → 通知 + +**场景:** Status 变为 Done 时,自动填写 Completed date = 今天,并发送通知。 + +1. **Settings** → **Scheduled Tasks** → **New task** +2. 名称:`Status done → complete date` +3. **Schedule**:可用 Event 触发(非定时) +4. **Check / Trigger**:Event → **Database record field changed** + - Database:`Customers` + - Field:`Status` +5. **Actions** → Add **Set field** + - Target:**Matched records** + - Field ID:Completed date 的 field id + - Value:**Today (date)** +6. **Notify**:取消 silent(如需桌面通知) +7. 保存并 **Enable** +8. 找一条 Status = `Todo` 的记录,改为 `Done` +9. 等待 1–2 秒,或打开任务点 **Run now** +10. 回到任务列表查看 **Last actions** + +**预期** + +- [ ] Completed date 变为当天 +- [ ] 收到应用内/系统通知(若已开启) +- [ ] 任务列表 **Last actions** 显示 setField 成功 +- [ ] 任务记录 **Last run log** 字段有 JSON 日志 + +### C6 — 执行日志 + +重复触发一次(或 Run now) + +**预期** + +- [ ] Last actions 摘要更新 +- [ ] 失败时显示错误信息(可故意填错 field id 验证) + +### C7 — 即时触发 + +与 C1 相同步骤,重点观察 **改字段后 1–2 秒内** 是否触发,无需等到定时。 + +**预期** + +- [ ] eventBus 路径生效,非仅 cron + +### C3/C4 — 引擎能力(UI 未完整) + +`createRecord`、`linkRecords` 已在引擎实现,表单 UI 仅支持 **setField**。本轮可跳过,或后续用模板/JSON 测。 + +--- + +## 5. 端到端验收(Phase A 官方场景) + +按顺序跑通一整条业务链: + +1. [ ] 向 `Customers` **导入** `customers-import.csv` +2. [ ] 配置 **Company** relation → `Contacts`,添加 **lookup** Company name +3. [ ] Table **按 Status 分组** +4. [ ] **多选**若干行 → 批量设置 **Owner** +5. [ ] **导出 CSV**,再 **导入**标量字段(Name/Status/Amount) +6. [ ] 确认 text/number/select 无乱码、无意外覆盖 + +--- + +## 6. 回归冒烟(15 分钟) + +| # | 区域 | 操作 | 预期 | +|---|------|------|------| +| R1 | Board 视图 | 打开 Customers Board | 无报错,卡片正常 | +| R2 | Calendar 视图 | 用 Completed date 建日历视图 | 可打开、可快速创建 | +| R3 | Gallery / List | 切换布局 | 正常渲染 | +| R4 | Meta type DB | 打开 Page/File meta 库 | 无 CSV 导入;导出可用 | +| R5 | Locked DB | 打开系统只读库 | 无批量复选框 | +| R6 | 旧版 Scheduled Task | 每日提醒类任务 | 仍能触发通知 | +| R7 | TodoList | Settings → TodoList | 路径与数据正常 | +| R8 | 页面 / 文档 | 打开任意 Page | 编辑、保存正常 | + +--- + +## 7. 自动化测试(可选,给开发用) + +```bash +# 单元测试 +cd packages/client && npm run test -- --run \ + database-csv autonumber lookup bidirectional-relation \ + rollup-field-display format-number-field scheduled-task-actions + +cd packages/ui && npm run test -- --run collection-view-query-formula + +# 类型检查 +cd packages/ui && npm run compile +cd packages/client && npm run build +``` + +--- + +## 8. 已知限制(测时留意) + +| 项 | 说明 | +|----|------| +| 公式筛选 | 仅客户端 post-filter,不进 SQL | +| Form 视图 | 仅基础标量字段;详见 [form-view.md](./form-view.md) | +| Lookup Name | 选虚拟字段 **Name**(记录标题),非 schema text 字段 | +| 自动化 UI | 仅 **setField** 可视化配置 | +| createRecord / linkRecords | 引擎有,UI 未完整 | +| runScript | 未启用 | +| 二级分组计数 | 子组计数可能与全局字段计数不一致 | +| 双向 relation | 表格脚本/button 路径可能未同步 | +| Web 端 | 当前空白页,请用桌面包测试 | + +--- + +## 9. 提 Bug 时请附带 + +1. 数据库类型:`records` / `meta_type` / 系统库 +2. 视图类型与筛选、分组配置 +3. 涉及的字段类型与 field id +4. 复现步骤 + 预期 vs 实际 +5. 环境:桌面包路径或 `LOCAL_ONLY` dev +6. 截图 / Console 报错(如有) + +--- + +## 10. 快速检查清单(一页纸) + +```text +[ ] 0 桌面 Colapp 启动,Space 可编辑 +[ ] A1 CSV 导出 +[ ] A2 CSV 导入 +[ ] A3 Autonumber +[ ] A4 Lookup +[ ] A5 双向 Relation +[ ] A6 行多选 +[ ] A7 批量设字段 + 删除 +[ ] A8 列底统计 +[ ] B1 Rollup concatenate/unique/show_values +[ ] B2 数字 percent/currency +[ ] B3 公式筛选 +[ ] B4 Form 视图建记录 +[ ] B5 复制行 +[ ] B6 二级分组 +[ ] B7 Collaborator 分组 +[ ] C1 Status→Done 自动填 Completed date + 通知 +[ ] C6 Last actions / run log +[ ] E2E 导入→关联→分组→批量→导出→再导入 +[ ] REG Board/Calendar/Meta/Locked/TodoList +``` + +全部勾选后即可认为本分支 **feat/airtable-phase-a** 手工验收完成。 diff --git a/docs/form-view.md b/docs/form-view.md new file mode 100644 index 000000000..18ad31a1a --- /dev/null +++ b/docs/form-view.md @@ -0,0 +1,154 @@ +# Form View + +> Branch: `feat/airtable-phase-a` (Phase B4) +> Related: [database-airtable-alignment.md](./database-airtable-alignment.md), [database-airtable-test-plan.md](./database-airtable-test-plan.md) + +## Overview + +**Form view** (`database_view.layout: 'form'`) is a workspace-only layout for **creating new records** via a vertical form. It is an MVP aligned with Airtable's internal form pattern — not a public/shareable form URL. + +**Entry:** Database → Create view → **Form** → fill fields → **Create record**. + +**Implementation:** `packages/ui/src/components/databases/forms/form-view.tsx` + +--- + +## What the form shows + +### Always visible + +| UI label | Source | Control | +|----------|--------|---------| +| **Name** | Record title (`node.name`) | Text input | + +This is separate from schema fields. Every record has a primary name, regardless of field definitions. + +### Schema fields + +The form lists **user-editable schema fields** that pass the supported-type filter (see below). + +**Field source (in order):** + +1. Fields marked **visible** in this view's field settings (eye icon in view settings) +2. If none are visible (e.g. older Form views created before defaults were fixed), **fallback** to all supported schema fields on the database + +**Field order:** Same as view field order (schema `index` / view field settings). + +--- + +## Supported field types (MVP) + +These types appear on the form and can be submitted on create. + +| Type | Form control | Stored as | Notes | +|------|--------------|-----------|-------| +| `text` | Single-line text input | `{ type: 'text', value }` | e.g. Comment | +| `number` | `type="number"` input | `{ type: 'number', value }` | e.g. Amount | +| `boolean` | Select: No value / Yes / No | `{ type: 'boolean', value }` | | +| `select` | Dropdown of options | `{ type: 'string', value: optionId }` | e.g. Status | +| `email` | Text input | `{ type: 'string', value }` | No email validation UI yet | +| `phone` | Text input | `{ type: 'string', value }` | | +| `url` | Text input | `{ type: 'string', value }` | | +| `date` | Text input | `{ type: 'string', value }` | **No date picker yet** — enter ISO date string, e.g. `2026-07-07` | + +Empty fields are **omitted** on create (not written to the record). + +--- + +## Excluded field types (not shown) + +These are intentionally hidden from the Form view in MVP. + +| Type | Reason | +|------|--------| +| `relation` | Needs linked-record picker | +| `lookup` | Read-only, derived from relation | +| `rollup` | Read-only, computed | +| `formula` | Read-only, computed | +| `autonumber` | System-assigned on create | +| `collaborator` | Needs user picker | +| `multi_select` | Needs multi-option picker | +| `file` / `resource` | Needs file/asset picker | +| `button` | Action field, not data entry | +| `created_at` / `created_by` / `updated_at` / `updated_by` | System metadata | + +To set relation or collaborator values when creating a record, use **Table view** inline edit, or create via Form then edit in Table. + +--- + +## View settings + +Form views use the same **Fields** panel as other layouts: + +- **Eye icon** — show/hide a field on the form +- Field order follows view field ordering + +New Form views default all user schema fields to **visible** (`getDefaultViewFieldDisplay('form') === true`). + +--- + +## Example (Customers database) + +A typical test form may show: + +``` +Name [ Record name ] +Comment (text) [ ] +Status (select) [ No value ▼ ] +Amount (number) [ ] +Completed date [ ] ← text input, not calendar +``` + +It will **not** show: Company (relation), Company name (lookup), Record # (autonumber), Owner (collaborator), Tags (multi_select), Score (formula). + +--- + +## Known limitations + +| Limitation | Detail | +|------------|--------| +| Scalar-only | No relation / lookup / collaborator / file on form | +| Date UX | `date` fields use plain text, not a calendar widget | +| No edit mode | Form is create-only; existing rows are not edited here | +| No public URL | Workspace members only; no hosted public form | +| No conditionals | No show/hide rules based on other answers | +| No defaults UI | Schema field defaults apply on server path; form does not pre-fill | + +--- + +## Planned enhancements (not implemented) + +Suggested order for follow-up work: + +1. **Date picker** — calendar widget for `date` fields +2. **multi_select** — checkbox or multi-dropdown +3. **relation** — search/select linked records (reuse relation picker from table) +4. **collaborator** — user picker +5. **Pre-fill defaults** — show schema `default` values in inputs +6. **Field descriptions** — helper text under labels + +--- + +## Code references + +| Area | Path | +|------|------| +| Form UI | `packages/ui/src/components/databases/forms/form-view.tsx` | +| Layout enum | `packages/core/src/registry/nodes/database-view.ts` (`'form'`) | +| Default field visibility | `packages/ui/src/lib/databases.ts` → `getDefaultViewFieldDisplay` | +| View field list | `packages/ui/src/lib/database-fields.ts` → `buildDatabaseViewFields` | +| Create record | `packages/ui/src/lib/record-create.ts` → `insertDatabaseRecord` | +| View router | `packages/ui/src/components/databases/view.tsx` | + +--- + +## Manual test (B4) + +1. Create **Form** view on a records database with mixed field types +2. Confirm only supported types appear (see tables above) +3. Fill Name + Status + Amount → **Create record** +4. Switch to **Table** view → verify new row and values +5. In view settings, hide **Amount** → confirm it disappears from form +6. Try **Completed date** with value `2026-07-07` → confirm stored correctly + +Full checklist: [database-airtable-test-plan.md § B4](./database-airtable-test-plan.md#b4--form-view表单视图). diff --git a/package-lock.json b/package-lock.json index 7829011c7..c7f946659 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,11 +85,89 @@ "@vitejs/plugin-react": "^5.2.0", "jsdom": "^28.1.0", "tailwindcss": "^4.2.2", - "vite": "^7.3.1", + "vite": "^8.1.3", "vite-plugin-pwa": "^1.2.0", "vitest": "^4.1.2" } }, + "apps/web/node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/@acemir/cssom": { "version": "0.9.31", "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", @@ -3750,21 +3828,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -3773,9 +3851,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -5022,16 +5100,22 @@ "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@nodelib/fs.scandir": { @@ -5445,6 +5529,16 @@ "@octokit/openapi-types": "^12.11.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -6815,6 +6909,263 @@ "node": ">= 10" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -8557,9 +8908,9 @@ ] }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -18209,9 +18560,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -19550,6 +19901,47 @@ "node": ">=8.0" } }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -21323,14 +21715,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" diff --git a/packages/client/src/lib/apply-collection-actions.ts b/packages/client/src/lib/apply-collection-actions.ts index 24b1cb39c..7998aac8f 100644 --- a/packages/client/src/lib/apply-collection-actions.ts +++ b/packages/client/src/lib/apply-collection-actions.ts @@ -37,14 +37,17 @@ const applyCollectionFieldAction = ( attributes.name = action.name; return; case 'clear': + assertFieldEditable(action.fieldId, schemaFields); delete attributes.fields[action.fieldId]; return; case 'set': assertFieldExists(action.fieldId, schemaFields); + assertFieldEditable(action.fieldId, schemaFields); attributes.fields[action.fieldId] = action.value; return; case 'increment': { assertFieldExists(action.fieldId, schemaFields); + assertFieldEditable(action.fieldId, schemaFields); const field = schemaFields[action.fieldId]; if (!field || field.type !== 'number') { throw new MutationError( @@ -65,6 +68,7 @@ const applyCollectionFieldAction = ( } case 'toggle': { assertFieldExists(action.fieldId, schemaFields); + assertFieldEditable(action.fieldId, schemaFields); const field = schemaFields[action.fieldId]; if (!field || field.type !== 'boolean') { throw new MutationError( @@ -87,6 +91,7 @@ const applyCollectionFieldAction = ( } case 'append': { assertFieldExists(action.fieldId, schemaFields); + assertFieldEditable(action.fieldId, schemaFields); const field = schemaFields[action.fieldId]; if ( !field || @@ -115,6 +120,7 @@ const applyCollectionFieldAction = ( return; } case 'remove': { + assertFieldEditable(action.fieldId, schemaFields); assertFieldExists(action.fieldId, schemaFields); const current = attributes.fields[action.fieldId]; if (current?.type !== 'string_array') { @@ -149,3 +155,21 @@ const assertFieldExists = ( ); } }; + +const assertFieldEditable = ( + fieldId: string, + schemaFields: Record +) => { + const field = schemaFields[fieldId]; + if ( + field?.type === 'autonumber' || + field?.type === 'lookup' || + field?.type === 'rollup' || + field?.type === 'formula' + ) { + throw new MutationError( + MutationErrorCode.FieldUpdateForbidden, + `${field.type} fields are read-only.` + ); + } +}; diff --git a/packages/client/src/lib/autonumber-fields.ts b/packages/client/src/lib/autonumber-fields.ts new file mode 100644 index 000000000..9ceda5a0e --- /dev/null +++ b/packages/client/src/lib/autonumber-fields.ts @@ -0,0 +1,67 @@ +import { + AutonumberFieldAttributes, + FieldAttributes, + FieldValue, +} from '@colanode/core'; + +export const isAutonumberField = ( + field: FieldAttributes +): field is AutonumberFieldAttributes => field.type === 'autonumber'; + +export const getAutonumberFields = ( + fields: Record | FieldAttributes[] +): AutonumberFieldAttributes[] => { + const list = Array.isArray(fields) ? fields : Object.values(fields); + return list.filter(isAutonumberField); +}; + +export const readAutonumberNextValue = ( + field: AutonumberFieldAttributes +): number => { + return field.nextValue ?? 1; +}; + +export const allocateAutonumbers = ( + fields: Record +): { + values: Record; + fields: Record; +} => { + const nextFields = { ...fields }; + const values: Record = {}; + + for (const [fieldId, field] of Object.entries(nextFields)) { + if (!isAutonumberField(field)) { + continue; + } + + const assigned = readAutonumberNextValue(field); + values[fieldId] = { type: 'number', value: assigned }; + nextFields[fieldId] = { + ...field, + nextValue: assigned + 1, + }; + } + + return { values, fields: nextFields }; +}; + +export const seedAutonumberNextValue = ( + fieldId: string, + records: Array<{ fields: Record }> +): number => { + let max = 0; + + for (const record of records) { + const value = record.fields[fieldId]; + if (value?.type === 'number' && Number.isFinite(value.value)) { + max = Math.max(max, value.value); + } + } + + return max + 1; +}; + +export const isAutonumberFieldEditable = (field: FieldAttributes): boolean => { + return field.type !== 'autonumber'; +}; diff --git a/packages/client/src/lib/bidirectional-relation.ts b/packages/client/src/lib/bidirectional-relation.ts new file mode 100644 index 000000000..aa410c2d2 --- /dev/null +++ b/packages/client/src/lib/bidirectional-relation.ts @@ -0,0 +1,163 @@ +import { + FieldAttributes, + FieldValue, + RelationFieldAttributes, +} from '@colanode/core'; + +export type RelationLinkDiff = { + added: string[]; + removed: string[]; +}; + +type RelationCarrier = { + type: string; + fields?: Record; +}; + +const activeRelationSyncKeys = new Set(); + +export const readRelationIds = (value: FieldValue | undefined): string[] => { + if (value?.type === 'string_array') { + return value.value; + } + + return []; +}; + +export const diffRelationIds = ( + previousIds: string[], + nextIds: string[] +): RelationLinkDiff => { + const previousSet = new Set(previousIds); + const nextSet = new Set(nextIds); + + return { + added: nextIds.filter((id) => !previousSet.has(id)), + removed: previousIds.filter((id) => !nextSet.has(id)), + }; +}; + +const isRelationCarrier = (node: RelationCarrier): boolean => { + return node.type === 'record' || node.type === 'page' || node.type === 'file'; +}; + +const updateRelationIds = ( + fields: Record, + fieldId: string, + updater: (ids: string[]) => string[] +): Record => { + const current = readRelationIds(fields[fieldId]); + const next = updater(current); + + if (next.length === 0) { + const { [fieldId]: _removed, ...rest } = fields; + return rest; + } + + return { + ...fields, + [fieldId]: { + type: 'string_array', + value: next, + }, + }; +}; + +export const applyBidirectionalRelationSync = ({ + relationField, + sourceNodeId, + previousIds, + nextIds, + getReverseField, + updateRelationNode, +}: { + relationField: RelationFieldAttributes; + sourceNodeId: string; + previousIds: string[]; + nextIds: string[]; + getReverseField: () => RelationFieldAttributes | null | undefined; + updateRelationNode: ( + nodeId: string, + updater: (draft: RelationCarrier) => void + ) => void; +}): void => { + if (!relationField.reverseFieldId || !relationField.databaseId) { + return; + } + + const reverseField = getReverseField(); + if (!reverseField || reverseField.type !== 'relation') { + return; + } + + const sourceKey = `${sourceNodeId}:${relationField.id}`; + if (activeRelationSyncKeys.has(sourceKey)) { + return; + } + + const { added, removed } = diffRelationIds(previousIds, nextIds); + if (added.length === 0 && removed.length === 0) { + return; + } + + const reverseFieldId = relationField.reverseFieldId; + + activeRelationSyncKeys.add(sourceKey); + try { + for (const targetId of added) { + const targetKey = `${targetId}:${reverseFieldId}`; + if (activeRelationSyncKeys.has(targetKey)) { + continue; + } + + activeRelationSyncKeys.add(targetKey); + try { + updateRelationNode(targetId, (draft) => { + if (!isRelationCarrier(draft)) { + return; + } + + const fields = draft.fields ?? {}; + draft.fields = updateRelationIds(fields, reverseFieldId, (ids) => + ids.includes(sourceNodeId) ? ids : [...ids, sourceNodeId] + ); + }); + } finally { + activeRelationSyncKeys.delete(targetKey); + } + } + + for (const targetId of removed) { + const targetKey = `${targetId}:${reverseFieldId}`; + if (activeRelationSyncKeys.has(targetKey)) { + continue; + } + + activeRelationSyncKeys.add(targetKey); + try { + updateRelationNode(targetId, (draft) => { + if (!isRelationCarrier(draft)) { + return; + } + + const fields = draft.fields ?? {}; + draft.fields = updateRelationIds(fields, reverseFieldId, (ids) => + ids.filter((id) => id !== sourceNodeId) + ); + }); + } finally { + activeRelationSyncKeys.delete(targetKey); + } + } + } finally { + activeRelationSyncKeys.delete(sourceKey); + } +}; + +export const getReverseRelationField = ( + fields: Record, + reverseFieldId: string +): RelationFieldAttributes | null => { + const field = fields[reverseFieldId]; + return field?.type === 'relation' ? field : null; +}; diff --git a/packages/client/src/lib/database-csv.ts b/packages/client/src/lib/database-csv.ts index c2921f423..320bf143b 100644 --- a/packages/client/src/lib/database-csv.ts +++ b/packages/client/src/lib/database-csv.ts @@ -141,6 +141,13 @@ export const getDatabaseCsvCellValue = ( } return ''; } + case 'autonumber': { + const value = row.fields[field.id]; + if (value?.type === 'number') { + return Number.isFinite(value.value) ? String(value.value) : ''; + } + return ''; + } case 'date': { const value = row.fields[field.id]; if (value?.type === 'string') { @@ -191,6 +198,7 @@ export const getDatabaseCsvCellValue = ( case 'formula': case 'resource': case 'rollup': + case 'lookup': return ''; default: return ''; @@ -339,6 +347,7 @@ export const mapCsvImportHeaders = (input: { }; const IMPORT_SKIP_FIELD_TYPES = new Set([ + 'autonumber', 'button', 'created_at', 'created_by', @@ -347,6 +356,8 @@ const IMPORT_SKIP_FIELD_TYPES = new Set([ 'file', 'formula', 'resource', + 'lookup', + 'lookup', 'rollup', ]); diff --git a/packages/client/src/lib/format-number-field.ts b/packages/client/src/lib/format-number-field.ts new file mode 100644 index 000000000..604712f4d --- /dev/null +++ b/packages/client/src/lib/format-number-field.ts @@ -0,0 +1,38 @@ +import { NumberFieldAttributes } from '@colanode/core'; + +type NumberFormatOptions = Pick< + NumberFieldAttributes, + 'format' | 'currency' +>; + +export const formatNumberFieldValue = ( + value: number, + field?: NumberFormatOptions +): string => { + if (!Number.isFinite(value)) { + return ''; + } + + const format = field?.format ?? 'plain'; + + if (format === 'percent') { + return `${new Intl.NumberFormat(undefined, { + maximumFractionDigits: 2, + }).format(value * 100)}%`; + } + + if (format === 'currency') { + const currency = field?.currency ?? 'USD'; + try { + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency, + maximumFractionDigits: 2, + }).format(value); + } catch { + return value.toFixed(2); + } + } + + return Number.isInteger(value) ? value.toString() : value.toFixed(2); +}; diff --git a/packages/client/src/lib/index.ts b/packages/client/src/lib/index.ts index 030bf38b9..ac9166fe0 100644 --- a/packages/client/src/lib/index.ts +++ b/packages/client/src/lib/index.ts @@ -8,9 +8,16 @@ export * from './mentions'; export * from './notification-settings'; export * from './scheduled-task-schedule'; export * from './scheduled-task-checks'; +export * from './scheduled-task-actions'; export * from './scheduled-task-check-utils'; export * from './scheduled-task-templates'; export * from './asset-library'; +export * from './bidirectional-relation'; +export * from './lookup-field-display'; +export * from './rollup-field-display'; +export * from './format-number-field'; +export * from './autonumber-fields'; +export * from './apply-collection-actions'; export * from './database-csv'; export * from './todo-list'; export * from './types'; diff --git a/packages/client/src/lib/lookup-field-display.ts b/packages/client/src/lib/lookup-field-display.ts new file mode 100644 index 000000000..8528cea96 --- /dev/null +++ b/packages/client/src/lib/lookup-field-display.ts @@ -0,0 +1,86 @@ +import { + FieldAttributes, + FieldValue, + MultiSelectFieldAttributes, + RECORD_NAME_LOOKUP_FIELD_ID, + SelectFieldAttributes, +} from '@colanode/core'; + +type LookupSourceNode = { + name: string; + createdAt: string; + createdBy: string; + updatedAt?: string | null; + updatedBy?: string | null; + fields?: Record; +}; + +export const getLookupValueFromNode = ( + node: LookupSourceNode, + field: FieldAttributes +): FieldValue | undefined => { + if (field.id === RECORD_NAME_LOOKUP_FIELD_ID) { + return node.name ? { type: 'text', value: node.name } : undefined; + } + + switch (field.type) { + case 'created_at': + return { type: 'string', value: node.createdAt }; + case 'created_by': + return { type: 'string', value: node.createdBy }; + case 'updated_at': + return node.updatedAt + ? { type: 'string', value: node.updatedAt } + : undefined; + case 'updated_by': + return node.updatedBy + ? { type: 'string', value: node.updatedBy } + : undefined; + default: + return node.fields?.[field.id]; + } +}; + +export const formatLookupFieldValue = ( + value: FieldValue | undefined, + field: FieldAttributes | undefined +): string => { + if (!value) { + return ''; + } + + switch (value.type) { + case 'boolean': + return value.value ? 'Yes' : 'No'; + case 'number': + return Number.isFinite(value.value) + ? Number.isInteger(value.value) + ? String(value.value) + : value.value.toFixed(2) + : ''; + case 'string': + if (field?.type === 'select') { + const option = (field as SelectFieldAttributes).options?.[value.value]; + return option?.name ?? value.value; + } + return value.value; + case 'text': + return value.value; + case 'string_array': + if (field?.type === 'multi_select') { + const options = (field as MultiSelectFieldAttributes).options ?? {}; + return value.value + .map((optionId) => options[optionId]?.name ?? optionId) + .join(', '); + } + return value.value.join(', '); + default: + return ''; + } +}; + +export const formatLookupValues = ( + values: string[] +): string => { + return values.filter((value) => value.length > 0).join(', '); +}; diff --git a/packages/client/src/lib/mappers.ts b/packages/client/src/lib/mappers.ts index 2a8382887..a38625ed7 100644 --- a/packages/client/src/lib/mappers.ts +++ b/packages/client/src/lib/mappers.ts @@ -326,6 +326,8 @@ export const mapScheduledTask = (row: SelectScheduledTask): ScheduledTask => { schedule: JSON.parse(row.schedule) as ScheduledTask['schedule'], check: JSON.parse(row.check) as ScheduledTask['check'], notify: JSON.parse(row.notify) as ScheduledTask['notify'], + actions: [], + lastRunLog: null, scope, context: scopeToContext(scope) ?? context, lastRunAt: row.last_run_at, diff --git a/packages/client/src/lib/records.ts b/packages/client/src/lib/records.ts index bd4420673..14928294e 100644 --- a/packages/client/src/lib/records.ts +++ b/packages/client/src/lib/records.ts @@ -101,6 +101,7 @@ const buildFilterQuery = ( return null; case 'multi_select': return buildMultiSelectFilterQuery(filter, field); + case 'autonumber': case 'number': return buildNumberFilterQuery(filter, field); case 'phone': @@ -222,7 +223,7 @@ const buildCollaboratorFilterQuery = ( const buildNumberFilterQuery = ( filter: DatabaseViewFieldFilterAttributes, - field: NumberFieldAttributes + field: { id: string } ): string | null => { if (filter.operator === 'is_empty') { return buildFieldFilterQuery(field.id, 'IS', 'NULL'); diff --git a/packages/client/src/lib/rollup-field-display.ts b/packages/client/src/lib/rollup-field-display.ts new file mode 100644 index 000000000..2e3aeaecc --- /dev/null +++ b/packages/client/src/lib/rollup-field-display.ts @@ -0,0 +1,136 @@ +import { + FieldAttributes, + FieldValue, + RollupAggregation, + RollupFieldAttributes, +} from '@colanode/core'; + +import { + formatLookupFieldValue, + getLookupValueFromNode, +} from '@colanode/client/lib/lookup-field-display'; + +export type RollupSourceNode = { + id: string; + name: string; + createdAt: string; + createdBy: string; + updatedAt?: string | null; + updatedBy?: string | null; + fields?: Record; +}; + +export type RollupResult = + | { kind: 'number'; value: number } + | { kind: 'text'; value: string }; + +const NUMERIC_AGGREGATIONS: RollupAggregation[] = [ + 'sum', + 'average', + 'min', + 'max', +]; + +const DISPLAY_AGGREGATIONS: RollupAggregation[] = [ + 'show_values', + 'unique', + 'concatenate', +]; + +export const isNumericRollupAggregation = ( + aggregation: RollupAggregation +): boolean => NUMERIC_AGGREGATIONS.includes(aggregation); + +export const rollupAggregationNeedsField = ( + aggregation: RollupAggregation +): boolean => aggregation !== 'count'; + +const orderNodesByRelationIds = ( + relationIds: string[], + relatedNodes: RollupSourceNode[] +): RollupSourceNode[] => { + const nodesById = new Map(relatedNodes.map((node) => [node.id, node])); + + return relationIds + .map((id) => nodesById.get(id)) + .filter((node): node is RollupSourceNode => !!node); +}; + +export const computeRollupResult = ( + field: RollupFieldAttributes, + relationIds: string[], + relatedNodes: RollupSourceNode[], + rolledUpField: FieldAttributes | undefined +): RollupResult | null => { + if (!field.aggregation) { + return null; + } + + if (field.aggregation === 'count') { + return { kind: 'number', value: relationIds.length }; + } + + if (!field.rolledUpFieldId || !rolledUpField) { + return null; + } + + const orderedNodes = orderNodesByRelationIds(relationIds, relatedNodes); + + if (DISPLAY_AGGREGATIONS.includes(field.aggregation)) { + const aggregation = field.aggregation; + const displayValues: string[] = []; + + for (const node of orderedNodes) { + const value = getLookupValueFromNode(node, rolledUpField); + const formatted = formatLookupFieldValue(value, rolledUpField); + if (formatted) { + displayValues.push(formatted); + } + } + + if (displayValues.length === 0) { + return { kind: 'text', value: '' }; + } + + if (aggregation === 'unique') { + return { kind: 'text', value: [...new Set(displayValues)].join(', ') }; + } + + return { kind: 'text', value: displayValues.join(', ') }; + } + + if (!isNumericRollupAggregation(field.aggregation)) { + return null; + } + + const numbers: number[] = []; + for (const node of orderedNodes) { + const value = getLookupValueFromNode(node, rolledUpField); + if (value?.type === 'number') { + numbers.push(value.value); + } + } + + if (numbers.length === 0) { + return { kind: 'number', value: 0 }; + } + + switch (field.aggregation) { + case 'sum': + return { + kind: 'number', + value: numbers.reduce((acc, current) => acc + current, 0), + }; + case 'average': + return { + kind: 'number', + value: numbers.reduce((acc, current) => acc + current, 0) / numbers.length, + }; + case 'min': + return { kind: 'number', value: Math.min(...numbers) }; + case 'max': + return { kind: 'number', value: Math.max(...numbers) }; + default: + return null; + } +}; diff --git a/packages/client/src/lib/scheduled-task-actions.ts b/packages/client/src/lib/scheduled-task-actions.ts new file mode 100644 index 000000000..58094758d --- /dev/null +++ b/packages/client/src/lib/scheduled-task-actions.ts @@ -0,0 +1,383 @@ +import { applyCollectionFieldActions } from '@colanode/client/lib/apply-collection-actions'; +import { mapNode } from '@colanode/client/lib/mappers'; +import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service'; +import { + ScheduledTaskAction, + ScheduledTaskActionResult, + ScheduledTaskRunLog, + ScheduledTaskScope, +} from '@colanode/client/types/scheduled-tasks'; +import { + CollectionFieldAction, + DatabaseNode, + FieldValue, + generateId, + IdType, + RecordAttributes, +} from '@colanode/core'; + +type ActionValue = FieldValue | { kind: 'today' }; + +const resolveActionValue = (value: ActionValue): FieldValue => { + if ( + value && + typeof value === 'object' && + 'kind' in value && + value.kind === 'today' + ) { + const today = new Date(); + const date = today.toISOString().split('T')[0] ?? ''; + return { type: 'string', value: date }; + } + + return value as FieldValue; +}; + +const resolveTargetNodeIds = ( + action: ScheduledTaskAction, + matchedNodeIds: string[], + scope: ScheduledTaskScope | null +): string[] => { + if (action.type === 'createRecord' || action.type === 'runScript') { + return matchedNodeIds; + } + + if (action.type === 'setField' || action.type === 'linkRecords') { + if (action.target === 'scope_record' && scope?.nodeId) { + return [scope.nodeId]; + } + + return matchedNodeIds; + } + + return matchedNodeIds; +}; + +const fetchDatabase = async ( + workspace: WorkspaceService, + databaseId: string +): Promise => { + const row = await workspace.database + .selectFrom('nodes') + .selectAll() + .where('id', '=', databaseId) + .executeTakeFirst(); + + if (!row) { + return null; + } + + const node = mapNode(row); + return node.type === 'database' ? node : null; +}; + +const applySetFieldAction = async ( + workspace: WorkspaceService, + nodeId: string, + fieldId: string, + value: ActionValue +): Promise => { + const row = await workspace.database + .selectFrom('nodes') + .selectAll() + .where('id', '=', nodeId) + .executeTakeFirst(); + + if (!row) { + return { + actionIndex: -1, + type: 'setField', + success: false, + message: `Node ${nodeId} not found`, + }; + } + + const node = mapNode(row); + if (node.type !== 'record' && node.type !== 'page' && node.type !== 'file') { + return { + actionIndex: -1, + type: 'setField', + success: false, + message: `Node ${nodeId} is not a collection row`, + }; + } + + const databaseId = + node.type === 'record' + ? node.databaseId + : node.type === 'page' || node.type === 'file' + ? node.metaTypeId + : null; + + if (!databaseId) { + return { + actionIndex: -1, + type: 'setField', + success: false, + message: `Node ${nodeId} has no database schema`, + }; + } + + const database = await fetchDatabase(workspace, databaseId); + if (!database) { + return { + actionIndex: -1, + type: 'setField', + success: false, + message: `Database ${databaseId} not found`, + }; + } + + const actions: CollectionFieldAction[] = [ + { + op: 'set', + fieldId, + value: resolveActionValue(value), + }, + ]; + + const result = await workspace.nodes.updateNode(nodeId, (current) => { + if (current.type !== 'record' && current.type !== 'page' && current.type !== 'file') { + return current; + } + + const withFields = { + ...current, + fields: current.fields ?? {}, + }; + + return applyCollectionFieldActions( + withFields, + actions, + database.fields ?? {} + ); + }); + + if (result !== 'success') { + return { + actionIndex: -1, + type: 'setField', + success: false, + message: `Failed to update ${nodeId}`, + }; + } + + return { + actionIndex: -1, + type: 'setField', + success: true, + message: `Updated field ${fieldId}`, + affectedNodeIds: [nodeId], + }; +}; + +const applyCreateRecordAction = async ( + workspace: WorkspaceService, + action: Extract +): Promise => { + const database = await fetchDatabase(workspace, action.databaseId); + if (!database) { + return { + actionIndex: -1, + type: 'createRecord', + success: false, + message: `Database ${action.databaseId} not found`, + }; + } + + const recordId = generateId(IdType.Record); + const fields: Record = {}; + for (const [fieldId, value] of Object.entries(action.fields ?? {})) { + fields[fieldId] = resolveActionValue(value); + } + + const attributes: RecordAttributes = { + type: 'record', + parentId: action.databaseId, + databaseId: action.databaseId, + name: action.name ?? '', + fields, + }; + + await workspace.nodes.insertNode(recordId, attributes); + + return { + actionIndex: -1, + type: 'createRecord', + success: true, + message: `Created record ${recordId}`, + affectedNodeIds: [recordId], + }; +}; + +const applyLinkRecordsAction = async ( + workspace: WorkspaceService, + nodeId: string, + action: Extract +): Promise => { + const row = await workspace.database + .selectFrom('nodes') + .selectAll() + .where('id', '=', nodeId) + .executeTakeFirst(); + + if (!row) { + return { + actionIndex: -1, + type: 'linkRecords', + success: false, + message: `Node ${nodeId} not found`, + }; + } + + const node = mapNode(row); + if (node.type !== 'record' && node.type !== 'page' && node.type !== 'file') { + return { + actionIndex: -1, + type: 'linkRecords', + success: false, + message: `Node ${nodeId} is not a collection row`, + }; + } + + const databaseId = + node.type === 'record' ? node.databaseId : node.metaTypeId ?? null; + if (!databaseId) { + return { + actionIndex: -1, + type: 'linkRecords', + success: false, + message: `Node ${nodeId} has no database schema`, + }; + } + + const database = await fetchDatabase(workspace, databaseId); + if (!database) { + return { + actionIndex: -1, + type: 'linkRecords', + success: false, + message: `Database ${databaseId} not found`, + }; + } + + for (const linkedRecordId of action.linkedRecordIds) { + const appendResult = await workspace.nodes.updateNode(nodeId, (current) => { + if (current.type !== 'record' && current.type !== 'page' && current.type !== 'file') { + return current; + } + + const withFields = { + ...current, + fields: current.fields ?? {}, + }; + + return applyCollectionFieldActions( + withFields, + [ + { + op: 'append', + fieldId: action.relationFieldId, + value: linkedRecordId, + }, + ], + database.fields ?? {} + ); + }); + + if (appendResult !== 'success') { + return { + actionIndex: -1, + type: 'linkRecords', + success: false, + message: `Failed to link ${linkedRecordId} on ${nodeId}`, + }; + } + } + + return { + actionIndex: -1, + type: 'linkRecords', + success: true, + message: `Linked ${action.linkedRecordIds.length} record(s)`, + affectedNodeIds: [nodeId], + }; +}; + +export const runScheduledTaskActions = async ( + workspace: WorkspaceService, + input: { + actions: ScheduledTaskAction[]; + matchedNodeIds: string[]; + scope: ScheduledTaskScope | null; + } +): Promise => { + const results: ScheduledTaskActionResult[] = []; + + for (const [index, action] of input.actions.entries()) { + if (action.type === 'createRecord') { + const result = await applyCreateRecordAction(workspace, action); + results.push({ ...result, actionIndex: index }); + continue; + } + + if (action.type === 'runScript') { + results.push({ + actionIndex: index, + type: 'runScript', + success: false, + message: 'runScript actions are not enabled yet', + }); + continue; + } + + const targetNodeIds = resolveTargetNodeIds( + action, + input.matchedNodeIds, + input.scope + ); + + if (targetNodeIds.length === 0) { + results.push({ + actionIndex: index, + type: action.type, + success: false, + message: 'No target records for action', + }); + continue; + } + + for (const nodeId of targetNodeIds) { + if (action.type === 'setField') { + const result = await applySetFieldAction( + workspace, + nodeId, + action.fieldId, + action.value + ); + results.push({ ...result, actionIndex: index }); + } + + if (action.type === 'linkRecords') { + const result = await applyLinkRecordsAction(workspace, nodeId, action); + results.push({ ...result, actionIndex: index }); + } + } + } + + return results; +}; + +export const buildScheduledTaskRunLog = ( + input: { + runAt: string; + checkSummary: string; + matchedNodeIds: string[]; + actionResults: ScheduledTaskActionResult[]; + } +): ScheduledTaskRunLog => ({ + runAt: input.runAt, + checkSummary: input.checkSummary, + matchedNodeIds: input.matchedNodeIds, + actionResults: input.actionResults, +}); diff --git a/packages/client/src/lib/scheduled-task-meta-type.ts b/packages/client/src/lib/scheduled-task-meta-type.ts index a623c3079..c7a34e1e5 100644 --- a/packages/client/src/lib/scheduled-task-meta-type.ts +++ b/packages/client/src/lib/scheduled-task-meta-type.ts @@ -68,12 +68,28 @@ const buildScheduledTaskDatabaseFields = (): Record => index: 'e', }; + const actionsField: TextFieldAttributes = { + id: SCHEDULED_TASK_FIELD_IDS.actions, + type: 'text', + name: 'Actions', + index: 'f', + }; + + const lastRunLogField: TextFieldAttributes = { + id: SCHEDULED_TASK_FIELD_IDS.lastRunLog, + type: 'text', + name: 'Last run log', + index: 'g', + }; + return { [enabledField.id]: enabledField, [scheduleField.id]: scheduleField, [checkField.id]: checkField, [notifyField.id]: notifyField, [scopeField.id]: scopeField, + [actionsField.id]: actionsField, + [lastRunLogField.id]: lastRunLogField, }; }; @@ -145,6 +161,33 @@ const buildRemindersRelationField = ( databaseId: scheduledTaskDatabaseId, }); +const ensureScheduledTaskDatabaseFields = async ( + workspace: WorkspaceService, + databaseId: string +): Promise => { + const requiredFields = buildScheduledTaskDatabaseFields(); + const result = await workspace.nodes.updateNode(databaseId, (draft) => { + if (draft.type !== 'database') { + return draft; + } + + for (const [fieldId, field] of Object.entries(requiredFields)) { + if (!draft.fields[fieldId]) { + draft.fields = { + ...draft.fields, + [fieldId]: field, + }; + } + } + + return draft; + }); + + if (result === 'success') { + debug(`Ensured scheduled task database fields on ${databaseId}`); + } +}; + export const ensureRemindersRelationFields = async ( workspace: WorkspaceService, scheduledTaskDatabaseId: string @@ -318,6 +361,7 @@ export const ensureScheduledTaskMetaType = async ( } await ensureRemindersRelationFields(workspace, databaseId); + await ensureScheduledTaskDatabaseFields(workspace, databaseId); await syncTaskRelationLinks(workspace, databaseId); return databaseId; diff --git a/packages/client/src/lib/scheduled-task-record-mapper.ts b/packages/client/src/lib/scheduled-task-record-mapper.ts index 0419bb124..f34fd03b9 100644 --- a/packages/client/src/lib/scheduled-task-record-mapper.ts +++ b/packages/client/src/lib/scheduled-task-record-mapper.ts @@ -5,9 +5,11 @@ import { normalizeScheduledTaskScope, scopeToContext } from '@colanode/client/li import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service'; import { ScheduledTask, + ScheduledTaskAction, ScheduledTaskCheck, ScheduledTaskInput, ScheduledTaskNotify, + ScheduledTaskRunLog, ScheduledTaskSchedule, ScheduledTaskScope, } from '@colanode/client/types/scheduled-tasks'; @@ -73,6 +75,14 @@ export const recordToScheduledTask = ( readTextField(fields, SCHEDULED_TASK_FIELD_IDS.notify), { silent: true } ); + const actions = parseJsonField( + readTextField(fields, SCHEDULED_TASK_FIELD_IDS.actions), + [] + ); + const lastRunLog = parseJsonField( + readTextField(fields, SCHEDULED_TASK_FIELD_IDS.lastRunLog), + null + ); const scope = parseJsonField( readTextField(fields, SCHEDULED_TASK_FIELD_IDS.scope), null @@ -88,10 +98,12 @@ export const recordToScheduledTask = ( schedule, check, notify, + actions, scope, context: scopeToContext(scope), lastRunAt: state?.last_run_at ?? null, lastResultHash: state?.last_result_hash ?? null, + lastRunLog, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -125,6 +137,10 @@ export const scheduledTaskInputToRecordAttributes = ( type: 'text', value: JSON.stringify(input.notify), }, + [SCHEDULED_TASK_FIELD_IDS.actions]: { + type: 'text', + value: JSON.stringify(input.actions ?? []), + }, [SCHEDULED_TASK_FIELD_IDS.scope]: { type: 'text', value: scope ? JSON.stringify(scope) : '', diff --git a/packages/client/src/services/scheduled-task-service.ts b/packages/client/src/services/scheduled-task-service.ts index 6762e633c..50142f3a6 100644 --- a/packages/client/src/services/scheduled-task-service.ts +++ b/packages/client/src/services/scheduled-task-service.ts @@ -1,6 +1,10 @@ import { eventBus } from '@colanode/client/lib/event-bus'; import { isScheduledTaskEventCheck } from '@colanode/client/lib/scheduled-task-check-utils'; import { runScheduledTaskCheck } from '@colanode/client/lib/scheduled-task-checks'; +import { + buildScheduledTaskRunLog, + runScheduledTaskActions, +} from '@colanode/client/lib/scheduled-task-actions'; import { createScheduledTaskRecord, ensureScheduledTaskMetaType, @@ -21,7 +25,7 @@ import { ScheduledTaskInput, } from '@colanode/client/types/scheduled-tasks'; import { Event } from '@colanode/client/types/events'; -import { generateId, IdType } from '@colanode/core'; +import { generateId, IdType, SCHEDULED_TASK_FIELD_IDS } from '@colanode/core'; const formatReminderNotificationBody = (runAt: Date): string => { const time = runAt.toLocaleTimeString([], { @@ -505,6 +509,38 @@ export class ScheduledTaskService { return; } + const actionResults = + task.actions.length > 0 + ? await runScheduledTaskActions(workspace, { + actions: task.actions, + matchedNodeIds: result.matchedPageIds, + scope: task.scope, + }) + : []; + + const runLog = buildScheduledTaskRunLog({ + runAt: now, + checkSummary: result.summary, + matchedNodeIds: result.matchedPageIds, + actionResults, + }); + + await workspace.nodes.updateNode(task.id, (draft) => { + if (draft.type !== 'record') { + return draft; + } + + draft.fields = { + ...draft.fields, + [SCHEDULED_TASK_FIELD_IDS.lastRunLog]: { + type: 'text', + value: JSON.stringify(runLog), + }, + }; + + return draft; + }); + if (task.lastResultHash === result.resultHash && !options?.forceNotify) { return; } diff --git a/packages/client/src/types/scheduled-tasks.ts b/packages/client/src/types/scheduled-tasks.ts index 883b6d0dd..0a9b43160 100644 --- a/packages/client/src/types/scheduled-tasks.ts +++ b/packages/client/src/types/scheduled-tasks.ts @@ -1,3 +1,5 @@ +import type { FieldValue } from '@colanode/core'; + export type ScheduledTaskSchedule = | { type: 'interval'; @@ -109,6 +111,54 @@ export type ScheduledTaskNotify = { silent: boolean; }; +export type ScheduledTaskSetFieldAction = { + type: 'setField'; + target: 'matched_records' | 'scope_record'; + fieldId: string; + value: FieldValue | { kind: 'today' }; +}; + +export type ScheduledTaskCreateRecordAction = { + type: 'createRecord'; + databaseId: string; + name?: string; + fields: Record; +}; + +export type ScheduledTaskLinkRecordsAction = { + type: 'linkRecords'; + target: 'matched_records'; + relationFieldId: string; + linkedRecordIds: string[]; +}; + +export type ScheduledTaskRunScriptAction = { + type: 'runScript'; + databaseId: string; + script: string; +}; + +export type ScheduledTaskAction = + | ScheduledTaskSetFieldAction + | ScheduledTaskCreateRecordAction + | ScheduledTaskLinkRecordsAction + | ScheduledTaskRunScriptAction; + +export type ScheduledTaskActionResult = { + actionIndex: number; + type: ScheduledTaskAction['type']; + success: boolean; + message: string; + affectedNodeIds?: string[]; +}; + +export type ScheduledTaskRunLog = { + runAt: string; + checkSummary: string; + matchedNodeIds: string[]; + actionResults: ScheduledTaskActionResult[]; +}; + export type ScheduledTaskScope = | { kind: 'pages'; @@ -154,11 +204,13 @@ export type ScheduledTask = { schedule: ScheduledTaskSchedule; check: ScheduledTaskCheck; notify: ScheduledTaskNotify; + actions: ScheduledTaskAction[]; scope: ScheduledTaskScope | null; /** @deprecated Derived from scope when reading legacy data */ context: ScheduledTaskContext | null; lastRunAt: string | null; lastResultHash: string | null; + lastRunLog: ScheduledTaskRunLog | null; createdAt: string; updatedAt: string | null; }; @@ -169,6 +221,7 @@ export type ScheduledTaskInput = { schedule: ScheduledTaskSchedule; check: ScheduledTaskCheck; notify: ScheduledTaskNotify; + actions?: ScheduledTaskAction[]; scope?: ScheduledTaskScope | null; /** @deprecated Use scope */ context?: ScheduledTaskContext | null; diff --git a/packages/client/test/autonumber-fields.test.ts b/packages/client/test/autonumber-fields.test.ts new file mode 100644 index 000000000..5f2a7241f --- /dev/null +++ b/packages/client/test/autonumber-fields.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { + allocateAutonumbers, + readAutonumberNextValue, + seedAutonumberNextValue, +} from '@colanode/client/lib/autonumber-fields'; +import { AutonumberFieldAttributes } from '@colanode/core'; + +const autonumberField = ( + id: string, + nextValue?: number +): AutonumberFieldAttributes => ({ + id, + type: 'autonumber', + name: 'ID', + index: 'a', + nextValue, +}); + +describe('autonumber-fields', () => { + it('reads the next autonumber value', () => { + expect(readAutonumberNextValue(autonumberField('id'))).toBe(1); + expect(readAutonumberNextValue(autonumberField('id', 42))).toBe(42); + }); + + it('allocates monotonic values and advances schema counters', () => { + const first = allocateAutonumbers({ + num: autonumberField('num', 10), + other: autonumberField('other'), + }); + + expect(first.values.num).toEqual({ type: 'number', value: 10 }); + expect(first.values.other).toEqual({ type: 'number', value: 1 }); + expect(first.fields.num?.type).toBe('autonumber'); + if (first.fields.num?.type === 'autonumber') { + expect(first.fields.num.nextValue).toBe(11); + } + if (first.fields.other?.type === 'autonumber') { + expect(first.fields.other.nextValue).toBe(2); + } + + const second = allocateAutonumbers(first.fields); + expect(second.values.num).toEqual({ type: 'number', value: 11 }); + expect(second.values.other).toEqual({ type: 'number', value: 2 }); + }); + + it('seeds next value from existing records', () => { + expect( + seedAutonumberNextValue('num', [ + { fields: { num: { type: 'number', value: 3 } } }, + { fields: { num: { type: 'number', value: 8 } } }, + ]) + ).toBe(9); + }); +}); diff --git a/packages/client/test/bidirectional-relation.test.ts b/packages/client/test/bidirectional-relation.test.ts new file mode 100644 index 000000000..d538efdb1 --- /dev/null +++ b/packages/client/test/bidirectional-relation.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { + applyBidirectionalRelationSync, + diffRelationIds, + readRelationIds, +} from '@colanode/client/lib/bidirectional-relation'; +import { RelationFieldAttributes } from '@colanode/core'; + +type TestNode = { + type: string; + fields?: Record; +}; + +describe('bidirectional-relation', () => { + it('diffs relation ids', () => { + expect(diffRelationIds(['a', 'b'], ['b', 'c'])).toEqual({ + added: ['c'], + removed: ['a'], + }); + }); + + it('syncs reverse links when relation ids change', () => { + const relationField: RelationFieldAttributes = { + id: 'contacts', + type: 'relation', + name: 'Contacts', + index: 'a', + databaseId: 'companies-db', + reverseFieldId: 'company', + }; + + const reverseField: RelationFieldAttributes = { + id: 'company', + type: 'relation', + name: 'Company', + index: 'a', + databaseId: 'contacts-db', + reverseFieldId: 'contacts', + }; + + const nodes = new Map([ + ['company-1', { type: 'record', fields: {} }], + ['contact-1', { type: 'record', fields: {} }], + ]); + + applyBidirectionalRelationSync({ + relationField, + sourceNodeId: 'contact-1', + previousIds: [], + nextIds: ['company-1'], + getReverseField: () => reverseField, + updateRelationNode: (nodeId, updater) => { + const node = nodes.get(nodeId); + if (!node) { + return; + } + + updater(node); + }, + }); + + expect(readRelationIds(nodes.get('company-1')?.fields?.company)).toEqual([ + 'contact-1', + ]); + }); +}); diff --git a/packages/client/test/format-number-field.test.ts b/packages/client/test/format-number-field.test.ts new file mode 100644 index 000000000..bc748b661 --- /dev/null +++ b/packages/client/test/format-number-field.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { formatNumberFieldValue } from '@colanode/client/lib/format-number-field'; + +describe('formatNumberFieldValue', () => { + it('formats plain integers and decimals', () => { + expect(formatNumberFieldValue(42)).toBe('42'); + expect(formatNumberFieldValue(1.5)).toBe('1.50'); + }); + + it('formats percent values', () => { + expect( + formatNumberFieldValue(0.25, { format: 'percent', currency: null }) + ).toBe('25%'); + }); + + it('formats currency values', () => { + const formatted = formatNumberFieldValue(12.5, { + format: 'currency', + currency: 'USD', + }); + expect(formatted).toContain('12.50'); + }); +}); diff --git a/packages/client/test/lookup-field-display.test.ts b/packages/client/test/lookup-field-display.test.ts new file mode 100644 index 000000000..1067762ff --- /dev/null +++ b/packages/client/test/lookup-field-display.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatLookupFieldValue, + formatLookupValues, + getLookupValueFromNode, +} from '@colanode/client/lib/lookup-field-display'; +import { + FieldAttributes, + RECORD_NAME_LOOKUP_FIELD_ID, + SelectFieldAttributes, +} from '@colanode/core'; + +const statusField: SelectFieldAttributes = { + id: 'status', + type: 'select', + name: 'Status', + index: 'a', + options: { + open: { id: 'open', name: 'Open', index: 'a', color: 'blue' }, + }, +}; + +describe('lookup-field-display', () => { + it('formats select option labels', () => { + expect( + formatLookupFieldValue( + { type: 'string', value: 'open' }, + statusField + ) + ).toBe('Open'); + }); + + it('reads system fields from related nodes', () => { + const createdAtField: FieldAttributes = { + id: 'created', + type: 'created_at', + name: 'Created', + index: 'z', + }; + + expect( + getLookupValueFromNode( + { + name: 'Row', + createdAt: '2026-01-01T00:00:00.000Z', + createdBy: 'user-1', + fields: {}, + }, + createdAtField + ) + ).toEqual({ type: 'string', value: '2026-01-01T00:00:00.000Z' }); + }); + + it('reads record primary name', () => { + expect( + getLookupValueFromNode( + { + name: 'Acme Inc', + createdAt: '2026-01-01T00:00:00.000Z', + createdBy: 'user-1', + fields: {}, + }, + { + id: RECORD_NAME_LOOKUP_FIELD_ID, + type: 'text', + name: 'Name', + index: 'a0', + } + ) + ).toEqual({ type: 'text', value: 'Acme Inc' }); + }); + + it('joins multiple lookup values', () => { + expect(formatLookupValues(['Alpha', '', 'Beta'])).toBe('Alpha, Beta'); + }); +}); diff --git a/packages/client/test/rollup-field-display.test.ts b/packages/client/test/rollup-field-display.test.ts new file mode 100644 index 000000000..828101a16 --- /dev/null +++ b/packages/client/test/rollup-field-display.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; + +import { + FieldAttributes, + RollupFieldAttributes, +} from '@colanode/core'; + +import { computeRollupResult } from '@colanode/client/lib/rollup-field-display'; + +const rollupField = ( + overrides: Partial = {} +): RollupFieldAttributes => ({ + id: 'rollup-1', + type: 'rollup', + name: 'Rollup', + index: 'a0', + relationFieldId: 'rel-1', + rolledUpFieldId: 'name-field', + aggregation: 'concatenate', + ...overrides, +}); + +const nameField: FieldAttributes = { + id: 'name-field', + type: 'text', + name: 'Name', + index: 'a0', +}; + +const selectField: FieldAttributes = { + id: 'status-field', + type: 'select', + name: 'Status', + index: 'a1', + options: { + done: { id: 'done', name: 'Done', color: 'green', index: 'a0' }, + todo: { id: 'todo', name: 'Todo', color: 'gray', index: 'a1' }, + }, +}; + +describe('computeRollupResult', () => { + it('counts linked records', () => { + const result = computeRollupResult( + rollupField({ aggregation: 'count', rolledUpFieldId: null }), + ['r1', 'r2'], + [], + undefined + ); + + expect(result).toEqual({ kind: 'number', value: 2 }); + }); + + it('concatenates rolled up text values in relation order', () => { + const result = computeRollupResult( + rollupField({ aggregation: 'concatenate' }), + ['r2', 'r1'], + [ + { + id: 'r1', + name: 'Alpha', + createdAt: '', + createdBy: '', + fields: { 'name-field': { type: 'text', value: 'Alpha' } }, + }, + { + id: 'r2', + name: 'Beta', + createdAt: '', + createdBy: '', + fields: { 'name-field': { type: 'text', value: 'Beta' } }, + }, + ], + nameField + ); + + expect(result).toEqual({ kind: 'text', value: 'Beta, Alpha' }); + }); + + it('returns unique select option labels', () => { + const result = computeRollupResult( + rollupField({ + aggregation: 'unique', + rolledUpFieldId: 'status-field', + }), + ['r1', 'r2', 'r3'], + [ + { + id: 'r1', + name: 'One', + createdAt: '', + createdBy: '', + fields: { 'status-field': { type: 'string', value: 'done' } }, + }, + { + id: 'r2', + name: 'Two', + createdAt: '', + createdBy: '', + fields: { 'status-field': { type: 'string', value: 'todo' } }, + }, + { + id: 'r3', + name: 'Three', + createdAt: '', + createdBy: '', + fields: { 'status-field': { type: 'string', value: 'done' } }, + }, + ], + selectField + ); + + expect(result).toEqual({ kind: 'text', value: 'Done, Todo' }); + }); + + it('sums numeric rolled up values', () => { + const result = computeRollupResult( + rollupField({ + aggregation: 'sum', + rolledUpFieldId: 'amount-field', + }), + ['r1', 'r2'], + [ + { + id: 'r1', + name: 'One', + createdAt: '', + createdBy: '', + fields: { 'amount-field': { type: 'number', value: 10 } }, + }, + { + id: 'r2', + name: 'Two', + createdAt: '', + createdBy: '', + fields: { 'amount-field': { type: 'number', value: 5 } }, + }, + ], + { + id: 'amount-field', + type: 'number', + name: 'Amount', + index: 'a2', + } + ); + + expect(result).toEqual({ kind: 'number', value: 15 }); + }); +}); diff --git a/packages/client/test/scheduled-task-actions.test.ts b/packages/client/test/scheduled-task-actions.test.ts new file mode 100644 index 000000000..5c1fe1be3 --- /dev/null +++ b/packages/client/test/scheduled-task-actions.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { runScheduledTaskActions } from '@colanode/client/lib/scheduled-task-actions'; +import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service'; + +const buildWorkspace = () => { + const updateNode = vi.fn().mockResolvedValue('success'); + const insertNode = vi.fn().mockResolvedValue(undefined); + + const workspace = { + userId: 'user-1', + database: { + selectFrom: vi.fn().mockReturnValue({ + selectAll: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + executeTakeFirst: vi.fn().mockResolvedValue({ + id: 'record-1', + type: 'record', + attributes: JSON.stringify({ + type: 'record', + parentId: 'db-1', + databaseId: 'db-1', + name: 'Task item', + fields: {}, + }), + }), + }), + }), + }), + }, + nodes: { + updateNode, + insertNode, + }, + } as unknown as WorkspaceService; + + return { workspace, updateNode, insertNode }; +}; + +describe('runScheduledTaskActions', () => { + it('sets a today value on matched records', async () => { + const { workspace, updateNode } = buildWorkspace(); + + workspace.database.selectFrom = vi.fn().mockReturnValue({ + selectAll: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + executeTakeFirst: vi + .fn() + .mockResolvedValueOnce({ + id: 'record-1', + type: 'record', + attributes: JSON.stringify({ + type: 'record', + parentId: 'db-1', + databaseId: 'db-1', + name: 'Task item', + fields: {}, + }), + }) + .mockResolvedValueOnce({ + id: 'db-1', + type: 'database', + attributes: JSON.stringify({ + type: 'database', + parentId: 'space-1', + name: 'Tasks DB', + fields: { + completed: { + id: 'completed', + type: 'date', + name: 'Completed', + index: 'a0', + }, + }, + }), + }), + }), + }), + }) as never; + + const results = await runScheduledTaskActions(workspace, { + actions: [ + { + type: 'setField', + target: 'matched_records', + fieldId: 'completed', + value: { kind: 'today' }, + }, + ], + matchedNodeIds: ['record-1'], + scope: null, + }); + + expect(results).toHaveLength(1); + expect(results[0]?.success).toBe(true); + expect(updateNode).toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/lib/field-defaults.ts b/packages/core/src/lib/field-defaults.ts index 93ce2bb8f..eac854854 100644 --- a/packages/core/src/lib/field-defaults.ts +++ b/packages/core/src/lib/field-defaults.ts @@ -2,11 +2,13 @@ import type { FieldAttributes } from '@colanode/core/registry/nodes/field'; import type { FieldValue } from '@colanode/core/registry/nodes/field-value'; const SCHEMA_DEFAULT_UNSUPPORTED_TYPES = new Set([ + 'autonumber', 'created_at', 'created_by', 'updated_at', 'updated_by', 'relation', + 'lookup', 'rollup', 'resource', 'file', @@ -18,6 +20,13 @@ export const supportsFieldSchemaDefault = (field: FieldAttributes): boolean => { return !SCHEMA_DEFAULT_UNSUPPORTED_TYPES.has(field.type); }; +/** Virtual field id for lookup/rollup of a related record's primary name. */ +export const RECORD_NAME_LOOKUP_FIELD_ID = '__record_name__' as const; + +export const isRecordNameLookupFieldId = (fieldId: string): boolean => { + return fieldId === RECORD_NAME_LOOKUP_FIELD_ID; +}; + export const fieldDefaultToFieldValue = ( field: FieldAttributes ): FieldValue | null => { diff --git a/packages/core/src/lib/scheduled-tasks.ts b/packages/core/src/lib/scheduled-tasks.ts index 53cc52aad..fb2657522 100644 --- a/packages/core/src/lib/scheduled-tasks.ts +++ b/packages/core/src/lib/scheduled-tasks.ts @@ -10,6 +10,8 @@ export const SCHEDULED_TASK_FIELD_IDS = { check: '__st_check', notify: '__st_notify', scope: '__st_scope', + actions: '__st_actions', + lastRunLog: '__st_last_run_log', } as const; export const REMINDERS_RELATION_FIELD_ID = '__colanode_reminders'; diff --git a/packages/core/src/registry/nodes/database-view.ts b/packages/core/src/registry/nodes/database-view.ts index d9713e158..44e3a85c9 100644 --- a/packages/core/src/registry/nodes/database-view.ts +++ b/packages/core/src/registry/nodes/database-view.ts @@ -72,7 +72,7 @@ export type DatabaseViewSortAttributes = z.infer< export const databaseViewAttributesSchema = z.object({ type: z.literal('database_view'), parentId: z.string(), - layout: z.enum(['table', 'board', 'calendar', 'gallery', 'list']), + layout: z.enum(['table', 'board', 'calendar', 'gallery', 'list', 'form']), name: z.string(), avatar: z.string().nullable().optional(), index: z.string(), @@ -89,6 +89,7 @@ export const databaseViewAttributesSchema = z.object({ .optional() .nullable(), groupBy: z.string().nullable().optional(), + groupByLevels: z.array(z.string()).max(2).optional().nullable(), nameWidth: z.number().nullable().optional(), }); @@ -100,7 +101,8 @@ export type DatabaseViewLayout = | 'board' | 'calendar' | 'gallery' - | 'list'; + | 'list' + | 'form'; export const databaseViewModel: NodeModel = { type: 'database_view', diff --git a/packages/core/src/registry/nodes/field.ts b/packages/core/src/registry/nodes/field.ts index 718bbb592..6a0bbbce2 100644 --- a/packages/core/src/registry/nodes/field.ts +++ b/packages/core/src/registry/nodes/field.ts @@ -105,10 +105,24 @@ export const numberFieldAttributesSchema = z.object({ name: z.string(), index: z.string(), default: z.number().optional(), + format: z.enum(['plain', 'percent', 'currency']).optional().nullable(), + currency: z.string().optional().nullable(), }); export type NumberFieldAttributes = z.infer; +export const autonumberFieldAttributesSchema = z.object({ + id: z.string(), + type: z.literal('autonumber'), + name: z.string(), + index: z.string(), + nextValue: z.number().int().positive().optional(), +}); + +export type AutonumberFieldAttributes = z.infer< + typeof autonumberFieldAttributesSchema +>; + export const phoneFieldAttributesSchema = z.object({ id: z.string(), type: z.literal('phone'), @@ -125,6 +139,7 @@ export const relationFieldAttributesSchema = z.object({ name: z.string(), index: z.string(), databaseId: z.string().optional().nullable(), + reverseFieldId: z.string().optional().nullable(), }); export type RelationFieldAttributes = z.infer< @@ -149,6 +164,9 @@ export const rollupAggregationSchema = z.enum([ 'average', 'min', 'max', + 'show_values', + 'unique', + 'concatenate', ]); export type RollupAggregation = z.infer; @@ -165,6 +183,17 @@ export const rollupFieldAttributesSchema = z.object({ export type RollupFieldAttributes = z.infer; +export const lookupFieldAttributesSchema = z.object({ + id: z.string(), + type: z.literal('lookup'), + name: z.string(), + index: z.string(), + relationFieldId: z.string().optional().nullable(), + lookedUpFieldId: z.string().optional().nullable(), +}); + +export type LookupFieldAttributes = z.infer; + export const selectFieldAttributesSchema = z.object({ id: z.string(), type: z.literal('select'), @@ -241,6 +270,7 @@ export const formulaFieldAttributesSchema = z.object({ export type FormulaFieldAttributes = z.infer; export const fieldAttributesSchema = z.discriminatedUnion('type', [ + autonumberFieldAttributesSchema, booleanFieldAttributesSchema, buttonFieldAttributesSchema, collaboratorFieldAttributesSchema, @@ -250,6 +280,7 @@ export const fieldAttributesSchema = z.discriminatedUnion('type', [ emailFieldAttributesSchema, fileFieldAttributesSchema, formulaFieldAttributesSchema, + lookupFieldAttributesSchema, multiSelectFieldAttributesSchema, numberFieldAttributesSchema, phoneFieldAttributesSchema, diff --git a/packages/ui/src/components/app/app-error.tsx b/packages/ui/src/components/app/app-error.tsx new file mode 100644 index 000000000..ec682e84d --- /dev/null +++ b/packages/ui/src/components/app/app-error.tsx @@ -0,0 +1,26 @@ +import { CircleAlert } from 'lucide-react'; + +import { Button } from '@colanode/ui/components/ui/button'; + +export const AppError = () => { + return ( +
+
+ +

Something went wrong

+

+ Colanode could not start. Try reloading the page. If the problem + persists, reset local data or use the desktop app. +

+
+ + +
+
+
+ ); +}; diff --git a/packages/ui/src/components/app/app-loading.tsx b/packages/ui/src/components/app/app-loading.tsx index 6823be5ee..5a5a622b1 100644 --- a/packages/ui/src/components/app/app-loading.tsx +++ b/packages/ui/src/components/app/app-loading.tsx @@ -1,17 +1,14 @@ -import { DelayedComponent } from '@colanode/ui/components/ui/delayed-component'; import { Spinner } from '@colanode/ui/components/ui/spinner'; export const AppLoading = () => { return (
- -
-

- loading your workspace -

- -
-
+
+

+ loading your workspace +

+ +
); }; diff --git a/packages/ui/src/components/app/app-provider.tsx b/packages/ui/src/components/app/app-provider.tsx index 4a5b3478c..dfa09fe97 100644 --- a/packages/ui/src/components/app/app-provider.tsx +++ b/packages/ui/src/components/app/app-provider.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { AppInitOutput, AppType } from '@colanode/client/types'; import { build } from '@colanode/core'; import { collections } from '@colanode/ui/collections'; +import { AppError } from '@colanode/ui/components/app/app-error'; import { AppAssets } from '@colanode/ui/components/app/app-assets'; import { AppLayout } from '@colanode/ui/components/app/app-layout'; import { AppLoading } from '@colanode/ui/components/app/app-loading'; @@ -21,23 +22,29 @@ export const AppProvider = ({ type }: AppProviderProps) => { useEffect(() => { console.log(`Colanode | Version: ${build.version} | SHA: ${build.sha}`); - window.colanode.init().then((output) => { - console.log('Colanode | Initialized'); + window.colanode + .init() + .then((output) => { + console.log('Colanode | Initialized'); - if (output === 'success') { - collections - .preload() - .then(() => { - setInitOutput('success'); - }) - .catch((err) => { - setInitOutput('error'); - console.error('Colanode | Error preloading', err); - }); - } else { - setInitOutput(output); - } - }); + if (output === 'success') { + collections + .preload() + .then(() => { + setInitOutput('success'); + }) + .catch((err) => { + setInitOutput('error'); + console.error('Colanode | Error preloading', err); + }); + } else { + setInitOutput(output); + } + }) + .catch((err) => { + setInitOutput('error'); + console.error('Colanode | Init failed', err); + }); }, []); return ( @@ -46,6 +53,7 @@ export const AppProvider = ({ type }: AppProviderProps) => { {initOutput === null && } {initOutput === 'reset' && } + {initOutput === 'error' && } {initOutput === 'success' && ( diff --git a/packages/ui/src/components/databases/fields/field-create-popover.tsx b/packages/ui/src/components/databases/fields/field-create-popover.tsx index fde98d336..1d9e989de 100644 --- a/packages/ui/src/components/databases/fields/field-create-popover.tsx +++ b/packages/ui/src/components/databases/fields/field-create-popover.tsx @@ -17,6 +17,7 @@ import { import { DatabaseSelect } from '@colanode/ui/components/databases/database-select'; import { FieldTypeSelect } from '@colanode/ui/components/databases/fields/field-type-select'; import { ResourceTypesSelect } from '@colanode/ui/components/databases/fields/resource-types-select'; +import { LookupConfig } from '@colanode/ui/components/databases/fields/lookup-config'; import { RollupConfig } from '@colanode/ui/components/databases/fields/rollup-config'; import { Button } from '@colanode/ui/components/ui/button'; import { @@ -25,6 +26,7 @@ import { FieldGroup, FieldLabel, } from '@colanode/ui/components/ui/field'; +import { Checkbox } from '@colanode/ui/components/ui/checkbox'; import { Input } from '@colanode/ui/components/ui/input'; import { Popover, @@ -41,10 +43,12 @@ import { Textarea } from '@colanode/ui/components/ui/textarea'; import { useDatabase } from '@colanode/ui/contexts/database'; import { useWorkspace } from '@colanode/ui/contexts/workspace'; import { getRandomSelectOptionColor } from '@colanode/ui/lib/databases'; +import { updateNodeInCollection } from '@colanode/ui/lib/nodes'; const formSchema = z.object({ name: z.string().min(1, { message: 'Name is required' }), type: z.union([ + z.literal('autonumber'), z.literal('boolean'), z.literal('button'), z.literal('formula'), @@ -61,17 +65,30 @@ const formSchema = z.object({ z.literal('text'), z.literal('relation'), z.literal('resource'), + z.literal('lookup'), z.literal('rollup'), z.literal('updated_at'), z.literal('updated_by'), z.literal('url'), ]), relationDatabaseId: z.string().optional().nullable(), + createReverseRelation: z.boolean().optional(), + reverseRelationName: z.string().optional().nullable(), relationFieldId: z.string().optional().nullable(), rolledUpFieldId: z.string().optional().nullable(), + lookedUpFieldId: z.string().optional().nullable(), resourceAllowedTypes: z.array(z.string()).optional().nullable(), aggregation: z - .enum(['count', 'sum', 'average', 'min', 'max']) + .enum([ + 'count', + 'sum', + 'average', + 'min', + 'max', + 'show_values', + 'unique', + 'concatenate', + ]) .optional() .nullable(), fieldDefault: z.string().optional().nullable(), @@ -85,8 +102,11 @@ const defaultValues: FieldCreateFormValues = { name: '', type: 'text', relationDatabaseId: null, + createReverseRelation: false, + reverseRelationName: '', relationFieldId: null, rolledUpFieldId: null, + lookedUpFieldId: null, resourceAllowedTypes: null, aggregation: null, fieldDefault: '', @@ -378,6 +398,10 @@ export const FieldCreatePopover = ({ }); const type = useStore(form.store, (state) => state.values.type); + const createReverseRelation = useStore( + form.store, + (state) => state.values.createReverseRelation ?? false + ); const relationFieldId = useStore( form.store, (state) => state.values.relationFieldId @@ -386,6 +410,10 @@ export const FieldCreatePopover = ({ form.store, (state) => state.values.rolledUpFieldId ); + const lookedUpFieldId = useStore( + form.store, + (state) => state.values.lookedUpFieldId + ); const aggregation = useStore( form.store, (state) => state.values.aggregation @@ -455,6 +483,16 @@ export const FieldCreatePopover = ({ } const fieldId = generateId(IdType.Field); + let createdReverseFieldId: string | null = null; + + if ( + values.type === 'relation' && + values.createReverseRelation && + values.relationDatabaseId + ) { + createdReverseFieldId = generateId(IdType.Field); + } + nodes.update(database.id, (draft) => { if (draft.type !== 'database') { return; @@ -473,6 +511,9 @@ export const FieldCreatePopover = ({ if (newField.type === 'relation') { newField.databaseId = values.relationDatabaseId; + if (createdReverseFieldId) { + newField.reverseFieldId = createdReverseFieldId; + } } if (newField.type === 'resource') { @@ -488,6 +529,11 @@ export const FieldCreatePopover = ({ newField.aggregation = values.aggregation; } + if (newField.type === 'lookup') { + newField.relationFieldId = values.relationFieldId; + newField.lookedUpFieldId = values.lookedUpFieldId; + } + if (newField.type === 'button') { newField.label = values.buttonLabel?.trim() || values.name; newField.script = values.buttonScript ?? ''; @@ -501,11 +547,56 @@ export const FieldCreatePopover = ({ newField.expression = values.formulaExpression?.trim() || ''; } + if (newField.type === 'autonumber') { + newField.nextValue = 1; + } + applyFieldDefault(newField, values.fieldDefault); draft.fields[fieldId] = newField; }); + if ( + values.type === 'relation' && + createdReverseFieldId && + values.relationDatabaseId + ) { + const targetDatabase = nodes.get(values.relationDatabaseId); + if (!targetDatabase || targetDatabase.type !== 'database') { + throw new MutationError( + MutationErrorCode.RelationDatabaseNotFound, + 'Relation database not found.' + ); + } + + const reverseName = + values.reverseRelationName?.trim() || database.name; + + await updateNodeInCollection( + nodes, + workspace.userId, + targetDatabase, + (draft) => { + if (draft.type !== 'database') { + return; + } + + const reverseIndex = getNextFractionalIndex( + Object.values(draft.fields).map((field) => field.index) + ); + + draft.fields[createdReverseFieldId!] = { + id: createdReverseFieldId!, + type: 'relation', + name: reverseName, + index: reverseIndex, + databaseId: database.id, + reverseFieldId: fieldId, + }; + } + ); + } + return fieldId; }, onSuccess: (fieldId) => { @@ -610,18 +701,63 @@ export const FieldCreatePopover = ({ )} /> {type === 'relation' && ( - ( - - Database - field.handleChange(value)} - /> - + <> + ( + + Database + field.handleChange(value)} + /> + + )} + /> + ( + +
+ + field.handleChange(checked === true) + } + /> + + Link records from both sides + +
+
+ )} + /> + {createReverseRelation && ( + ( + + + Reverse field name + + + field.handleChange(event.target.value) + } + placeholder={database.name} + /> + + )} + /> )} - /> + )} {type === 'resource' && ( )} + {type === 'lookup' && ( + { + form.setFieldValue('relationFieldId', value); + form.setFieldValue('lookedUpFieldId', null); + }} + onChangeLookedUpField={(value) => + form.setFieldValue('lookedUpFieldId', value) + } + /> + )} {type === 'rollup' && ( { const nodes = workspace.collections.nodes; + const fieldToDelete = database.fields.find( + (field) => field.id === id + ); + nodes.update(database.id, (draft) => { if (draft.type !== 'database') { return; @@ -56,6 +61,32 @@ export const FieldDeleteDialog = ({ const { [id]: _removed, ...rest } = draft.fields; draft.fields = rest; }); + + if ( + fieldToDelete?.type === 'relation' && + fieldToDelete.reverseFieldId && + fieldToDelete.databaseId + ) { + const targetDatabase = nodes.get(fieldToDelete.databaseId); + if (targetDatabase?.type === 'database') { + const reverseFieldId = fieldToDelete.reverseFieldId; + await updateNodeInCollection( + nodes, + workspace.userId, + targetDatabase, + (draft) => { + if (draft.type !== 'database') { + return; + } + + const { [reverseFieldId]: _removed, ...rest } = + draft.fields; + draft.fields = rest; + } + ); + } + } + onOpenChange(false); }} > diff --git a/packages/ui/src/components/databases/fields/field-icon.tsx b/packages/ui/src/components/databases/fields/field-icon.tsx index 049b37204..fafdcd2ff 100644 --- a/packages/ui/src/components/databases/fields/field-icon.tsx +++ b/packages/ui/src/components/databases/fields/field-icon.tsx @@ -1,4 +1,5 @@ import { + ArrowUpRight, AtSign, Cable, Calendar, @@ -27,6 +28,8 @@ interface FieldIconProps { export const FieldIcon = ({ type, className }: FieldIconProps) => { switch (type) { + case 'autonumber': + return ; case 'boolean': return ; case 'button': @@ -57,6 +60,8 @@ export const FieldIcon = ({ type, className }: FieldIconProps) => { return ; case 'resource': return ; + case 'lookup': + return ; case 'rollup': return ; case 'text': diff --git a/packages/ui/src/components/databases/fields/field-select.tsx b/packages/ui/src/components/databases/fields/field-select.tsx index 4d4299766..b319da2f8 100644 --- a/packages/ui/src/components/databases/fields/field-select.tsx +++ b/packages/ui/src/components/databases/fields/field-select.tsx @@ -51,13 +51,14 @@ export const FieldSelect = ({ fields, value, onChange }: FieldSelectProps) => { - + No field type found. {fields.map((field) => ( { onChange(field.id); setOpen(false); diff --git a/packages/ui/src/components/databases/fields/field-type-select.tsx b/packages/ui/src/components/databases/fields/field-type-select.tsx index 07fdf7669..23a975905 100644 --- a/packages/ui/src/components/databases/fields/field-type-select.tsx +++ b/packages/ui/src/components/databases/fields/field-type-select.tsx @@ -34,6 +34,10 @@ const fieldTypes: FieldTypeOption[] = [ name: 'Button', type: 'button', }, + { + name: 'Auto number', + type: 'autonumber', + }, { name: 'Boolean', type: 'boolean', @@ -90,6 +94,10 @@ const fieldTypes: FieldTypeOption[] = [ name: 'Resource', type: 'resource', }, + { + name: 'Lookup', + type: 'lookup', + }, { name: 'Rollup', type: 'rollup', diff --git a/packages/ui/src/components/databases/fields/lookup-config.tsx b/packages/ui/src/components/databases/fields/lookup-config.tsx new file mode 100644 index 000000000..4dce32eb7 --- /dev/null +++ b/packages/ui/src/components/databases/fields/lookup-config.tsx @@ -0,0 +1,103 @@ +import { eq, useLiveQuery } from '@tanstack/react-db'; +import { useMemo } from 'react'; + +import { + FieldAttributes, + RelationFieldAttributes, + RECORD_NAME_LOOKUP_FIELD_ID, + TextFieldAttributes, +} from '@colanode/core'; +import { FieldSelect } from '@colanode/ui/components/databases/fields/field-select'; +import { Field, FieldLabel } from '@colanode/ui/components/ui/field'; +import { useDatabase } from '@colanode/ui/contexts/database'; +import { useWorkspace } from '@colanode/ui/contexts/workspace'; + +interface LookupConfigProps { + relationFieldId: string | null | undefined; + lookedUpFieldId: string | null | undefined; + onChangeRelationField: (fieldId: string | null) => void; + onChangeLookedUpField: (fieldId: string | null) => void; +} + +export const LookupConfig = ({ + relationFieldId, + lookedUpFieldId, + onChangeRelationField, + onChangeLookedUpField, +}: LookupConfigProps) => { + const workspace = useWorkspace(); + const database = useDatabase(); + + const relationFields = useMemo( + () => + database.fields.filter( + (field): field is RelationFieldAttributes => field.type === 'relation' + ), + [database.fields] + ); + + const selectedRelationField = relationFields.find( + (field) => field.id === relationFieldId + ); + const targetDatabaseId = selectedRelationField?.databaseId ?? null; + + const targetDatabaseQuery = useLiveQuery( + (q) => + q + .from({ nodes: workspace.collections.nodes }) + .where(({ nodes }) => eq(nodes.id, targetDatabaseId ?? '')), + [workspace.userId, targetDatabaseId] + ); + + const targetFields = useMemo(() => { + const node = targetDatabaseQuery.data[0]; + if (!node || node.type !== 'database') { + return [] as FieldAttributes[]; + } + + const schemaFields = Object.values(node.fields ?? {}).filter( + (field) => + field.type !== 'lookup' && + field.type !== 'rollup' && + field.type !== 'formula' && + field.type !== 'button' + ); + + const recordNameField: TextFieldAttributes = { + id: RECORD_NAME_LOOKUP_FIELD_ID, + type: 'text', + name: 'Name', + index: 'a0', + }; + + return [recordNameField, ...schemaFields]; + }, [targetDatabaseQuery.data]); + + return ( +
+ + Relation field + onChangeRelationField(value)} + /> + {relationFields.length === 0 && ( +

+ Create a relation field first to use a lookup. +

+ )} +
+ {targetDatabaseId && ( + + Field to look up + onChangeLookedUpField(value)} + /> + + )} +
+ ); +}; diff --git a/packages/ui/src/components/databases/fields/number-format-settings.tsx b/packages/ui/src/components/databases/fields/number-format-settings.tsx new file mode 100644 index 000000000..81e767c8f --- /dev/null +++ b/packages/ui/src/components/databases/fields/number-format-settings.tsx @@ -0,0 +1,77 @@ +import { NumberFieldAttributes } from '@colanode/core'; +import { Label } from '@colanode/ui/components/ui/label'; +import { useDatabase } from '@colanode/ui/contexts/database'; +import { useWorkspace } from '@colanode/ui/contexts/workspace'; + +interface NumberFormatSettingsProps { + field: NumberFieldAttributes; +} + +export const NumberFormatSettings = ({ field }: NumberFormatSettingsProps) => { + const workspace = useWorkspace(); + const database = useDatabase(); + + if (!database.canEdit || database.isLocked) { + return null; + } + + const updateField = (updater: (draft: NumberFieldAttributes) => void) => { + workspace.collections.nodes.update(database.id, (draft) => { + if (draft.type !== 'database') { + return; + } + + const fieldAttributes = draft.fields[field.id]; + if (!fieldAttributes || fieldAttributes.type !== 'number') { + return; + } + + updater(fieldAttributes); + }); + }; + + return ( +
+
+ + +
+ + {(field.format ?? 'plain') === 'currency' && ( +
+ + { + const value = event.target.value.trim().toUpperCase(); + updateField((draft) => { + draft.currency = value.length > 0 ? value : 'USD'; + }); + }} + /> +
+ )} +
+ ); +}; diff --git a/packages/ui/src/components/databases/fields/rollup-config.tsx b/packages/ui/src/components/databases/fields/rollup-config.tsx index 014a297d3..5cf57ecbd 100644 --- a/packages/ui/src/components/databases/fields/rollup-config.tsx +++ b/packages/ui/src/components/databases/fields/rollup-config.tsx @@ -7,6 +7,7 @@ import { RelationFieldAttributes, RollupAggregation, } from '@colanode/core'; +import { isNumericRollupAggregation, rollupAggregationNeedsField } from '@colanode/client/lib'; import { FieldSelect } from '@colanode/ui/components/databases/fields/field-select'; import { Button } from '@colanode/ui/components/ui/button'; import { @@ -34,6 +35,9 @@ const AGGREGATION_OPTIONS: { value: RollupAggregation; label: string }[] = [ { value: 'average', label: 'Average' }, { value: 'min', label: 'Minimum' }, { value: 'max', label: 'Maximum' }, + { value: 'show_values', label: 'Show values' }, + { value: 'unique', label: 'Unique values' }, + { value: 'concatenate', label: 'Concatenate' }, ]; interface RollupConfigProps { @@ -138,8 +142,13 @@ export const RollupConfig = ({ return [] as FieldAttributes[]; } - return Object.values(node.fields ?? {}); - }, [targetDatabaseQuery.data]); + const fields = Object.values(node.fields ?? {}); + if (!aggregation || !isNumericRollupAggregation(aggregation)) { + return fields; + } + + return fields.filter((field) => field.type === 'number'); + }, [aggregation, targetDatabaseQuery.data]); return (
@@ -160,7 +169,7 @@ export const RollupConfig = ({ Aggregation - {aggregation && aggregation !== 'count' && targetDatabaseId && ( + {aggregation && rollupAggregationNeedsField(aggregation) && targetDatabaseId && ( Field to aggregate { + return ![ + 'autonumber', + 'lookup', + 'rollup', + 'formula', + 'created_at', + 'created_by', + 'updated_at', + 'updated_by', + 'button', + 'relation', + 'file', + 'resource', + 'collaborator', + 'multi_select', + ].includes(type); +}; + +export const FormView = () => { + const workspace = useWorkspace(); + const database = useDatabase(); + const view = useDatabaseView(); + + const [name, setName] = useState(''); + const [fieldValues, setFieldValues] = useState>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + + const configuredFields = view.fields + .map((viewField) => viewField.field) + .filter((field) => isFormEditableField(field.type)); + + const formFields = + configuredFields.length > 0 + ? configuredFields + : filterUserEditableSchemaFields(database.fields).filter((field) => + isFormEditableField(field.type) + ); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!database.canCreateRecord) { + return; + } + + setIsSubmitting(true); + + try { + const fields: Record = {}; + + for (const field of formFields) { + const rawValue = fieldValues[field.id]?.trim() ?? ''; + if (!rawValue) { + continue; + } + + switch (field.type) { + case 'text': + fields[field.id] = { type: 'text', value: rawValue }; + break; + case 'number': { + const parsed = parseFloat(rawValue); + if (!Number.isNaN(parsed)) { + fields[field.id] = { type: 'number', value: parsed }; + } + break; + } + case 'boolean': + fields[field.id] = { + type: 'boolean', + value: rawValue === 'true', + }; + break; + case 'email': + case 'phone': + case 'url': + case 'date': + case 'select': + fields[field.id] = { type: 'string', value: rawValue }; + break; + default: + fields[field.id] = { type: 'text', value: rawValue }; + break; + } + } + + insertDatabaseRecord( + workspace, + { + id: database.id, + rootId: database.rootId, + fields: database.fields, + }, + { + name, + fields, + } + ); + + setName(''); + setFieldValues({}); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+ +
+ +
+
+
+ + setName(event.target.value)} + placeholder="Record name" + /> +
+ + {formFields.map((field) => ( +
+ + + {field.type === 'boolean' ? ( + + ) : field.type === 'select' ? ( + + ) : ( + + setFieldValues((prev) => ({ + ...prev, + [field.id]: event.target.value, + })) + } + /> + )} +
+ ))} + + {database.canCreateRecord && ( + + )} +
+
+
+ ); +}; diff --git a/packages/ui/src/components/databases/search/view-filters.tsx b/packages/ui/src/components/databases/search/view-filters.tsx index ecf56ef7b..24b440cdd 100644 --- a/packages/ui/src/components/databases/search/view-filters.tsx +++ b/packages/ui/src/components/databases/search/view-filters.tsx @@ -1,6 +1,6 @@ import { Plus } from 'lucide-react'; -import { SpecialId } from '@colanode/core'; +import { SpecialId, TextFieldAttributes } from '@colanode/core'; import { ViewBooleanFieldFilter } from '@colanode/ui/components/databases/search/view-boolean-field-filter'; import { ViewCollaboratorFieldFilter } from '@colanode/ui/components/databases/search/view-collaborator-field-filter'; import { ViewCreatedAtFieldFilter } from '@colanode/ui/components/databases/search/view-created-at-field-fitler'; @@ -96,6 +96,21 @@ export const ViewFilters = () => { ); case 'file': return null; + case 'formula': + return ( + + ); case 'multi_select': return ( { const view = useDatabaseView(); + const selection = useTableViewSelection(); const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useCollectionQuery(view.filters, view.sorts); const rows = data; + const visibleRowIdsKey = useMemo( + () => buildVisibleRowIdsKey(rows.map((row) => row.id)), + [rows] + ); + + useEffect(() => { + selection.setVisibleIds('main', parseVisibleRowIdsKey(visibleRowIdsKey)); + }, [selection.setVisibleIds, visibleRowIdsKey]); return (
diff --git a/packages/ui/src/components/databases/tables/table-view-bulk-action-bar.tsx b/packages/ui/src/components/databases/tables/table-view-bulk-action-bar.tsx new file mode 100644 index 000000000..f3187de4c --- /dev/null +++ b/packages/ui/src/components/databases/tables/table-view-bulk-action-bar.tsx @@ -0,0 +1,117 @@ +import { useState } from 'react'; + +import { Trash2, Copy, X } from 'lucide-react'; + +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@colanode/ui/components/ui/alert-dialog'; +import { Button } from '@colanode/ui/components/ui/button'; +import { TableViewBulkSetDialog } from '@colanode/ui/components/databases/tables/table-view-bulk-set-dialog'; +import { useDatabase } from '@colanode/ui/contexts/database'; +import { useTableViewSelection } from '@colanode/ui/contexts/table-view-selection'; +import { useWorkspace } from '@colanode/ui/contexts/workspace'; +import { bulkDeleteNodes } from '@colanode/ui/lib/bulk-record-operations'; +import { duplicateCollectionRows } from '@colanode/ui/lib/record-duplicate'; + +export const TableViewBulkActionBar = () => { + const workspace = useWorkspace(); + const database = useDatabase(); + const selection = useTableViewSelection(); + const [setFieldOpen, setSetFieldOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + if (!selection.enabled || selection.selectedCount === 0) { + return null; + } + + const handleDuplicate = () => { + duplicateCollectionRows( + workspace, + [...selection.selectedIds], + database.fields + ); + selection.clearSelection(); + }; + + const canDuplicate = + database.canCreateRecord && + [...selection.selectedIds].every((id) => { + const node = workspace.collections.nodes.get(id); + return node?.type === 'record'; + }); + + const handleDelete = () => { + bulkDeleteNodes(workspace.collections, [...selection.selectedIds]); + selection.clearSelection(); + setDeleteOpen(false); + }; + + return ( + <> +
+
+ + {selection.selectedCount} selected + + +
+ +
+ + {canDuplicate && ( + + )} + +
+
+ + + + + + + + Delete {selection.selectedCount} record + {selection.selectedCount === 1 ? '' : 's'}? + + + This action cannot be undone. The selected records will be + permanently deleted. + + + + Cancel + + + + + + ); +}; diff --git a/packages/ui/src/components/databases/tables/table-view-bulk-set-dialog.tsx b/packages/ui/src/components/databases/tables/table-view-bulk-set-dialog.tsx new file mode 100644 index 000000000..6015eca0a --- /dev/null +++ b/packages/ui/src/components/databases/tables/table-view-bulk-set-dialog.tsx @@ -0,0 +1,302 @@ +import { useState } from 'react'; + +import { + FieldAttributes, + FieldValue, + SelectFieldAttributes, +} from '@colanode/core'; +import { SelectFieldOptions } from '@colanode/ui/components/databases/fields/select-field-options'; +import { Button } from '@colanode/ui/components/ui/button'; +import { Checkbox } from '@colanode/ui/components/ui/checkbox'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@colanode/ui/components/ui/dialog'; +import { Input } from '@colanode/ui/components/ui/input'; +import { Label } from '@colanode/ui/components/ui/label'; +import { UserSearch } from '@colanode/ui/components/users/user-search'; +import { useDatabase } from '@colanode/ui/contexts/database'; +import { useTableViewSelection } from '@colanode/ui/contexts/table-view-selection'; +import { useWorkspace } from '@colanode/ui/contexts/workspace'; +import { + bulkSetFieldValue, + isBulkEditableField, +} from '@colanode/ui/lib/bulk-record-operations'; + +interface TableViewBulkSetDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const TableViewBulkSetDialog = ({ + open, + onOpenChange, +}: TableViewBulkSetDialogProps) => { + const workspace = useWorkspace(); + const database = useDatabase(); + const selection = useTableViewSelection(); + + const editableFields = database.fields.filter(isBulkEditableField); + const [fieldId, setFieldId] = useState(editableFields[0]?.id ?? ''); + const [textValue, setTextValue] = useState(''); + const [numberValue, setNumberValue] = useState(''); + const [booleanValue, setBooleanValue] = useState(false); + const [selectValue, setSelectValue] = useState(''); + const [multiSelectValues, setMultiSelectValues] = useState([]); + const [collaboratorIds, setCollaboratorIds] = useState([]); + const [clearValue, setClearValue] = useState(false); + + const selectedField = editableFields.find((field) => field.id === fieldId); + + const resetValueState = () => { + setTextValue(''); + setNumberValue(''); + setBooleanValue(false); + setSelectValue(''); + setMultiSelectValues([]); + setCollaboratorIds([]); + setClearValue(false); + }; + + const buildFieldValue = (): FieldValue | null => { + if (!selectedField || clearValue) { + return null; + } + + switch (selectedField.type) { + case 'text': + return { type: 'text', value: textValue }; + case 'email': + case 'phone': + case 'url': + case 'select': + return { type: 'string', value: selectValue }; + case 'number': { + const parsed = parseFloat(numberValue); + if (Number.isNaN(parsed)) { + return null; + } + + return { type: 'number', value: parsed }; + } + case 'boolean': + return { type: 'boolean', value: booleanValue }; + case 'date': + return { type: 'string', value: textValue }; + case 'multi_select': + case 'relation': + case 'resource': + return { type: 'string_array', value: multiSelectValues }; + case 'collaborator': + return { type: 'string_array', value: collaboratorIds }; + default: + return textValue + ? { type: 'text', value: textValue } + : null; + } + }; + + const handleApply = () => { + if (!selectedField || selection.selectedCount === 0) { + return; + } + + const value = buildFieldValue(); + bulkSetFieldValue( + workspace.collections, + database.fields, + [...selection.selectedIds], + selectedField.id, + value + ); + + selection.clearSelection(); + resetValueState(); + onOpenChange(false); + }; + + return ( + + + + + Set field for {selection.selectedCount} record + {selection.selectedCount === 1 ? '' : 's'} + + + +
+
+ + +
+ +
+ setClearValue(checked === true)} + /> + +
+ + {!clearValue && selectedField && ( + + )} +
+ + + + + +
+
+ ); +}; + +interface BulkFieldValueEditorProps { + field: FieldAttributes; + textValue: string; + numberValue: string; + booleanValue: boolean; + selectValue: string; + multiSelectValues: string[]; + collaboratorIds: string[]; + onTextValueChange: (value: string) => void; + onNumberValueChange: (value: string) => void; + onBooleanValueChange: (value: boolean) => void; + onSelectValueChange: (value: string) => void; + onMultiSelectValuesChange: (value: string[]) => void; + onCollaboratorIdsChange: (value: string[]) => void; +} + +const BulkFieldValueEditor = ({ + field, + textValue, + numberValue, + booleanValue, + selectValue, + multiSelectValues, + collaboratorIds, + onTextValueChange, + onNumberValueChange, + onBooleanValueChange, + onSelectValueChange, + onMultiSelectValuesChange, + onCollaboratorIdsChange, +}: BulkFieldValueEditorProps) => { + if (field.type === 'boolean') { + return ( +
+ onBooleanValueChange(checked === true)} + /> + +
+ ); + } + + if (field.type === 'number') { + return ( +
+ + onNumberValueChange(event.target.value)} + /> +
+ ); + } + + if (field.type === 'select' || field.type === 'multi_select') { + return ( +
+ + { + if (field.type === 'select') { + onSelectValueChange(optionId); + return; + } + + const nextValues = multiSelectValues.includes(optionId) + ? multiSelectValues.filter((id) => id !== optionId) + : [...multiSelectValues, optionId]; + onMultiSelectValuesChange(nextValues); + }} + allowAdd={field.type === 'multi_select'} + /> +
+ ); + } + + if (field.type === 'collaborator') { + return ( +
+ + { + const nextIds = collaboratorIds.includes(user.id) + ? collaboratorIds.filter((id) => id !== user.id) + : [...collaboratorIds, user.id]; + onCollaboratorIdsChange(nextIds); + onMultiSelectValuesChange(nextIds); + }} + /> +
+ ); + } + + return ( +
+ + onTextValueChange(event.target.value)} + /> +
+ ); +}; diff --git a/packages/ui/src/components/databases/tables/table-view-field-header.tsx b/packages/ui/src/components/databases/tables/table-view-field-header.tsx index 433968d62..5ba396844 100644 --- a/packages/ui/src/components/databases/tables/table-view-field-header.tsx +++ b/packages/ui/src/components/databases/tables/table-view-field-header.tsx @@ -11,6 +11,7 @@ import { FieldIcon } from '@colanode/ui/components/databases/fields/field-icon'; import { FieldRenameInput } from '@colanode/ui/components/databases/fields/field-rename-input'; import { FieldSchemaDefaultSettings } from '@colanode/ui/components/databases/fields/field-schema-default-settings'; import { FormulaExpressionSettings } from '@colanode/ui/components/databases/fields/formula-expression-settings'; +import { NumberFormatSettings } from '@colanode/ui/components/databases/fields/number-format-settings'; import { Popover, PopoverContent, @@ -256,6 +257,12 @@ export const TableViewFieldHeader = ({ )} + {viewField.field.type === 'number' && ( + <> + + + + )} {canSort && ( diff --git a/packages/ui/src/components/databases/tables/table-view-footer.tsx b/packages/ui/src/components/databases/tables/table-view-footer.tsx new file mode 100644 index 000000000..ac425c4e5 --- /dev/null +++ b/packages/ui/src/components/databases/tables/table-view-footer.tsx @@ -0,0 +1,129 @@ +import { useMemo } from 'react'; + +import { formatNumberFieldValue } from '@colanode/client/lib'; +import { NumberFieldAttributes } from '@colanode/core'; +import { + TABLE_VIEW_CHECKBOX_WIDTH, + useTableViewSelection, +} from '@colanode/ui/contexts/table-view-selection'; +import { useDatabase } from '@colanode/ui/contexts/database'; +import { useDatabaseView } from '@colanode/ui/contexts/database-view'; +import { useWorkspace } from '@colanode/ui/contexts/workspace'; +import { useLiveQuery } from '@colanode/ui/hooks/use-live-query'; + +const formatMetric = (value: number, fractionDigits = 2): string => { + if (Number.isInteger(value)) { + return value.toLocaleString(); + } + + return value.toLocaleString(undefined, { + maximumFractionDigits: fractionDigits, + }); +}; + +export const TableViewFooter = () => { + const workspace = useWorkspace(); + const database = useDatabase(); + const view = useDatabaseView(); + const selection = useTableViewSelection(); + const isMetaType = database.isPageMetaType || database.isFileMetaType; + + const numberFields = useMemo( + () => + view.fields + .map((viewField) => viewField.field) + .filter((field): field is NumberFieldAttributes => field.type === 'number'), + [view.fields] + ); + + const aggregates = useMemo( + () => [ + { fn: 'count' as const, fieldId: '*', alias: 'row_count' }, + ...numberFields.flatMap((field) => [ + { fn: 'sum' as const, fieldId: field.id, alias: `sum_${field.id}` }, + { fn: 'avg' as const, fieldId: field.id, alias: `avg_${field.id}` }, + ]), + ], + [numberFields] + ); + + const aggregateQuery = useLiveQuery( + { + type: 'database.query', + userId: workspace.userId, + databaseId: database.id, + where: view.filters, + aggregate: aggregates, + }, + { enabled: aggregates.length > 0 } + ); + + const metrics = + aggregateQuery.data?.mode === 'aggregated' + ? (aggregateQuery.data.rows[0]?.metrics ?? {}) + : {}; + + const rowCount = metrics.row_count ?? 0; + + return ( +
+ {selection.enabled && ( +
+ )} +
+ Σ +
+ {isMetaType && ( +
+ )} +
+ {formatMetric(rowCount)} records +
+ {view.fields.map((viewField) => { + if (viewField.field.type === 'number') { + const sum = metrics[`sum_${viewField.field.id}`]; + const avg = metrics[`avg_${viewField.field.id}`]; + + return ( +
+ {sum !== undefined && ( + + Sum{' '} + {formatNumberFieldValue(sum, viewField.field as NumberFieldAttributes)} + + )} + {avg !== undefined && ( + + Avg{' '} + {formatNumberFieldValue(avg, viewField.field as NumberFieldAttributes)} + + )} +
+ ); + } + + return ( +
+ ); + })} +
+
+ ); +}; diff --git a/packages/ui/src/components/databases/tables/table-view-group.tsx b/packages/ui/src/components/databases/tables/table-view-group.tsx index cb6541603..7d0474759 100644 --- a/packages/ui/src/components/databases/tables/table-view-group.tsx +++ b/packages/ui/src/components/databases/tables/table-view-group.tsx @@ -1,5 +1,5 @@ import { ChevronDown, ChevronRight, Plus } from 'lucide-react'; -import { ReactNode, useMemo, useState } from 'react'; +import { ReactNode, useEffect, useMemo, useState } from 'react'; import { InView } from 'react-intersection-observer'; import { DatabaseViewFilterAttributes } from '@colanode/core'; @@ -7,27 +7,48 @@ import { TableViewRow } from '@colanode/ui/components/databases/tables/table-vie import { MetaTypePageCreateFromFilter } from '@colanode/ui/components/pages/meta-type-page-create-from-filter'; import { useDatabase } from '@colanode/ui/contexts/database'; import { useDatabaseView } from '@colanode/ui/contexts/database-view'; +import { useTableViewSelection } from '@colanode/ui/contexts/table-view-selection'; import { useCollectionQuery } from '@colanode/ui/hooks/use-collection-query'; +import { + buildVisibleRowIdsKey, + parseVisibleRowIdsKey, +} from '@colanode/ui/lib/table-view-selection'; interface TableViewGroupProps { header: ReactNode; filter: DatabaseViewFilterAttributes; + baseFilters?: DatabaseViewFilterAttributes[]; + groupKey: string; } -export const TableViewGroup = ({ header, filter }: TableViewGroupProps) => { +export const TableViewGroup = ({ + header, + filter, + baseFilters = [], + groupKey, +}: TableViewGroupProps) => { const database = useDatabase(); const view = useDatabaseView(); + const selection = useTableViewSelection(); const [open, setOpen] = useState(true); const filters = useMemo( - () => [...view.filters, filter], - [view.filters, filter] + () => [...view.filters, ...baseFilters, filter], + [baseFilters, filter, view.filters] ); const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useCollectionQuery(filters, view.sorts); const rows = data; + const visibleRowIdsKey = useMemo( + () => buildVisibleRowIdsKey(rows.map((row) => row.id)), + [rows] + ); + + useEffect(() => { + selection.setVisibleIds(groupKey, parseVisibleRowIdsKey(visibleRowIdsKey)); + }, [groupKey, selection.setVisibleIds, visibleRowIdsKey]); return (
@@ -55,7 +76,7 @@ export const TableViewGroup = ({ header, filter }: TableViewGroupProps) => { className="animate-fade-in flex h-8 w-full cursor-pointer flex-row items-center gap-1 border-b pl-2 text-muted-foreground hover:bg-accent" onClick={(event) => { event.currentTarget.blur(); - view.createRecord([filter]); + view.createRecord(filters); }} > diff --git a/packages/ui/src/components/databases/tables/table-view-grouped-by-field.tsx b/packages/ui/src/components/databases/tables/table-view-grouped-by-field.tsx new file mode 100644 index 000000000..c4e8871bf --- /dev/null +++ b/packages/ui/src/components/databases/tables/table-view-grouped-by-field.tsx @@ -0,0 +1,22 @@ +import { FieldAttributes } from '@colanode/core'; +import { TableViewGroupedCollaborator } from '@colanode/ui/components/databases/tables/table-view-grouped-collaborator'; +import { TableViewGroupedCreatedBy } from '@colanode/ui/components/databases/tables/table-view-grouped-created-by'; +import { TableViewGroupedSelect } from '@colanode/ui/components/databases/tables/table-view-grouped-select'; + +interface TableViewGroupedByFieldProps { + field: FieldAttributes; +} + +export const TableViewGroupedByField = ({ field }: TableViewGroupedByFieldProps) => { + switch (field.type) { + case 'select': + case 'multi_select': + return ; + case 'collaborator': + return ; + case 'created_by': + return ; + default: + return null; + } +}; diff --git a/packages/ui/src/components/databases/tables/table-view-grouped-collaborator.tsx b/packages/ui/src/components/databases/tables/table-view-grouped-collaborator.tsx new file mode 100644 index 000000000..1d1d4cc67 --- /dev/null +++ b/packages/ui/src/components/databases/tables/table-view-grouped-collaborator.tsx @@ -0,0 +1,135 @@ +import { eq, useLiveQuery } from '@tanstack/react-db'; +import { CircleAlert, CircleDashed } from 'lucide-react'; + +import { + CollaboratorFieldAttributes, + DatabaseViewFilterAttributes, +} from '@colanode/core'; +import { Avatar } from '@colanode/ui/components/avatars/avatar'; +import { TableViewGroup } from '@colanode/ui/components/databases/tables/table-view-group'; +import { useWorkspace } from '@colanode/ui/contexts/workspace'; +import { useFieldValueCounts } from '@colanode/ui/hooks/use-field-value-counts'; + +interface TableViewGroupedCollaboratorProps { + field: CollaboratorFieldAttributes; +} + +const GroupCountBadge = ({ count }: { count: number }) => ( + + {count.toLocaleString()} + +); + +const CollaboratorGroupHeader = ({ + collaboratorId, + count, + fieldName, +}: { + collaboratorId: string | null; + count: number; + fieldName: string; +}) => { + const workspace = useWorkspace(); + const userQuery = useLiveQuery( + (q) => + q + .from({ users: workspace.collections.users }) + .where(({ users }) => eq(users.id, collaboratorId ?? '')) + .select(({ users }) => ({ + id: users.id, + name: users.name, + avatar: users.avatar, + })), + [workspace.userId, collaboratorId] + ); + + if (!collaboratorId) { + return ( +
+ + + No {fieldName} + + +
+ ); + } + + const user = userQuery.data[0]; + if (!user) { + return ( +
+ + + Unknown + + +
+ ); + } + + return ( +
+ + {user.name} + +
+ ); +}; + +export const TableViewGroupedCollaborator = ({ + field, +}: TableViewGroupedCollaboratorProps) => { + const { values, noValueCount } = useFieldValueCounts(field); + + return ( +
+ {values.map((collaborator) => { + const filter: DatabaseViewFilterAttributes = { + id: `group-${collaborator.value}`, + type: 'field', + fieldId: field.id, + operator: 'is_in', + value: [collaborator.value], + }; + + return ( + + } + /> + ); + })} + + } + /> +
+ ); +}; diff --git a/packages/ui/src/components/databases/tables/table-view-grouped-created-by.tsx b/packages/ui/src/components/databases/tables/table-view-grouped-created-by.tsx new file mode 100644 index 000000000..02a3d2348 --- /dev/null +++ b/packages/ui/src/components/databases/tables/table-view-grouped-created-by.tsx @@ -0,0 +1,100 @@ +import { eq, useLiveQuery } from '@tanstack/react-db'; +import { CircleAlert } from 'lucide-react'; + +import { + CreatedByFieldAttributes, + DatabaseViewFilterAttributes, +} from '@colanode/core'; +import { Avatar } from '@colanode/ui/components/avatars/avatar'; +import { TableViewGroup } from '@colanode/ui/components/databases/tables/table-view-group'; +import { useWorkspace } from '@colanode/ui/contexts/workspace'; +import { useFieldValueCounts } from '@colanode/ui/hooks/use-field-value-counts'; + +interface TableViewGroupedCreatedByProps { + field: CreatedByFieldAttributes; +} + +const GroupCountBadge = ({ count }: { count: number }) => ( + + {count.toLocaleString()} + +); + +const CreatedByGroupHeader = ({ + userId, + count, +}: { + userId: string; + count: number; +}) => { + const workspace = useWorkspace(); + const userQuery = useLiveQuery( + (q) => + q + .from({ users: workspace.collections.users }) + .where(({ users }) => eq(users.id, userId)) + .select(({ users }) => ({ + id: users.id, + name: users.name, + avatar: users.avatar, + })), + [workspace.userId, userId] + ); + + const user = userQuery.data[0]; + if (!user) { + return ( +
+ + + Unknown + + +
+ ); + } + + return ( +
+ + {user.name} + +
+ ); +}; + +export const TableViewGroupedCreatedBy = ({ + field, +}: TableViewGroupedCreatedByProps) => { + const { values } = useFieldValueCounts(field); + + return ( +
+ {values.map((user) => { + const filter: DatabaseViewFilterAttributes = { + id: `group-${user.value}`, + type: 'field', + fieldId: field.id, + operator: 'is_in', + value: [user.value], + }; + + return ( + + } + /> + ); + })} +
+ ); +}; diff --git a/packages/ui/src/components/databases/tables/table-view-grouped-select.tsx b/packages/ui/src/components/databases/tables/table-view-grouped-select.tsx new file mode 100644 index 000000000..d94601fa1 --- /dev/null +++ b/packages/ui/src/components/databases/tables/table-view-grouped-select.tsx @@ -0,0 +1,78 @@ +import { CircleDashed } from 'lucide-react'; + +import { + DatabaseViewFilterAttributes, + MultiSelectFieldAttributes, + SelectFieldAttributes, +} from '@colanode/core'; +import { TableViewGroup } from '@colanode/ui/components/databases/tables/table-view-group'; +import { useFieldValueCounts } from '@colanode/ui/hooks/use-field-value-counts'; +import { getSelectOptionStrongStyle } from '@colanode/ui/lib/databases'; + +interface TableViewGroupedSelectProps { + field: SelectFieldAttributes | MultiSelectFieldAttributes; +} + +const GroupCountBadge = ({ count }: { count: number }) => ( + + {count.toLocaleString()} + +); + +export const TableViewGroupedSelect = ({ field }: TableViewGroupedSelectProps) => { + const { values, noValueCount } = useFieldValueCounts(field); + const options = Object.values(field.options ?? {}); + + return ( +
+ {options.map((option) => { + const filter: DatabaseViewFilterAttributes = { + id: `group-${option.id}`, + type: 'field', + fieldId: field.id, + operator: 'is_in', + value: [option.id], + }; + const count = values.find((v) => v.value === option.id)?.count ?? 0; + + return ( + + + {option.name} + + +
+ } + /> + ); + })} + + + + No {field.name} + + +
+ } + /> +
+ ); +}; diff --git a/packages/ui/src/components/databases/tables/table-view-grouped.tsx b/packages/ui/src/components/databases/tables/table-view-grouped.tsx index dc69101e6..66ca51a15 100644 --- a/packages/ui/src/components/databases/tables/table-view-grouped.tsx +++ b/packages/ui/src/components/databases/tables/table-view-grouped.tsx @@ -1,77 +1,45 @@ -import { CircleDashed } from 'lucide-react'; - +import { FieldAttributes } from '@colanode/core'; +import { TableViewGroupedByField } from '@colanode/ui/components/databases/tables/table-view-grouped-by-field'; +import { TableViewNestedGrouped } from '@colanode/ui/components/databases/tables/table-view-nested-grouped'; import { - DatabaseViewFilterAttributes, - MultiSelectFieldAttributes, - SelectFieldAttributes, -} from '@colanode/core'; -import { TableViewGroup } from '@colanode/ui/components/databases/tables/table-view-group'; -import { useFieldValueCounts } from '@colanode/ui/hooks/use-field-value-counts'; -import { getSelectOptionStrongStyle } from '@colanode/ui/lib/databases'; + isTableGroupableField, + isTableNestedGroupableField, +} from '@colanode/ui/lib/view-grouping'; interface TableViewGroupedProps { - field: SelectFieldAttributes | MultiSelectFieldAttributes; + groupLevels: string[]; + fields: FieldAttributes[]; } -const GroupCountBadge = ({ count }: { count: number }) => ( - - {count.toLocaleString()} - -); +export const TableViewGrouped = ({ + groupLevels, + fields, +}: TableViewGroupedProps) => { + const resolvedFields = groupLevels + .map((fieldId) => fields.find((field) => field.id === fieldId)) + .filter((field): field is FieldAttributes => !!field); -export const TableViewGrouped = ({ field }: TableViewGroupedProps) => { - const { values, noValueCount } = useFieldValueCounts(field); - const options = Object.values(field.options ?? {}); + if (resolvedFields.length === 0) { + return null; + } + if ( + resolvedFields.length >= 2 && + isTableNestedGroupableField(resolvedFields[0]!) && + isTableNestedGroupableField(resolvedFields[1]!) + ) { return ( -
- {options.map((option) => { - const filter: DatabaseViewFilterAttributes = { - id: 'group', - type: 'field', - fieldId: field.id, - operator: 'is_in', - value: [option.id], - }; - const count = - values.find((v) => v.value === option.id)?.count ?? 0; - - return ( - - - {option.name} - - -
- } - /> - ); - })} - - - - No {field.name} - - -
- } - /> -
+ ); + } + + const primaryField = resolvedFields[0]; + if (!primaryField || !isTableGroupableField(primaryField)) { + return null; + } + + return ; }; diff --git a/packages/ui/src/components/databases/tables/table-view-header.tsx b/packages/ui/src/components/databases/tables/table-view-header.tsx index acb01aef6..2227055ae 100644 --- a/packages/ui/src/components/databases/tables/table-view-header.tsx +++ b/packages/ui/src/components/databases/tables/table-view-header.tsx @@ -5,12 +5,18 @@ import { FieldCreatePopover } from '@colanode/ui/components/databases/fields/fie import { TableViewFieldHeader } from '@colanode/ui/components/databases/tables/table-view-field-header'; import { TableViewNameHeader } from '@colanode/ui/components/databases/tables/table-view-name-header'; import { TableViewSystemFieldHeader } from '@colanode/ui/components/databases/tables/table-view-system-field-header'; +import { Checkbox } from '@colanode/ui/components/ui/checkbox'; import { useDatabase } from '@colanode/ui/contexts/database'; import { useDatabaseView } from '@colanode/ui/contexts/database-view'; +import { + TABLE_VIEW_CHECKBOX_WIDTH, + useTableViewSelection, +} from '@colanode/ui/contexts/table-view-selection'; export const TableViewHeader = () => { const database = useDatabase(); const view = useDatabaseView(); + const selection = useTableViewSelection(); const isMetaType = (database.purpose ?? 'records') === 'meta_type'; const metaTypeIdLabel = database.isFileMetaType ? 'File ID' @@ -20,6 +26,21 @@ export const TableViewHeader = () => { return (
+ {selection.enabled && ( +
+ +
+ )}
{isMetaType && metaTypeIdLabel && (
( + + {count.toLocaleString()} + +); + +const SelectGroupSections = ({ + field, + baseFilter, + groupKeyPrefix, +}: { + field: SelectFieldAttributes | MultiSelectFieldAttributes; + baseFilter?: DatabaseViewFilterAttributes; + groupKeyPrefix: string; +}) => { + const { values, noValueCount } = useFieldValueCounts(field); + const options = Object.values(field.options ?? {}); + + return ( + <> + {options.map((option) => { + const filter: DatabaseViewFilterAttributes = { + id: `${groupKeyPrefix}-${option.id}`, + type: 'field', + fieldId: field.id, + operator: 'is_in', + value: [option.id], + }; + const count = values.find((v) => v.value === option.id)?.count ?? 0; + + return ( + + + {option.name} + + +
+ } + /> + ); + })} + + + + No {field.name} + + +
+ } + /> + + ); +}; + +export const TableViewNestedGrouped = ({ + primaryField, + secondaryField, +}: TableViewNestedGroupedProps) => { + const { values: primaryValues, noValueCount: primaryNoValueCount } = + useFieldValueCounts(primaryField); + const primaryOptions = useMemo( + () => Object.values(primaryField.options ?? {}), + [primaryField.options] + ); + + return ( +
+ {primaryOptions.map((option) => { + const primaryFilter: DatabaseViewFilterAttributes = { + id: `primary-${option.id}`, + type: 'field', + fieldId: primaryField.id, + operator: 'is_in', + value: [option.id], + }; + const count = + primaryValues.find((value) => value.value === option.id)?.count ?? 0; + + return ( +
+
+ + {option.name} + + +
+
+ +
+
+ ); + })} +
+
+ + + No {primaryField.name} + + +
+
+ +
+
+
+ ); +}; diff --git a/packages/ui/src/components/databases/tables/table-view-row.tsx b/packages/ui/src/components/databases/tables/table-view-row.tsx index 6b496ae77..e828f8260 100644 --- a/packages/ui/src/components/databases/tables/table-view-row.tsx +++ b/packages/ui/src/components/databases/tables/table-view-row.tsx @@ -6,8 +6,13 @@ import { FileFieldsProvider } from '@colanode/ui/components/files/file-fields-pr import { PageFieldsProvider } from '@colanode/ui/components/pages/page-fields-provider'; import { RecordFieldValue } from '@colanode/ui/components/records/record-field-value'; import { RecordProvider } from '@colanode/ui/components/records/record-provider'; +import { Checkbox } from '@colanode/ui/components/ui/checkbox'; import { useDatabase } from '@colanode/ui/contexts/database'; import { useDatabaseView } from '@colanode/ui/contexts/database-view'; +import { + TABLE_VIEW_CHECKBOX_WIDTH, + useTableViewSelection, +} from '@colanode/ui/contexts/table-view-selection'; import { useTodoListSplit } from '@colanode/ui/contexts/todo-list-split'; import { useWorkspace } from '@colanode/ui/contexts/workspace'; import { CollectionRow } from '@colanode/ui/lib/collection-row'; @@ -27,11 +32,13 @@ export const TableViewRow = ({ index, row }: TableViewRowProps) => { const workspace = useWorkspace(); const database = useDatabase(); const view = useDatabaseView(); + const selection = useTableViewSelection(); const location = useLocation(); const split = useTodoListSplit(); const isTodoListSettings = isTodoListSettingsPath(location.pathname); const isSelected = row.nodeType === 'record' && split?.selectedRecordId === row.id; + const isRowChecked = selection.isSelected(row.id); const isMetaType = database.isPageMetaType || database.isFileMetaType; const role = extractNodeRole(row.source, workspace.userId) ?? database.role; @@ -57,7 +64,8 @@ export const TableViewRow = ({ index, row }: TableViewRowProps) => { className={cn( 'animate-fade-in flex flex-row items-center gap-0.5 border-b', isTodoListSettings && row.nodeType === 'record' && 'cursor-pointer hover:bg-accent/60', - isSelected && 'bg-accent ring-1 ring-inset ring-primary/35' + isSelected && 'bg-accent ring-1 ring-inset ring-primary/35', + isRowChecked && 'bg-primary/5' )} onClick={(event) => { if ( @@ -72,6 +80,22 @@ export const TableViewRow = ({ index, row }: TableViewRowProps) => { split.selectRecord(row.id); }} > + {selection.enabled && ( +
event.stopPropagation()} + > + selection.toggle(row.id)} + aria-label={`Select row ${index + 1}`} + /> +
+ )} { + const database = useDatabase(); + const enabled = database.canEdit && !database.isLocked; + + const [selectedIds, setSelectedIds] = useState>(() => new Set()); + const [visibleGroups, setVisibleGroups] = useState>( + {} + ); + + const visibleIds = useMemo(() => { + const ids = new Set(); + for (const groupIds of Object.values(visibleGroups)) { + for (const id of groupIds) { + ids.add(id); + } + } + + return ids; + }, [visibleGroups]); + + const toggle = useCallback((id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const selectAllVisible = useCallback(() => { + setSelectedIds((prev) => { + const allVisibleSelected = + visibleIds.size > 0 && + [...visibleIds].every((id) => prev.has(id)); + + if (allVisibleSelected) { + return new Set(); + } + + return new Set(visibleIds); + }); + }, [visibleIds]); + + const clearSelection = useCallback(() => { + setSelectedIds(new Set()); + }, []); + + const setVisibleIds = useCallback((groupKey: string, ids: string[]) => { + setVisibleGroups((prev) => { + if (areVisibleIdListsEqual(prev[groupKey], ids)) { + return prev; + } + + return { + ...prev, + [groupKey]: ids, + }; + }); + }, []); + + const headerCheckboxState = useMemo((): boolean | 'indeterminate' => { + if (visibleIds.size === 0) { + return false; + } + + let selectedVisibleCount = 0; + for (const id of visibleIds) { + if (selectedIds.has(id)) { + selectedVisibleCount += 1; + } + } + + if (selectedVisibleCount === 0) { + return false; + } + + if (selectedVisibleCount === visibleIds.size) { + return true; + } + + return 'indeterminate'; + }, [selectedIds, visibleIds]); + + const value = useMemo( + () => ({ + enabled, + selectedIds, + selectedCount: selectedIds.size, + headerCheckboxState, + isSelected: (id: string) => selectedIds.has(id), + toggle, + selectAllVisible, + clearSelection, + setVisibleIds, + }), + [ + clearSelection, + enabled, + headerCheckboxState, + selectAllVisible, + selectedIds, + setVisibleIds, + toggle, + ] + ); + + return ( + + {children} + + ); +}; diff --git a/packages/ui/src/components/databases/tables/table-view.tsx b/packages/ui/src/components/databases/tables/table-view.tsx index a0f0fad7b..516663157 100644 --- a/packages/ui/src/components/databases/tables/table-view.tsx +++ b/packages/ui/src/components/databases/tables/table-view.tsx @@ -1,62 +1,62 @@ import { Fragment } from 'react'; -import { - MultiSelectFieldAttributes, - SelectFieldAttributes, -} from '@colanode/core'; import { ViewFilterButton } from '@colanode/ui/components/databases/search/view-filter-button'; import { ViewSearchBar } from '@colanode/ui/components/databases/search/view-search-bar'; import { ViewSortButton } from '@colanode/ui/components/databases/search/view-sort-button'; import { TableViewBody } from '@colanode/ui/components/databases/tables/table-view-body'; +import { TableViewBulkActionBar } from '@colanode/ui/components/databases/tables/table-view-bulk-action-bar'; +import { TableViewFooter } from '@colanode/ui/components/databases/tables/table-view-footer'; import { TableViewGrouped } from '@colanode/ui/components/databases/tables/table-view-grouped'; import { TableViewHeader } from '@colanode/ui/components/databases/tables/table-view-header'; import { TableViewRecordCreateRow } from '@colanode/ui/components/databases/tables/table-view-record-create-row'; +import { TableViewSelectionProvider } from '@colanode/ui/components/databases/tables/table-view-selection-provider'; import { TableViewSettings } from '@colanode/ui/components/databases/tables/table-view-settings'; import { ViewFullscreenButton } from '@colanode/ui/components/databases/view-fullscreen-button'; import { ViewTabs } from '@colanode/ui/components/databases/view-tabs'; import { useDatabase } from '@colanode/ui/contexts/database'; import { useDatabaseView } from '@colanode/ui/contexts/database-view'; +import { resolveViewGroupByLevels } from '@colanode/ui/lib/view-grouping'; export const TableView = () => { const database = useDatabase(); const view = useDatabaseView(); - const groupByField = database.fields.find( - (field) => field.id === view.groupBy - ); - const isGrouped = - groupByField?.type === 'select' || - groupByField?.type === 'multi_select'; + const groupLevels = resolveViewGroupByLevels(view); + const isGrouped = groupLevels.length > 0; return ( - -
- -
- - - - + + +
+ +
+ + + + +
+
+ +
+ + {isGrouped ? ( + + + + + ) : ( + + + + + + )}
-
- -
- - {isGrouped ? ( - - ) : ( - - - - - )} -
- + + + ); }; diff --git a/packages/ui/src/components/databases/view-create-dialog.tsx b/packages/ui/src/components/databases/view-create-dialog.tsx index cdebb00c5..c82b60251 100644 --- a/packages/ui/src/components/databases/view-create-dialog.tsx +++ b/packages/ui/src/components/databases/view-create-dialog.tsx @@ -1,6 +1,6 @@ import { useForm } from '@tanstack/react-form'; import { useMutation } from '@tanstack/react-query'; -import { Calendar, Columns, LayoutGrid, List, Table } from 'lucide-react'; +import { Calendar, Columns, LayoutGrid, List, Table, ClipboardList } from 'lucide-react'; import { FC } from 'react'; import { toast } from 'sonner'; import { z } from 'zod/v4'; @@ -35,7 +35,7 @@ import { cn } from '@colanode/ui/lib/utils'; const formSchema = z.object({ name: z.string(), - type: z.enum(['table', 'board', 'calendar', 'gallery', 'list']), + type: z.enum(['table', 'board', 'calendar', 'gallery', 'list', 'form']), }); type ViewCreateFormValues = z.infer; @@ -48,7 +48,7 @@ const defaultValues: ViewCreateFormValues = { interface ViewTypeOption { name: string; icon: FC; - type: 'table' | 'board' | 'calendar' | 'gallery' | 'list'; + type: 'table' | 'board' | 'calendar' | 'gallery' | 'list' | 'form'; } const viewTypes: ViewTypeOption[] = [ @@ -77,6 +77,11 @@ const viewTypes: ViewTypeOption[] = [ icon: List, type: 'list', }, + { + name: 'Form', + icon: ClipboardList, + type: 'form', + }, ]; interface ViewCreateDialogProps { diff --git a/packages/ui/src/components/databases/view-group-settings.tsx b/packages/ui/src/components/databases/view-group-settings.tsx index 034a4b0b2..b7d381af8 100644 --- a/packages/ui/src/components/databases/view-group-settings.tsx +++ b/packages/ui/src/components/databases/view-group-settings.tsx @@ -4,54 +4,96 @@ import { FieldSelect } from '@colanode/ui/components/databases/fields/field-sele import { useDatabase } from '@colanode/ui/contexts/database'; import { useDatabaseView } from '@colanode/ui/contexts/database-view'; import { useWorkspace } from '@colanode/ui/contexts/workspace'; +import { + isBoardGroupableField, + isTableNestedGroupableField, + resolveViewGroupByLevels, + syncViewGroupByFields, +} from '@colanode/ui/lib/view-grouping'; export const ViewGroupSettings = () => { - const workspace = useWorkspace(); - const database = useDatabase(); - const view = useDatabaseView(); + const workspace = useWorkspace(); + const database = useDatabase(); + const view = useDatabaseView(); - const groupableFields = database.fields.filter( - (field) => field.type === 'select' || field.type === 'multi_select' + const groupableFields = database.fields.filter(isBoardGroupableField); + const nestedGroupableFields = database.fields.filter(isTableNestedGroupableField); + const groupLevels = resolveViewGroupByLevels(view); + const primaryGroupId = groupLevels[0] ?? null; + const secondaryGroupId = groupLevels[1] ?? null; + + const updateGroupLevels = (primary: string | null, secondary: string | null) => { + const levels = [primary, secondary].filter( + (level): level is string => !!level && level.length > 0 ); - const updateGroupBy = (fieldId: string | null) => { - workspace.collections.nodes.update(view.id, (draft) => { - if (draft.type !== 'database_view') { - return; - } - - draft.groupBy = fieldId; - }); - }; - - return ( -
-

Group by

- {groupableFields.length > 0 ? ( -
-
- updateGroupBy(fieldId)} - /> -
- {view.groupBy && ( - - )} -
- ) : ( -

- Create a select or multi-select field to group records. -

+ workspace.collections.nodes.update(view.id, (draft) => { + if (draft.type !== 'database_view') { + return; + } + + syncViewGroupByFields(draft, levels); + }); + }; + + return ( +
+

Group by

+ {groupableFields.length > 0 ? ( +
+
+
+ + updateGroupLevels( + fieldId, + fieldId === secondaryGroupId ? null : secondaryGroupId + ) + } + /> +
+ {primaryGroupId && ( + )} +
+ + {primaryGroupId && nestedGroupableFields.length > 1 && ( +
+

Then by

+ field.id !== primaryGroupId + )} + value={secondaryGroupId} + onChange={(fieldId) => updateGroupLevels(primaryGroupId, fieldId)} + /> + {secondaryGroupId && ( + + )} +
+ )}
- ); + ) : ( +

+ Create a select, multi-select, collaborator, or created-by field to + group records. +

+ )} +
+ ); }; diff --git a/packages/ui/src/components/databases/view-icon.tsx b/packages/ui/src/components/databases/view-icon.tsx index 7e2ff08f4..998cc0c97 100644 --- a/packages/ui/src/components/databases/view-icon.tsx +++ b/packages/ui/src/components/databases/view-icon.tsx @@ -1,6 +1,7 @@ import { Calendar, Database, + ClipboardList, LayoutGrid, List, SquareKanban, @@ -49,5 +50,9 @@ export const ViewIcon = ({ return ; } + if (layout === 'form') { + return ; + } + return ; }; diff --git a/packages/ui/src/components/databases/view.tsx b/packages/ui/src/components/databases/view.tsx index 809716e21..b42a825f5 100644 --- a/packages/ui/src/components/databases/view.tsx +++ b/packages/ui/src/components/databases/view.tsx @@ -15,6 +15,7 @@ import { } from '@colanode/core'; import { BoardView } from '@colanode/ui/components/databases/boards/board-view'; import { CalendarView } from '@colanode/ui/components/databases/calendars/calendar-view'; +import { FormView } from '@colanode/ui/components/databases/forms/form-view'; import { GalleryView } from '@colanode/ui/components/databases/gallery/gallery-view'; import { ListView } from '@colanode/ui/components/databases/list/list-view'; import { TableView } from '@colanode/ui/components/databases/tables/table-view'; @@ -63,6 +64,7 @@ export const View = ({ view }: ViewProps) => { filters: Object.values(view.filters ?? {}), sorts: Object.values(view.sorts ?? {}), groupBy: view.groupBy, + groupByLevels: view.groupByLevels ?? null, nameWidth: view.nameWidth ?? getDefaultNameWidth(), isSearchBarOpened: isSearchBarOpened || openedFieldFilters.length > 0, isSortsOpened, @@ -195,6 +197,7 @@ export const View = ({ view }: ViewProps) => { .with('calendar', () => ) .with('gallery', () => ) .with('list', () => ) + .with('form', () => ) .exhaustive()}
diff --git a/packages/ui/src/components/nodes/node-reminder-helpers.ts b/packages/ui/src/components/nodes/node-reminder-helpers.ts index 17cc828e2..f9ac39af3 100644 --- a/packages/ui/src/components/nodes/node-reminder-helpers.ts +++ b/packages/ui/src/components/nodes/node-reminder-helpers.ts @@ -46,6 +46,7 @@ const UNSUPPORTED_CONDITION_FIELD_TYPES = new Set([ 'multi_select', 'relation', 'resource', + 'lookup', 'rollup', 'updated_by', ]); diff --git a/packages/ui/src/components/pages/meta-type-page-create-dialog.tsx b/packages/ui/src/components/pages/meta-type-page-create-dialog.tsx index 5b1bf89e0..13b22f937 100644 --- a/packages/ui/src/components/pages/meta-type-page-create-dialog.tsx +++ b/packages/ui/src/components/pages/meta-type-page-create-dialog.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import { toast } from 'sonner'; import { LocalDatabaseNode, LocalPageNode } from '@colanode/client/types'; +import { allocateAutonumbers } from '@colanode/client/lib'; import { FieldValue, generateId, IdType } from '@colanode/core'; import { PageForm, @@ -59,6 +60,19 @@ export const MetaTypePageCreateDialog = ({ ? (metaTypeNode as LocalDatabaseNode) : null; + let autonumberValues: Record = {}; + if (nodes.has(metaTypeId)) { + nodes.update(metaTypeId, (draft) => { + if (draft.type !== 'database') { + return; + } + + const allocation = allocateAutonumbers(draft.fields); + autonumberValues = allocation.values; + draft.fields = allocation.fields; + }); + } + const page: LocalPageNode = { id: pageId, type: 'page', @@ -66,9 +80,12 @@ export const MetaTypePageCreateDialog = ({ avatar: values.avatar, parentId: spaceId, metaTypeId, - fields: resolveInitialPageFields(metaType, { - initialFields, - }), + fields: { + ...resolveInitialPageFields(metaType, { + initialFields, + }), + ...autonumberValues, + }, rootId: spaceId, createdAt: new Date().toISOString(), createdBy: workspace.userId, diff --git a/packages/ui/src/components/records/record-field-value.tsx b/packages/ui/src/components/records/record-field-value.tsx index 3516ec3d7..2f688fa8e 100644 --- a/packages/ui/src/components/records/record-field-value.tsx +++ b/packages/ui/src/components/records/record-field-value.tsx @@ -1,5 +1,6 @@ import { FieldAttributes, isRemindersRelationFieldId } from '@colanode/core'; import { RemindersRelationValue } from '@colanode/ui/components/nodes/reminders-relation-value'; +import { RecordAutonumberValue } from '@colanode/ui/components/records/values/record-autonumber-value'; import { RecordBooleanValue } from '@colanode/ui/components/records/values/record-boolean-value'; import { RecordButtonValue } from '@colanode/ui/components/records/values/record-button-value'; import { RecordCollaboratorValue } from '@colanode/ui/components/records/values/record-collaborator-value'; @@ -9,6 +10,7 @@ import { RecordDateValue } from '@colanode/ui/components/records/values/record-d import { RecordEmailValue } from '@colanode/ui/components/records/values/record-email-value'; import { RecordFileValue } from '@colanode/ui/components/records/values/record-file-value'; import { RecordFormulaValue } from '@colanode/ui/components/records/values/record-formula-value'; +import { RecordLookupValue } from '@colanode/ui/components/records/values/record-lookup-value'; import { RecordMultiSelectValue } from '@colanode/ui/components/records/values/record-multi-select-value'; import { RecordNumberValue } from '@colanode/ui/components/records/values/record-number-value'; import { RecordPhoneValue } from '@colanode/ui/components/records/values/record-phone-value'; @@ -31,6 +33,8 @@ export const RecordFieldValue = ({ readOnly, }: RecordFieldValueProps) => { switch (field.type) { + case 'autonumber': + return ; case 'boolean': return ; case 'button': @@ -49,6 +53,8 @@ export const RecordFieldValue = ({ return ; case 'formula': return ; + case 'lookup': + return ; case 'multi_select': return ; case 'number': diff --git a/packages/ui/src/components/records/values/record-autonumber-value.tsx b/packages/ui/src/components/records/values/record-autonumber-value.tsx new file mode 100644 index 000000000..3e6213cf8 --- /dev/null +++ b/packages/ui/src/components/records/values/record-autonumber-value.tsx @@ -0,0 +1,21 @@ +import { AutonumberFieldAttributes } from '@colanode/core'; +import { useRecordField } from '@colanode/ui/hooks/use-record-field'; + +interface RecordAutonumberValueProps { + field: AutonumberFieldAttributes; +} + +export const RecordAutonumberValue = ({ field }: RecordAutonumberValueProps) => { + const { value } = useRecordField({ field }); + + const display = + value?.type === 'number' && Number.isFinite(value.value) + ? String(value.value) + : ''; + + return ( +
+ {display} +
+ ); +}; diff --git a/packages/ui/src/components/records/values/record-lookup-value.tsx b/packages/ui/src/components/records/values/record-lookup-value.tsx new file mode 100644 index 000000000..d23535534 --- /dev/null +++ b/packages/ui/src/components/records/values/record-lookup-value.tsx @@ -0,0 +1,129 @@ +import { eq, inArray, useLiveQuery } from '@tanstack/react-db'; +import { useMemo } from 'react'; + +import { + formatLookupFieldValue, + formatLookupValues, + getLookupValueFromNode, +} from '@colanode/client/lib'; +import { LocalFileNode, LocalPageNode, LocalRecordNode } from '@colanode/client/types'; +import { FieldAttributes, LookupFieldAttributes, RECORD_NAME_LOOKUP_FIELD_ID } from '@colanode/core'; +import { useDatabase } from '@colanode/ui/contexts/database'; +import { useRecord } from '@colanode/ui/contexts/record'; +import { useWorkspace } from '@colanode/ui/contexts/workspace'; + +interface RecordLookupValueProps { + field: LookupFieldAttributes; +} + +export const RecordLookupValue = ({ field }: RecordLookupValueProps) => { + const workspace = useWorkspace(); + const database = useDatabase(); + const record = useRecord(); + + const relationField = database.fields.find( + (item) => item.id === field.relationFieldId + ); + + const relationIds = useMemo(() => { + if (!field.relationFieldId) { + return [] as string[]; + } + + const value = record.fields[field.relationFieldId]; + if (value && value.type === 'string_array') { + return value.value; + } + + return [] as string[]; + }, [record.fields, field.relationFieldId]); + + const targetDatabaseId = + relationField?.type === 'relation' ? relationField.databaseId : null; + + const targetDatabaseQuery = useLiveQuery( + (q) => + q + .from({ nodes: workspace.collections.nodes }) + .where(({ nodes }) => eq(nodes.id, targetDatabaseId ?? '')), + [workspace.userId, targetDatabaseId] + ); + + const lookedUpField = useMemo((): FieldAttributes | undefined => { + if (field.lookedUpFieldId === RECORD_NAME_LOOKUP_FIELD_ID) { + return { + id: RECORD_NAME_LOOKUP_FIELD_ID, + type: 'text', + name: 'Name', + index: 'a0', + }; + } + + const node = targetDatabaseQuery.data[0]; + if (!node || node.type !== 'database' || !field.lookedUpFieldId) { + return undefined; + } + + return node.fields?.[field.lookedUpFieldId]; + }, [field.lookedUpFieldId, targetDatabaseQuery.data]); + + const relatedNodesQuery = useLiveQuery( + (q) => + q + .from({ nodes: workspace.collections.nodes }) + .where(({ nodes }) => + inArray(nodes.id, relationIds.length > 0 ? relationIds : ['']) + ), + [workspace.userId, relationIds] + ); + + const displayValue = useMemo(() => { + if (!field.relationFieldId || !field.lookedUpFieldId) { + return ''; + } + + if (!lookedUpField) { + return ''; + } + + const formattedValues: string[] = []; + + for (const node of relatedNodesQuery.data) { + if (node.type !== 'record' && node.type !== 'page' && node.type !== 'file') { + continue; + } + + const source = + node.type === 'record' + ? (node as LocalRecordNode) + : node.type === 'page' + ? (node as LocalPageNode) + : (node as LocalFileNode); + + const value = getLookupValueFromNode(source, lookedUpField); + const formatted = formatLookupFieldValue(value, lookedUpField); + if (formatted) { + formattedValues.push(formatted); + } + } + + return formatLookupValues(formattedValues); + }, [ + field.lookedUpFieldId, + field.relationFieldId, + lookedUpField, + relatedNodesQuery.data, + ]); + + if (!relationField) { + return ( +
+ ); + } + + return ( +
+ {displayValue} +
+ ); +}; diff --git a/packages/ui/src/components/records/values/record-number-value.tsx b/packages/ui/src/components/records/values/record-number-value.tsx index 023b248a0..9a09fd974 100644 --- a/packages/ui/src/components/records/values/record-number-value.tsx +++ b/packages/ui/src/components/records/values/record-number-value.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; +import { formatNumberFieldValue } from '@colanode/client/lib'; import { NumberFieldValue, type NumberFieldAttributes } from '@colanode/core'; import { Input } from '@colanode/ui/components/ui/input'; import { useRecord } from '@colanode/ui/contexts/record'; @@ -52,6 +53,21 @@ export const RecordNumberValue = ({ }); }; + const editable = record.canEdit && !readOnly; + + if (!editable) { + const displayValue = + value?.value !== undefined + ? formatNumberFieldValue(value.value, field) + : ''; + + return ( +
+ {displayValue} +
+ ); + } + return ( { - if (!Number.isFinite(value)) { - return ''; +const toRollupSourceNode = (node: { + id: string; + name: string; + createdAt: string; + createdBy: string; + updatedAt?: string | null; + updatedBy?: string | null; + type: string; + fields?: RollupSourceNode['fields']; +}): RollupSourceNode => ({ + id: node.id, + name: node.name, + createdAt: node.createdAt, + createdBy: node.createdBy, + updatedAt: node.updatedAt, + updatedBy: node.updatedBy, + fields: node.fields, +}); + +export const RecordRollupValue = ({ field }: RecordRollupValueProps) => { + const workspace = useWorkspace(); + const database = useDatabase(); + const record = useRecord(); + + const relationField = database.fields.find( + (item): item is RelationFieldAttributes => + item.id === field.relationFieldId && item.type === 'relation' + ); + + const relationIds = useMemo(() => { + if (!field.relationFieldId) { + return [] as string[]; } - return Number.isInteger(value) ? value.toString() : value.toFixed(2); -}; + const value = record.fields[field.relationFieldId]; + if (value && value.type === 'string_array') { + return value.value; + } -export const RecordRollupValue = ({ field }: RecordRollupValueProps) => { - const workspace = useWorkspace(); - const database = useDatabase(); - const record = useRecord(); + return [] as string[]; + }, [record.fields, field.relationFieldId]); + + const needsRecords = + field.aggregation !== 'count' && relationIds.length > 0; + + const recordsQuery = useLiveQuery( + (q) => + q + .from({ nodes: workspace.collections.nodes }) + .where(({ nodes }) => + inArray(nodes.id, needsRecords ? relationIds : ['']) + ), + [workspace.userId, needsRecords, relationIds] + ); + + const rolledUpField = useMemo(() => { + if (!field.rolledUpFieldId || !relationField?.databaseId) { + return undefined; + } - const relationField = database.fields.find( - (item) => item.id === field.relationFieldId + const targetDatabase = workspace.collections.nodes.get( + relationField.databaseId ); + if (!targetDatabase || targetDatabase.type !== 'database') { + return undefined; + } - const relationIds = useMemo(() => { - if (!field.relationFieldId) { - return [] as string[]; - } - - const value = record.fields[field.relationFieldId]; - if (value && value.type === 'string_array') { - return value.value; - } - - return [] as string[]; - }, [record.fields, field.relationFieldId]); - - const needsRecords = field.aggregation !== 'count' && relationIds.length > 0; - - const recordsQuery = useLiveQuery( - (q) => - q - .from({ nodes: workspace.collections.nodes }) - .where(({ nodes }) => - inArray(nodes.id, needsRecords ? relationIds : ['']) - ), - [workspace.userId, needsRecords, relationIds] + return targetDatabase.fields[field.rolledUpFieldId]; + }, [field.rolledUpFieldId, relationField?.databaseId, workspace.collections.nodes]); + + const result = useMemo(() => { + const relatedNodes = recordsQuery.data + .filter( + (node) => + node.type === 'record' || node.type === 'page' || node.type === 'file' + ) + .map(toRollupSourceNode); + + return computeRollupResult( + field, + relationIds, + relatedNodes, + rolledUpField ); + }, [field, recordsQuery.data, relationIds, rolledUpField]); - const result = useMemo(() => { - if (!field.relationFieldId || !field.aggregation) { - return null; - } - - if (field.aggregation === 'count') { - return relationIds.length; - } - - if (!field.rolledUpFieldId) { - return null; - } - - const numbers: number[] = []; - for (const node of recordsQuery.data) { - if (node.type !== 'record' && node.type !== 'page') { - continue; - } - - const fields = - node.type === 'record' - ? (node as LocalRecordNode).fields - : (node as LocalPageNode).fields; - const value = fields?.[field.rolledUpFieldId]; - if (value && value.type === 'number') { - numbers.push(value.value); - } - } - - if (numbers.length === 0) { - return 0; - } - - switch (field.aggregation) { - case 'sum': - return numbers.reduce((acc, n) => acc + n, 0); - case 'average': - return numbers.reduce((acc, n) => acc + n, 0) / numbers.length; - case 'min': - return Math.min(...numbers); - case 'max': - return Math.max(...numbers); - default: - return null; - } - }, [ - field.relationFieldId, - field.rolledUpFieldId, - field.aggregation, - relationIds, - recordsQuery.data, - ]); - - if (!relationField || result === null) { - return ( -
- ); - } - + if (!relationField || result === null) { return ( -
{formatNumber(result)}
+
); + } + + const displayValue = + result.kind === 'text' + ? result.value + : formatNumberFieldValue( + result.value, + rolledUpField?.type === 'number' + ? (rolledUpField as NumberFieldAttributes) + : undefined + ); + + return
{displayValue}
; }; diff --git a/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-actions-section.tsx b/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-actions-section.tsx new file mode 100644 index 000000000..0ff9f516e --- /dev/null +++ b/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-actions-section.tsx @@ -0,0 +1,179 @@ +import { Plus, Trash2 } from 'lucide-react'; + +import { ScheduledTaskAction, ScheduledTaskInput } from '@colanode/client/types'; +import { Button } from '@colanode/ui/components/ui/button'; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, + FieldLegend, +} from '@colanode/ui/components/ui/field'; +import { Input } from '@colanode/ui/components/ui/input'; +import { nativeSelectClassName } from '@colanode/ui/components/workspaces/scheduled-tasks/scheduled-task-helpers'; + +interface ScheduledTaskActionsSectionProps { + draft: ScheduledTaskInput; + onChange: (draft: ScheduledTaskInput) => void; +} + +const createSetFieldAction = (): ScheduledTaskAction => ({ + type: 'setField', + target: 'matched_records', + fieldId: '', + value: { kind: 'today' }, +}); + +export const ScheduledTaskActionsSection = ({ + draft, + onChange, +}: ScheduledTaskActionsSectionProps) => { + const actions = draft.actions ?? []; + + const updateActions = (nextActions: ScheduledTaskAction[]) => { + onChange({ ...draft, actions: nextActions }); + }; + + return ( + + Actions + + Run after the trigger matches. Use "Today" for date fields like + Completed date. + + +
+ {actions.map((action, index) => { + if (action.type !== 'setField') { + return null; + } + + return ( +
+
+

Set field

+ +
+ + + Target + + + + + Field ID + { + const next = [...actions]; + next[index] = { ...action, fieldId: event.target.value }; + updateActions(next); + }} + /> + + + + Value + + + + {!( + typeof action.value === 'object' && + action.value !== null && + 'kind' in action.value && + action.value.kind === 'today' + ) && ( + + Text value + { + const next = [...actions]; + next[index] = { + ...action, + value: { type: 'text', value: event.target.value }, + }; + updateActions(next); + }} + /> + + )} +
+ ); + })} +
+ + +
+ ); +}; diff --git a/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-form.tsx b/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-form.tsx index 48cee1cb3..53eff28d0 100644 --- a/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-form.tsx +++ b/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-form.tsx @@ -13,6 +13,7 @@ import { Input } from '@colanode/ui/components/ui/input'; import { Label } from '@colanode/ui/components/ui/label'; import { Separator } from '@colanode/ui/components/ui/separator'; import { Tabs, TabsList, TabsTrigger } from '@colanode/ui/components/ui/tabs'; +import { ScheduledTaskActionsSection } from '@colanode/ui/components/workspaces/scheduled-tasks/scheduled-task-actions-section'; import { ScheduledTaskTriggerSection } from '@colanode/ui/components/workspaces/scheduled-tasks/scheduled-task-trigger-section'; import { nativeSelectClassName } from '@colanode/ui/components/workspaces/scheduled-tasks/scheduled-task-helpers'; @@ -156,6 +157,10 @@ export const ScheduledTaskForm = ({ + + + + Notification diff --git a/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-helpers.ts b/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-helpers.ts index 054136846..a204c8369 100644 --- a/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-helpers.ts +++ b/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-helpers.ts @@ -46,6 +46,7 @@ const UNSUPPORTED_CONDITION_FIELD_TYPES = new Set([ 'multi_select', 'relation', 'resource', + 'lookup', 'rollup', 'button', 'formula', @@ -277,6 +278,7 @@ export const defaultTaskInput = ( notify: { silent: false, }, + actions: [], }); export const getCheckMetaTypeId = (check: ScheduledTaskCheck): string => { diff --git a/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-list.tsx b/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-list.tsx index a01986e70..984059a4c 100644 --- a/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-list.tsx +++ b/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-list.tsx @@ -89,6 +89,18 @@ export const ScheduledTaskList = ({

Last run: {formatLastRun(task.lastRunAt)}

+ {task.lastRunLog && ( +

+ Last actions:{' '} + {task.lastRunLog.actionResults.length === 0 + ? 'none' + : task.lastRunLog.actionResults + .map((result) => + result.success ? result.message : `Failed: ${result.message}` + ) + .join(' · ')} +

+ )}
diff --git a/packages/ui/src/components/workspaces/scheduled-tasks/workspace-scheduled-tasks-container.tsx b/packages/ui/src/components/workspaces/scheduled-tasks/workspace-scheduled-tasks-container.tsx index 42aa8d7dd..2aa7d5cd4 100644 --- a/packages/ui/src/components/workspaces/scheduled-tasks/workspace-scheduled-tasks-container.tsx +++ b/packages/ui/src/components/workspaces/scheduled-tasks/workspace-scheduled-tasks-container.tsx @@ -107,6 +107,7 @@ export const WorkspaceScheduledTasksContainer = () => { schedule: task.schedule, check: task.check, notify: task.notify, + actions: task.actions, scope: task.scope, context: task.context, }; @@ -180,6 +181,7 @@ export const WorkspaceScheduledTasksContainer = () => { schedule: task.schedule, check: task.check, notify: task.notify, + actions: task.actions, }, }, onError(error) { diff --git a/packages/ui/src/contexts/database-view.ts b/packages/ui/src/contexts/database-view.ts index 4a9649be8..91ddd71e1 100644 --- a/packages/ui/src/contexts/database-view.ts +++ b/packages/ui/src/contexts/database-view.ts @@ -17,6 +17,7 @@ interface DatabaseViewContext { filters: DatabaseViewFilterAttributes[]; sorts: DatabaseViewSortAttributes[]; groupBy: string | null | undefined; + groupByLevels: string[] | null | undefined; nameWidth: number; isSearchBarOpened: boolean; isSortsOpened: boolean; diff --git a/packages/ui/src/contexts/table-view-selection.ts b/packages/ui/src/contexts/table-view-selection.ts new file mode 100644 index 000000000..9d852d3c5 --- /dev/null +++ b/packages/ui/src/contexts/table-view-selection.ts @@ -0,0 +1,32 @@ +import { createContext, useContext } from 'react'; + +export const TABLE_VIEW_CHECKBOX_WIDTH = 28; + +export interface TableViewSelectionContext { + enabled: boolean; + selectedIds: ReadonlySet; + selectedCount: number; + headerCheckboxState: boolean | 'indeterminate'; + isSelected: (id: string) => boolean; + toggle: (id: string) => void; + selectAllVisible: () => void; + clearSelection: () => void; + setVisibleIds: (groupKey: string, ids: string[]) => void; +} + +const defaultContext: TableViewSelectionContext = { + enabled: false, + selectedIds: new Set(), + selectedCount: 0, + headerCheckboxState: false, + isSelected: () => false, + toggle: () => undefined, + selectAllVisible: () => undefined, + clearSelection: () => undefined, + setVisibleIds: () => undefined, +}; + +export const TableViewSelectionContext = + createContext(defaultContext); + +export const useTableViewSelection = () => useContext(TableViewSelectionContext); diff --git a/packages/ui/src/hooks/use-database-sql-collection-query.tsx b/packages/ui/src/hooks/use-database-sql-collection-query.tsx index b56ea3f80..7a1ed9e7e 100644 --- a/packages/ui/src/hooks/use-database-sql-collection-query.tsx +++ b/packages/ui/src/hooks/use-database-sql-collection-query.tsx @@ -23,6 +23,10 @@ import { useWorkspace } from '@colanode/ui/contexts/workspace'; import { useLiveQuery } from '@colanode/ui/hooks/use-live-query'; import { CollectionRow } from '@colanode/ui/lib/collection-row'; import { databaseQueryRowToCollectionRow } from '@colanode/ui/lib/database-query-collection'; +import { + applyFormulaPostFilters, + partitionFormulaViewFilters, +} from '@colanode/ui/lib/collection-view-query'; const ROWS_PER_PAGE = 100; @@ -46,16 +50,21 @@ export const useDatabaseSqlCollectionQuery = ( const queryClient = useQueryClient(); const pageSize = count ?? ROWS_PER_PAGE; + const { sqlFilters, formulaFilters } = useMemo( + () => partitionFormulaViewFilters(filters, database.fields), + [filters, database.fields] + ); + const baseInput = useMemo( (): Omit => ({ type: 'database.query', userId: workspace.userId, databaseId: database.id, - where: filters, + where: sqlFilters, orderBy: sorts, limit: pageSize, }), - [workspace.userId, database.id, filters, sorts, pageSize] + [workspace.userId, database.id, sqlFilters, sorts, pageSize] ); const collectionQueryKey = useMemo( @@ -132,13 +141,24 @@ export const useDatabaseSqlCollectionQuery = ( return map; }, [nodesQuery.data]); - const data: CollectionRow[] = useMemo( - () => - queryRows.map((row) => - databaseQueryRowToCollectionRow(row, nodesById.get(row.id)) - ), - [nodesById, queryRows] - ); + const data: CollectionRow[] = useMemo(() => { + const rows = queryRows.map((row) => + databaseQueryRowToCollectionRow(row, nodesById.get(row.id)) + ); + + return applyFormulaPostFilters( + rows, + formulaFilters, + database.fields, + workspace.userId + ); + }, [ + database.fields, + formulaFilters, + nodesById, + queryRows, + workspace.userId, + ]); return { data, diff --git a/packages/ui/src/hooks/use-entity-field.tsx b/packages/ui/src/hooks/use-entity-field.tsx index fef658b3c..c61d2df44 100644 --- a/packages/ui/src/hooks/use-entity-field.tsx +++ b/packages/ui/src/hooks/use-entity-field.tsx @@ -2,9 +2,13 @@ import { debounceStrategy, usePacedMutations } from '@tanstack/react-db'; import { useCallback, useMemo } from 'react'; import { LocalNode } from '@colanode/client/types'; -import { FieldAttributes, FieldValue } from '@colanode/core'; +import { FieldAttributes, FieldValue, RelationFieldAttributes } from '@colanode/core'; import { useEntityFields } from '@colanode/ui/contexts/entity-fields'; import { useWorkspace } from '@colanode/ui/contexts/workspace'; +import { + applyBidirectionalRelationSyncToCollection, + readRelationFieldIds, +} from '@colanode/ui/lib/bidirectional-relation'; import { applyNodeTransaction } from '@colanode/ui/lib/nodes'; interface Options { @@ -17,6 +21,17 @@ export const useEntityField = ({ field }: Options) => { const mutate = usePacedMutations({ onMutate: (nextValue) => { + const previousIds = + field.type === 'relation' + ? readRelationFieldIds(entity.fields, field.id) + : []; + const nextIds = + field.type === 'relation' && nextValue?.type === 'string_array' + ? nextValue.value + : field.type === 'relation' + ? [] + : []; + workspace.collections.nodes.update(entity.id, (draft) => { if (entity.entityType === 'record') { if (draft.type !== 'record') { @@ -55,6 +70,15 @@ export const useEntityField = ({ field }: Options) => { }; } }); + + if (field.type === 'relation') { + applyBidirectionalRelationSyncToCollection(workspace.collections, { + relationField: field as RelationFieldAttributes, + sourceNodeId: entity.id, + previousIds, + nextIds, + }); + } }, mutationFn: async ({ transaction }) => { await applyNodeTransaction(workspace.userId, transaction); diff --git a/packages/ui/src/lib/bidirectional-relation.ts b/packages/ui/src/lib/bidirectional-relation.ts new file mode 100644 index 000000000..ae32ba780 --- /dev/null +++ b/packages/ui/src/lib/bidirectional-relation.ts @@ -0,0 +1,78 @@ +import { + applyBidirectionalRelationSync, + getReverseRelationField, + readRelationIds, +} from '@colanode/client/lib'; +import { RelationFieldAttributes } from '@colanode/core'; +import { WorkspaceCollections } from '@colanode/ui/collections'; + +export const applyBidirectionalRelationSyncToCollection = ( + collections: WorkspaceCollections, + input: { + relationField: RelationFieldAttributes; + sourceNodeId: string; + previousIds: string[]; + nextIds: string[]; + } +): void => { + const { relationField } = input; + + if (!relationField.databaseId || !relationField.reverseFieldId) { + return; + } + + const targetDatabase = collections.nodes.get(relationField.databaseId); + if (!targetDatabase || targetDatabase.type !== 'database') { + return; + } + + applyBidirectionalRelationSync({ + relationField, + sourceNodeId: input.sourceNodeId, + previousIds: input.previousIds, + nextIds: input.nextIds, + getReverseField: () => + getReverseRelationField( + targetDatabase.fields, + relationField.reverseFieldId! + ), + updateRelationNode: (nodeId, updater) => { + if (!collections.nodes.has(nodeId)) { + return; + } + + collections.nodes.update(nodeId, (draft) => { + if ( + draft.type !== 'record' && + draft.type !== 'page' && + draft.type !== 'file' + ) { + return; + } + + updater(draft); + }); + }, + }); +}; + +export const readRelationFieldIds = ( + fields: Record, + fieldId: string +): string[] => { + const value = fields[fieldId]; + if ( + value && + typeof value === 'object' && + 'type' in value && + (value as { type: string }).type === 'string_array' && + 'value' in value && + Array.isArray((value as { value: unknown }).value) + ) { + return readRelationIds( + value as { type: 'string_array'; value: string[] } + ); + } + + return []; +}; diff --git a/packages/ui/src/lib/bulk-record-operations.ts b/packages/ui/src/lib/bulk-record-operations.ts new file mode 100644 index 000000000..f57be5088 --- /dev/null +++ b/packages/ui/src/lib/bulk-record-operations.ts @@ -0,0 +1,78 @@ +import { applyCollectionFieldActions } from '@colanode/client/lib'; +import { CollectionFieldAction, FieldAttributes, FieldValue } from '@colanode/core'; +import { WorkspaceCollections } from '@colanode/ui/collections'; + +const READ_ONLY_FIELD_TYPES = new Set([ + 'autonumber', + 'lookup', + 'rollup', + 'formula', + 'created_at', + 'created_by', + 'updated_at', + 'updated_by', + 'button', +]); + +export const isBulkEditableField = (field: FieldAttributes): boolean => { + if (READ_ONLY_FIELD_TYPES.has(field.type)) { + return false; + } + + return true; +}; + +export const bulkSetFieldValue = ( + collections: WorkspaceCollections, + databaseFields: FieldAttributes[], + nodeIds: string[], + fieldId: string, + value: FieldValue | null +): void => { + const schemaFields = Object.fromEntries( + databaseFields.map((field) => [field.id, field]) + ); + const actions: CollectionFieldAction[] = + value === null + ? [{ op: 'clear', fieldId }] + : [{ op: 'set', fieldId, value }]; + + for (const nodeId of nodeIds) { + if (!collections.nodes.has(nodeId)) { + continue; + } + + collections.nodes.update(nodeId, (draft) => { + if ( + draft.type !== 'record' && + draft.type !== 'page' && + draft.type !== 'file' + ) { + return; + } + + const updated = applyCollectionFieldActions( + { + name: draft.name, + fields: draft.fields ?? {}, + }, + actions, + schemaFields + ); + + draft.name = updated.name; + draft.fields = updated.fields; + }); + } +}; + +export const bulkDeleteNodes = ( + collections: WorkspaceCollections, + nodeIds: string[] +): void => { + for (const nodeId of nodeIds) { + if (collections.nodes.has(nodeId)) { + collections.nodes.delete(nodeId); + } + } +}; diff --git a/packages/ui/src/lib/collection-view-query.ts b/packages/ui/src/lib/collection-view-query.ts index 1d1f4daf7..1ac7e8758 100644 --- a/packages/ui/src/lib/collection-view-query.ts +++ b/packages/ui/src/lib/collection-view-query.ts @@ -2,8 +2,10 @@ import { DatabaseViewFieldFilterAttributes, DatabaseViewFilterAttributes, DatabaseViewSortAttributes, + evaluateFormulaExpression, FieldAttributes, FieldValue, + FormulaFieldAttributes, SpecialId, isStringArray, } from '@colanode/core'; @@ -25,6 +27,31 @@ const normalizeDate = (value: unknown): string | null => { return date.toISOString().split('T')[0] ?? null; }; +const getFormulaRowValue = ( + row: CollectionRow, + field: FormulaFieldAttributes, + fieldsById: Record +): unknown => { + const result = evaluateFormulaExpression( + field.expression, + { + name: row.name, + fields: row.fields, + createdAt: row.createdAt, + createdBy: row.createdBy, + updatedAt: row.updatedAt, + updatedBy: row.updatedBy, + }, + fieldsById + ); + + if (!result.success) { + return undefined; + } + + return result.value; +}; + const getRowValue = ( row: CollectionRow, fieldId: string, @@ -44,6 +71,8 @@ const getRowValue = ( return row.createdAt; case 'created_by': return row.createdBy; + case 'formula': + return getFormulaRowValue(row, field, fieldsById); case 'updated_at': return row.updatedAt; case 'updated_by': @@ -275,6 +304,37 @@ const matchFieldFilter = ( return true; case 'date': return matchDateFilter(value, filter, true); + case 'collaborator': + return matchArrayFilter(value, filter); + case 'formula': { + const formulaValue = getFormulaRowValue( + row, + field as FormulaFieldAttributes, + fieldsById + ); + + if (formulaValue === null || formulaValue === undefined) { + if (filter.operator === 'is_empty') { + return true; + } + + if (filter.operator === 'is_not_empty') { + return false; + } + + return matchStringFilter('', filter); + } + + if (typeof formulaValue === 'boolean') { + return matchBooleanFilter(formulaValue, filter); + } + + if (typeof formulaValue === 'number') { + return matchNumberFilter(formulaValue, filter); + } + + return matchStringFilter(formulaValue, filter); + } case 'email': case 'phone': case 'text': @@ -283,6 +343,7 @@ const matchFieldFilter = ( case 'multi_select': case 'relation': return matchArrayFilter(value, filter); + case 'autonumber': case 'number': return matchNumberFilter(value, filter); case 'select': @@ -370,9 +431,60 @@ const getSortValue = ( } const fieldValue = row.fields[field.id] as FieldValue | undefined; + if (field.type === 'formula') { + return getFormulaRowValue(row, field as FormulaFieldAttributes, fieldsById); + } + return fieldValue?.value; }; +export const partitionFormulaViewFilters = ( + filters: DatabaseViewFilterAttributes[], + fields: FieldAttributes[] +): { + sqlFilters: DatabaseViewFilterAttributes[]; + formulaFilters: DatabaseViewFieldFilterAttributes[]; +} => { + const fieldsById = fields.reduce>( + (acc, field) => { + acc[field.id] = field; + return acc; + }, + {} + ); + + const formulaFilters: DatabaseViewFieldFilterAttributes[] = []; + const sqlFilters: DatabaseViewFilterAttributes[] = []; + + for (const filter of filters) { + if (filter.type === 'field' && fieldsById[filter.fieldId]?.type === 'formula') { + formulaFilters.push(filter); + continue; + } + + sqlFilters.push(filter); + } + + return { sqlFilters, formulaFilters }; +}; + +export const applyFormulaPostFilters = ( + rows: CollectionRow[], + formulaFilters: DatabaseViewFieldFilterAttributes[], + fields: FieldAttributes[], + userId: string +): CollectionRow[] => { + if (formulaFilters.length === 0) { + return rows; + } + + return formulaFilters.reduce( + (currentRows, filter) => + filterCollectionRows(currentRows, filter, fields, userId), + rows + ); +}; + export const filterCollectionRows = ( rows: CollectionRow[], filter: DatabaseViewFilterAttributes, diff --git a/packages/ui/src/lib/database-csv-import.ts b/packages/ui/src/lib/database-csv-import.ts index b0e6ba38a..3170c3bec 100644 --- a/packages/ui/src/lib/database-csv-import.ts +++ b/packages/ui/src/lib/database-csv-import.ts @@ -6,6 +6,7 @@ import { } from '@colanode/core'; import { LocalRecordNode } from '@colanode/client/types'; import { + allocateAutonumbers, CsvImportHeaderMapping, isCsvImportableField, mapCsvImportHeaders, @@ -138,6 +139,9 @@ export const importDatabaseRecordsFromCsv = (input: { let importedCount = 0; let skippedCount = 0; + let fieldsState = Object.fromEntries( + input.database.fields.map((field) => [field.id, field]) + ); for (const row of table.slice(1)) { const name = @@ -163,6 +167,9 @@ export const importDatabaseRecordsFromCsv = (input: { } } + const allocation = allocateAutonumbers(fieldsState); + fieldsState = allocation.fields; + const record: LocalRecordNode = { id: generateId(IdType.Record), type: 'record', @@ -170,7 +177,10 @@ export const importDatabaseRecordsFromCsv = (input: { rootId: input.database.rootId, databaseId: input.database.id, name, - fields, + fields: { + ...fields, + ...allocation.values, + }, createdAt: new Date().toISOString(), createdBy: input.workspace.userId, updatedAt: null, @@ -183,5 +193,15 @@ export const importDatabaseRecordsFromCsv = (input: { importedCount += 1; } + if (importedCount > 0 && input.workspace.collections.nodes.has(input.database.id)) { + input.workspace.collections.nodes.update(input.database.id, (draft) => { + if (draft.type !== 'database') { + return; + } + + draft.fields = fieldsState; + }); + } + return { importedCount, skippedCount }; }; diff --git a/packages/ui/src/lib/databases.ts b/packages/ui/src/lib/databases.ts index ef2305383..3797639e6 100644 --- a/packages/ui/src/lib/databases.ts +++ b/packages/ui/src/lib/databases.ts @@ -50,6 +50,8 @@ export const getDefaultFieldWidth = (type: FieldType): number => { return 200; case 'resource': return 200; + case 'lookup': + return 200; case 'rollup': return 200; case 'select': @@ -74,7 +76,7 @@ export const getDefaultNameWidth = (): number => { export const getDefaultViewFieldDisplay = ( layout: DatabaseViewLayout ): boolean => { - return layout === 'table' || layout === 'gallery' || layout === 'list'; + return layout === 'table' || layout === 'gallery' || layout === 'list' || layout === 'form'; }; interface SelectOptionColor { @@ -655,6 +657,7 @@ export const getFieldFilterOperators = ( return fileFieldFilterOperators; case 'multi_select': return multiSelectFieldFilterOperators; + case 'autonumber': case 'number': return numberFieldFilterOperators; case 'phone': @@ -712,6 +715,7 @@ const recordMatchesFilter = ( return recordMatchesFileFilter(record, filter, field); case 'multi_select': return recordMatchesMultiSelectFilter(record, filter, field); + case 'autonumber': case 'number': return recordMatchesNumberFilter(record, filter, field); case 'phone': @@ -1161,7 +1165,7 @@ const recordMatchesUrlFilter = ( }; export const isFilterableField = (field: FieldAttributes) => { - return field.type !== 'button' && field.type !== 'formula'; + return field.type !== 'button'; }; export const isSortableField = (field: FieldAttributes) => { diff --git a/packages/ui/src/lib/page-markdown-fields.ts b/packages/ui/src/lib/page-markdown-fields.ts index 67771f795..296c61206 100644 --- a/packages/ui/src/lib/page-markdown-fields.ts +++ b/packages/ui/src/lib/page-markdown-fields.ts @@ -153,6 +153,8 @@ export const yamlValueToFieldValue = ( case 'updated_by': case 'relation': case 'rollup': + case 'lookup': + case 'autonumber': case 'button': case 'formula': case 'file': diff --git a/packages/ui/src/lib/record-create.ts b/packages/ui/src/lib/record-create.ts index acb9b7851..e34f6f8c4 100644 --- a/packages/ui/src/lib/record-create.ts +++ b/packages/ui/src/lib/record-create.ts @@ -1,8 +1,10 @@ +import { allocateAutonumbers } from '@colanode/client/lib'; import { FieldAttributes, generateId, IdType, DatabaseViewFilterAttributes, + FieldValue, } from '@colanode/core'; import { LocalRecordNode } from '@colanode/client/types'; import { WorkspaceCollections } from '@colanode/ui/collections'; @@ -25,13 +27,32 @@ export const insertDatabaseRecord = ( input?: { name?: string; filters?: DatabaseViewFilterAttributes[]; + fields?: Record; } ): LocalRecordNode => { - const fields = generateFieldValuesFromFilters( - database.fields, - input?.filters ?? [], - workspace.userId - ); + let autonumberValues: Record = {}; + + if (workspace.collections.nodes.has(database.id)) { + workspace.collections.nodes.update(database.id, (draft) => { + if (draft.type !== 'database') { + return; + } + + const allocation = allocateAutonumbers(draft.fields); + autonumberValues = allocation.values; + draft.fields = allocation.fields; + }); + } + + const fields = { + ...generateFieldValuesFromFilters( + database.fields, + input?.filters ?? [], + workspace.userId + ), + ...autonumberValues, + ...(input?.fields ?? {}), + }; const record: LocalRecordNode = { id: generateId(IdType.Record), diff --git a/packages/ui/src/lib/record-duplicate.ts b/packages/ui/src/lib/record-duplicate.ts new file mode 100644 index 000000000..02fddc87f --- /dev/null +++ b/packages/ui/src/lib/record-duplicate.ts @@ -0,0 +1,103 @@ +import { cloneDeep } from 'lodash-es'; + +import { allocateAutonumbers } from '@colanode/client/lib'; +import { + FieldAttributes, + FieldValue, + generateId, + IdType, +} from '@colanode/core'; +import { LocalRecordNode } from '@colanode/client/types'; +import { WorkspaceCollections } from '@colanode/ui/collections'; + +type WorkspaceLike = { + userId: string; + collections: WorkspaceCollections; +}; + +const COPY_SUFFIX = ' (copy)'; + +const copyFieldValues = ( + fields: Record, + databaseFields: FieldAttributes[] +): Record => { + const readOnlyFieldIds = new Set( + databaseFields + .filter((field) => field.type === 'autonumber') + .map((field) => field.id) + ); + + const copied: Record = {}; + for (const [fieldId, value] of Object.entries(fields)) { + if (readOnlyFieldIds.has(fieldId)) { + continue; + } + + copied[fieldId] = cloneDeep(value); + } + + return copied; +}; + +export const duplicateDatabaseRecord = ( + workspace: WorkspaceLike, + source: LocalRecordNode, + databaseFields: FieldAttributes[] +): LocalRecordNode => { + let autonumberValues: Record = {}; + + if (workspace.collections.nodes.has(source.databaseId)) { + workspace.collections.nodes.update(source.databaseId, (draft) => { + if (draft.type !== 'database') { + return; + } + + const allocation = allocateAutonumbers(draft.fields); + autonumberValues = allocation.values; + draft.fields = allocation.fields; + }); + } + + const record: LocalRecordNode = { + id: generateId(IdType.Record), + type: 'record', + parentId: source.parentId, + rootId: source.rootId, + databaseId: source.databaseId, + name: source.name ? `${source.name}${COPY_SUFFIX}` : '', + fields: { + ...copyFieldValues(source.fields, databaseFields), + ...autonumberValues, + }, + createdAt: new Date().toISOString(), + createdBy: workspace.userId, + updatedAt: null, + updatedBy: null, + localRevision: '0', + serverRevision: '0', + }; + + workspace.collections.nodes.insert(record); + return record; +}; + +export const duplicateCollectionRows = ( + workspace: WorkspaceLike, + rowIds: string[], + databaseFields: FieldAttributes[] +): LocalRecordNode[] => { + const created: LocalRecordNode[] = []; + + for (const rowId of rowIds) { + const node = workspace.collections.nodes.get(rowId); + if (!node || node.type !== 'record') { + continue; + } + + created.push( + duplicateDatabaseRecord(workspace, node, databaseFields) + ); + } + + return created; +}; diff --git a/packages/ui/src/lib/table-view-selection.ts b/packages/ui/src/lib/table-view-selection.ts new file mode 100644 index 000000000..78550c3ae --- /dev/null +++ b/packages/ui/src/lib/table-view-selection.ts @@ -0,0 +1,19 @@ +export const areVisibleIdListsEqual = ( + left: string[] | undefined, + right: string[] +): boolean => { + if (!left) { + return right.length === 0; + } + + if (left.length !== right.length) { + return false; + } + + return left.every((id, index) => id === right[index]); +}; + +export const buildVisibleRowIdsKey = (ids: string[]): string => ids.join('\0'); + +export const parseVisibleRowIdsKey = (key: string): string[] => + key.length > 0 ? key.split('\0') : []; diff --git a/packages/ui/src/lib/view-grouping.ts b/packages/ui/src/lib/view-grouping.ts new file mode 100644 index 000000000..e68f1c5ef --- /dev/null +++ b/packages/ui/src/lib/view-grouping.ts @@ -0,0 +1,49 @@ +import { FieldAttributes, FieldType } from '@colanode/core'; + +export const BOARD_GROUP_FIELD_TYPES: FieldType[] = [ + 'select', + 'multi_select', + 'collaborator', + 'created_by', +]; + +export const TABLE_NESTED_GROUP_FIELD_TYPES: FieldType[] = [ + 'select', + 'multi_select', +]; + +export const isBoardGroupableField = (field: FieldAttributes): boolean => + BOARD_GROUP_FIELD_TYPES.includes(field.type); + +export const isTableGroupableField = (field: FieldAttributes): boolean => + isBoardGroupableField(field); + +export const isTableNestedGroupableField = (field: FieldAttributes): boolean => + TABLE_NESTED_GROUP_FIELD_TYPES.includes(field.type); + +export const resolveViewGroupByLevels = (view: { + groupBy?: string | null; + groupByLevels?: string[] | null; +}): string[] => { + if (view.groupByLevels && view.groupByLevels.length > 0) { + return view.groupByLevels.slice(0, 2); + } + + if (view.groupBy) { + return [view.groupBy]; + } + + return []; +}; + +export const syncViewGroupByFields = ( + draft: { + groupBy?: string | null; + groupByLevels?: string[] | null; + }, + levels: string[] +): void => { + const normalized = levels.filter(Boolean).slice(0, 2); + draft.groupByLevels = normalized.length > 0 ? normalized : null; + draft.groupBy = normalized[0] ?? null; +}; diff --git a/packages/ui/test/collection-view-query-formula.test.ts b/packages/ui/test/collection-view-query-formula.test.ts new file mode 100644 index 000000000..5928a1582 --- /dev/null +++ b/packages/ui/test/collection-view-query-formula.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { FieldAttributes } from '@colanode/core'; + +import { CollectionRow } from '@colanode/ui/lib/collection-row'; +import { filterCollectionRows } from '@colanode/ui/lib/collection-view-query'; + +const formulaField: FieldAttributes = { + id: 'formula-1', + type: 'formula', + name: 'Score', + index: 'a0', + expression: 'prop("Amount")', +}; + +const amountField: FieldAttributes = { + id: 'amount', + type: 'number', + name: 'Amount', + index: 'a1', +}; + +const row = (amount: number): CollectionRow => ({ + id: `row-${amount}`, + name: `Row ${amount}`, + fields: { + amount: { type: 'number', value: amount }, + }, + nodeType: 'record', + createdAt: '2026-01-01T00:00:00.000Z', + createdBy: 'user-1', + updatedAt: null, + updatedBy: null, + source: { + id: `row-${amount}`, + type: 'record', + name: `Row ${amount}`, + parentId: 'db-1', + rootId: 'root-1', + databaseId: 'db-1', + fields: { + amount: { type: 'number', value: amount }, + }, + createdAt: '2026-01-01T00:00:00.000Z', + createdBy: 'user-1', + updatedAt: null, + updatedBy: null, + localRevision: '0', + serverRevision: '0', + }, +}); + +describe('filterCollectionRows formula post-filter', () => { + it('filters rows by evaluated formula number comparisons', () => { + const rows = [row(5), row(10), row(15)]; + const filtered = filterCollectionRows( + rows, + { + id: 'formula-filter', + type: 'field', + fieldId: formulaField.id, + operator: 'is_greater_than', + value: 8, + }, + [formulaField, amountField], + 'user-1' + ); + + expect(filtered.map((item) => item.id)).toEqual(['row-10', 'row-15']); + }); +}); diff --git a/packages/ui/test/table-view-selection.test.ts b/packages/ui/test/table-view-selection.test.ts new file mode 100644 index 000000000..1ec8b3347 --- /dev/null +++ b/packages/ui/test/table-view-selection.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { + areVisibleIdListsEqual, + buildVisibleRowIdsKey, + parseVisibleRowIdsKey, +} from '@colanode/ui/lib/table-view-selection'; + +describe('table-view-selection', () => { + it('treats identical id lists as equal', () => { + expect(areVisibleIdListsEqual(['a', 'b'], ['a', 'b'])).toBe(true); + }); + + it('treats different lengths as not equal', () => { + expect(areVisibleIdListsEqual(['a'], ['a', 'b'])).toBe(false); + }); + + it('treats missing group as empty', () => { + expect(areVisibleIdListsEqual(undefined, [])).toBe(true); + expect(areVisibleIdListsEqual(undefined, ['a'])).toBe(false); + }); + + it('round-trips visible row id keys', () => { + const ids = ['row-1', 'row-2']; + expect(parseVisibleRowIdsKey(buildVisibleRowIdsKey(ids))).toEqual(ids); + }); +}); From e1ce23b149a187c56542cb62d505aa96b532eba0 Mon Sep 17 00:00:00 2001 From: barry Date: Tue, 7 Jul 2026 14:28:22 +0800 Subject: [PATCH 03/20] Implement cross-origin isolation headers in Vite configuration, enhance loading experience in main application entry, and improve error handling in service worker registration. Update UI components to include loading indicators and streamline collection synchronization with error logging for better debugging. --- apps/web/src/main.tsx | 28 +++++++++-- apps/web/src/register-sw.tsx | 11 +++++ apps/web/src/root.tsx | 46 +++++++------------ apps/web/src/workers/dedicated.ts | 2 + apps/web/vite.config.js | 10 ++++ packages/ui/src/collections/accounts.ts | 4 ++ packages/ui/src/collections/metadata.ts | 4 ++ packages/ui/src/collections/tabs.ts | 4 ++ packages/ui/src/collections/temp-files.ts | 4 ++ packages/ui/src/collections/workspaces.ts | 4 ++ packages/ui/src/components/app/app-layout.tsx | 3 +- .../ui/src/components/app/app-provider.tsx | 13 ++---- 12 files changed, 89 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/register-sw.tsx diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index faa865fa5..5c5ec4207 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,17 +9,36 @@ import { isMobileDevice, isOpfsSupported } from '@colanode/web/lib/utils'; import { Root } from '@colanode/web/root'; import DedicatedWorker from '@colanode/web/workers/dedicated?worker'; +const rootElement = document.getElementById('root') as HTMLElement; +const root = createRoot(rootElement); + +const BootLoading = () => ( +
+

+ Loading Colanode… +

+
+); + +root.render(); + const initializeApp = async () => { const isMobile = isMobileDevice(); if (isMobile) { - const root = createRoot(document.getElementById('root') as HTMLElement); root.render(); return; } const hasOpfsSupport = await isOpfsSupported(); if (!hasOpfsSupport) { - const root = createRoot(document.getElementById('root') as HTMLElement); root.render(); return; } @@ -94,11 +113,10 @@ const initializeApp = async () => { }) ); - const root = createRoot(document.getElementById('root') as HTMLElement); root.render(); }; -initializeApp().catch(() => { - const root = createRoot(document.getElementById('root') as HTMLElement); +initializeApp().catch((error) => { + console.error('Colanode web bootstrap failed', error); root.render(); }); diff --git a/apps/web/src/register-sw.tsx b/apps/web/src/register-sw.tsx new file mode 100644 index 000000000..5f9f55c99 --- /dev/null +++ b/apps/web/src/register-sw.tsx @@ -0,0 +1,11 @@ +import { useRegisterSW } from 'virtual:pwa-register/react'; + +export const RegisterServiceWorker = () => { + useRegisterSW({ + onRegisterError(error) { + console.error('Service worker registration failed', error); + }, + }); + + return null; +}; diff --git a/apps/web/src/root.tsx b/apps/web/src/root.tsx index 5d4368ebe..28c0bd178 100644 --- a/apps/web/src/root.tsx +++ b/apps/web/src/root.tsx @@ -1,38 +1,26 @@ -import { lazy, Suspense } from 'react'; -import { useRegisterSW } from 'virtual:pwa-register/react'; - import '../../../packages/ui/src/styles/globals.css'; -const App = lazy(() => - import('@colanode/ui').then((module) => ({ default: module.App })) -); +import { lazy, Suspense } from 'react'; -const RootLoading = () => ( -
-

- Loading… -

-
-); +import { App } from '@colanode/ui'; -export const Root = () => { - useRegisterSW({ - onRegisterError(error) { - console.log('SW registration error', error); - }, - }); +const RegisterServiceWorker = import.meta.env.PROD + ? lazy(() => + import('@colanode/web/register-sw').then((module) => ({ + default: module.RegisterServiceWorker, + })) + ) + : null; +export const Root = () => { return ( - }> + <> + {RegisterServiceWorker ? ( + + + + ) : null} - + ); }; diff --git a/apps/web/src/workers/dedicated.ts b/apps/web/src/workers/dedicated.ts index 75ad07102..fd667244f 100644 --- a/apps/web/src/workers/dedicated.ts +++ b/apps/web/src/workers/dedicated.ts @@ -198,6 +198,8 @@ const handleMessage = async (message: BroadcastMessage) => { app.mediator.unsubscribeQuery(message.key, message.windowId); } else if (message.type === 'init_result') { + appInitOutput = message.result; + const promise = pendingPromises.get('init'); if (!promise || promise.type !== 'init') { return; diff --git a/apps/web/vite.config.js b/apps/web/vite.config.js index 5581849ee..6c4d8ca0f 100644 --- a/apps/web/vite.config.js +++ b/apps/web/vite.config.js @@ -5,12 +5,22 @@ import { defineConfig } from 'vite'; import { VitePWA } from 'vite-plugin-pwa'; // https://vitejs.dev/config/ +const crossOriginIsolationHeaders = { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cross-Origin-Resource-Policy': 'same-origin', +}; + export default defineConfig({ server: { + headers: crossOriginIsolationHeaders, warmup: { clientFiles: ['./index.html', './src/main.tsx', './src/root.tsx'], }, }, + preview: { + headers: crossOriginIsolationHeaders, + }, test: { globals: true, environment: 'jsdom', diff --git a/packages/ui/src/collections/accounts.ts b/packages/ui/src/collections/accounts.ts index 2aa6e3638..5a07edea9 100644 --- a/packages/ui/src/collections/accounts.ts +++ b/packages/ui/src/collections/accounts.ts @@ -24,6 +24,10 @@ export const createAccountsCollection = () => { commit(); markReady(); + }) + .catch((error) => { + console.error('Failed to sync accounts collection', error); + markReady(); }); const subscriptionId = window.eventBus.subscribe((event) => { diff --git a/packages/ui/src/collections/metadata.ts b/packages/ui/src/collections/metadata.ts index 04010de66..989737a9e 100644 --- a/packages/ui/src/collections/metadata.ts +++ b/packages/ui/src/collections/metadata.ts @@ -28,6 +28,10 @@ export const createMetadataCollection = () => { commit(); markReady(); + }) + .catch((error) => { + console.error('Failed to sync metadata collection', error); + markReady(); }); const subscriptionId = window.eventBus.subscribe((event) => { diff --git a/packages/ui/src/collections/tabs.ts b/packages/ui/src/collections/tabs.ts index 8fbd42696..bcfdd9525 100644 --- a/packages/ui/src/collections/tabs.ts +++ b/packages/ui/src/collections/tabs.ts @@ -24,6 +24,10 @@ export const createTabsCollection = () => { commit(); markReady(); + }) + .catch((error) => { + console.error('Failed to sync tabs collection', error); + markReady(); }); const subscriptionId = window.eventBus.subscribe((event) => { diff --git a/packages/ui/src/collections/temp-files.ts b/packages/ui/src/collections/temp-files.ts index 82bf53cf3..0e01d8fa8 100644 --- a/packages/ui/src/collections/temp-files.ts +++ b/packages/ui/src/collections/temp-files.ts @@ -24,6 +24,10 @@ export const createTempFilesCollection = () => { commit(); markReady(); + }) + .catch((error) => { + console.error('Failed to sync temp files collection', error); + markReady(); }); const subscriptionId = window.eventBus.subscribe((event) => { diff --git a/packages/ui/src/collections/workspaces.ts b/packages/ui/src/collections/workspaces.ts index 0af60db64..9071b4086 100644 --- a/packages/ui/src/collections/workspaces.ts +++ b/packages/ui/src/collections/workspaces.ts @@ -24,6 +24,10 @@ export const createWorkspacesCollection = () => { commit(); markReady(); + }) + .catch((error) => { + console.error('Failed to sync workspaces collection', error); + markReady(); }); const subscriptionId = window.eventBus.subscribe((event) => { diff --git a/packages/ui/src/components/app/app-layout.tsx b/packages/ui/src/components/app/app-layout.tsx index e4bc63480..75ab95082 100644 --- a/packages/ui/src/components/app/app-layout.tsx +++ b/packages/ui/src/components/app/app-layout.tsx @@ -2,6 +2,7 @@ import { lazy, Suspense } from 'react'; import { match } from 'ts-pattern'; import { AppType } from '@colanode/client/types'; +import { AppLoading } from '@colanode/ui/components/app/app-loading'; const LayoutDesktop = lazy(() => import('@colanode/ui/components/layouts/layout-desktop').then((module) => ({ @@ -26,7 +27,7 @@ interface AppLayoutProps { export const AppLayout = ({ type }: AppLayoutProps) => { return (
- + }> {match(type) .with('desktop', () => ) .with('mobile', () => ) diff --git a/packages/ui/src/components/app/app-provider.tsx b/packages/ui/src/components/app/app-provider.tsx index dfa09fe97..619ee23ea 100644 --- a/packages/ui/src/components/app/app-provider.tsx +++ b/packages/ui/src/components/app/app-provider.tsx @@ -28,15 +28,10 @@ export const AppProvider = ({ type }: AppProviderProps) => { console.log('Colanode | Initialized'); if (output === 'success') { - collections - .preload() - .then(() => { - setInitOutput('success'); - }) - .catch((err) => { - setInitOutput('error'); - console.error('Colanode | Error preloading', err); - }); + setInitOutput('success'); + void collections.preload().catch((err) => { + console.error('Colanode | Error preloading collections', err); + }); } else { setInitOutput(output); } From 2f30704ccd0e41eec04f47b8c54478ed7c2cb376 Mon Sep 17 00:00:00 2001 From: barry Date: Tue, 7 Jul 2026 18:45:27 +0800 Subject: [PATCH 04/20] Enhance Vite configuration with HMR settings, implement cleanup for service workers in development mode, and ensure local-only account initialization in dedicated worker. Update UI routes to handle account loading errors and streamline workspace creation process. --- apps/web/src/main.tsx | 26 +++++++++++ apps/web/src/workers/dedicated.ts | 43 +++++++++++++++++++ apps/web/vite.config.js | 8 +++- .../components/documents/document-editor.tsx | 11 +---- packages/ui/src/routes/create.tsx | 30 ++++++++++--- packages/ui/src/routes/home.tsx | 5 ++- 6 files changed, 104 insertions(+), 19 deletions(-) diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 5c5ec4207..f327e6ee0 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -30,7 +30,33 @@ const BootLoading = () => ( root.render(); +const cleanupDevServiceWorkers = async () => { + if (!import.meta.env.DEV || !('serviceWorker' in navigator)) { + return; + } + + try { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map((registration) => registration.unregister())); + } catch (error) { + console.warn('Failed to unregister service workers in dev mode', error); + } + + if (!('caches' in window)) { + return; + } + + try { + const cacheKeys = await caches.keys(); + await Promise.all(cacheKeys.map((cacheKey) => caches.delete(cacheKey))); + } catch (error) { + console.warn('Failed to clear caches in dev mode', error); + } +}; + const initializeApp = async () => { + await cleanupDevServiceWorkers(); + const isMobile = isMobileDevice(); if (isMobile) { root.render(); diff --git a/apps/web/src/workers/dedicated.ts b/apps/web/src/workers/dedicated.ts index fd667244f..fb25998e8 100644 --- a/apps/web/src/workers/dedicated.ts +++ b/apps/web/src/workers/dedicated.ts @@ -1,6 +1,7 @@ import * as Comlink from 'comlink'; import { eventBus } from '@colanode/client/lib'; +import { mapAccount } from '@colanode/client/lib/mappers'; import { MutationInput, MutationResult, @@ -15,6 +16,7 @@ import { extractFileSubtype, generateId, IdType, + createDebugger, } from '@colanode/core'; import { BroadcastInitMessage, @@ -33,6 +35,10 @@ import { WebPathService } from '@colanode/web/services/path-service'; const windowId = generateId(IdType.Window); const pendingPromises = new Map(); +const debug = createDebugger('web:worker:dedicated'); + +const LOCAL_ACCOUNT_EMAIL = 'local@colanode.local'; +const LOCAL_ACCOUNT_NAME = 'Local User'; const fs = new WebFileSystem(); const path = new WebPathService(); @@ -70,11 +76,46 @@ const finishInit = (result: AppInitOutput) => { } }; +const ensureLocalOnlyAccountBootstrap = async (app: AppService) => { + const now = new Date().toISOString(); + let accountRow = await app.database + .selectFrom('accounts') + .selectAll() + .orderBy('created_at', 'asc') + .executeTakeFirst(); + + if (!accountRow) { + debug('No local account found in web mode, creating bootstrap account'); + accountRow = await app.database + .insertInto('accounts') + .returningAll() + .values({ + id: generateId(IdType.Account), + name: LOCAL_ACCOUNT_NAME, + email: LOCAL_ACCOUNT_EMAIL, + avatar: null, + token: 'local-only', + device_id: generateId(IdType.Device), + created_at: now, + updated_at: now, + synced_at: now, + }) + .executeTakeFirst(); + } + + if (!accountRow) { + throw new Error('Failed to initialize local-only account record'); + } + + await app.initAccount(mapAccount(accountRow)); +}; + navigator.locks.request('colanode', async () => { try { const appMeta: AppMeta = { type: 'web', platform: navigator.userAgent, + localOnly: true, }; const bootstrap = await WebBootstrapService.create(path, fs); @@ -87,10 +128,12 @@ navigator.locks.request('colanode', async () => { await app.migrate(); await app.init(); + await ensureLocalOnlyAccountBootstrap(app); await bootstrap.updateVersion(build.version); await app.metadata.set('app', 'version', build.version); await app.metadata.set('app', 'platform', appMeta.platform); + await app.metadata.set('app', 'mode.localOnly', true); finishInit('success'); diff --git a/apps/web/vite.config.js b/apps/web/vite.config.js index 6c4d8ca0f..d5ccc7b89 100644 --- a/apps/web/vite.config.js +++ b/apps/web/vite.config.js @@ -11,9 +11,13 @@ const crossOriginIsolationHeaders = { 'Cross-Origin-Resource-Policy': 'same-origin', }; -export default defineConfig({ +export default defineConfig(() => ({ server: { headers: crossOriginIsolationHeaders, + hmr: { + protocol: 'ws', + clientPort: 4000, + }, warmup: { clientFiles: ['./index.html', './src/main.tsx', './src/root.tsx'], }, @@ -99,4 +103,4 @@ export default defineConfig({ }, }, }, -}); +})); diff --git a/packages/ui/src/components/documents/document-editor.tsx b/packages/ui/src/components/documents/document-editor.tsx index 2f2b973bf..9c9102379 100644 --- a/packages/ui/src/components/documents/document-editor.tsx +++ b/packages/ui/src/components/documents/document-editor.tsx @@ -7,7 +7,7 @@ import { useEditor, } from '@tiptap/react'; import { debounce, isEqual } from 'lodash-es'; -import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; +import { Fragment, useEffect, useMemo, useRef } from 'react'; import { toast } from 'sonner'; import { @@ -207,7 +207,6 @@ export const DocumentEditor = ({ const revisionRef = useRef(state?.revision ?? 0); const ydocRef = useRef(buildYDoc(state, updates)); const editorRef = useRef>(null); - const [editorReady, setEditorReady] = useState(false); const debouncedSave = useMemo( () => @@ -414,12 +413,6 @@ export const DocumentEditor = ({ editable: canEdit, shouldRerenderOnTransaction: false, autofocus: autoFocus, - onCreate: () => { - setEditorReady(true); - }, - onDestroy: () => { - setEditorReady(false); - }, onUpdate: async ({ editor, transaction }) => { if (transaction.docChanged) { hasPendingChanges.current = true; @@ -477,7 +470,7 @@ export const DocumentEditor = ({ return ( <> - {editor && editorReady && canEdit && ( + {editor && canEdit && ( diff --git a/packages/ui/src/routes/create.tsx b/packages/ui/src/routes/create.tsx index aa3325152..3db97fb2b 100644 --- a/packages/ui/src/routes/create.tsx +++ b/packages/ui/src/routes/create.tsx @@ -8,6 +8,23 @@ import { rootRoute } from '@colanode/ui/routes/root'; const Component = () => { const { accountId } = workspaceCreateRoute.useLoaderData(); + if (!accountId) { + return ( +
+
+

+ Unable to load account data +

+

+ Colanode cannot find a local account in this browser profile yet. + Please refresh the page. If this persists, clear site data and try + again. +

+
+
+ ); + } + return ; }; @@ -15,11 +32,8 @@ export const workspaceCreateRoute = createRoute({ getParentRoute: () => rootRoute, path: '/create', component: Component, - beforeLoad: () => { - const accountsCount = collections.accounts.size; - if (accountsCount === 0) { - throw redirect({ to: '/', replace: true }); - } + beforeLoad: async () => { + await collections.accounts.preload(); }, loader: () => { const accounts = collections.accounts.map((account) => account); @@ -47,8 +61,10 @@ export const workspaceCreateRoute = createRoute({ }; } - // this should never happen - throw new Error('No accounts found'); + // Avoid redirect loops between "/" and "/create". + return { + accountId: null, + }; }, context: () => { return { diff --git a/packages/ui/src/routes/home.tsx b/packages/ui/src/routes/home.tsx index 491e74c73..0578386ba 100644 --- a/packages/ui/src/routes/home.tsx +++ b/packages/ui/src/routes/home.tsx @@ -1,5 +1,6 @@ import { createRoute, redirect } from '@tanstack/react-router'; +import { collections } from '@colanode/ui/collections'; import { rootRoute } from '@colanode/ui/routes/root'; import { getDefaultWorkspaceUserId, @@ -10,7 +11,9 @@ export const homeRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: () => null, - beforeLoad: () => { + beforeLoad: async () => { + await collections.workspaces.preload(); + const defaultWorkspaceUserId = getDefaultWorkspaceUserId(); if (defaultWorkspaceUserId) { throw redirect({ From 1cb24fb1abc1af34a114dc8570329ee96355bcdb Mon Sep 17 00:00:00 2001 From: barry Date: Tue, 7 Jul 2026 18:55:50 +0800 Subject: [PATCH 05/20] Enhance default Page meta type by adding fields: Status, Category, Tags, Owner, Created, and Updated. Implement functionality to upgrade existing Page meta types with these default fields if missing. Update tests to verify the presence of new fields in the Page meta type. --- docs/page-meta-types.md | 2 +- packages/client/src/lib/page-meta-types.ts | 165 ++++++++++++++++++- packages/client/test/page-meta-types.test.ts | 94 +++++++++++ 3 files changed, 259 insertions(+), 2 deletions(-) diff --git a/docs/page-meta-types.md b/docs/page-meta-types.md index 64633bdad..c6cf1d8df 100644 --- a/docs/page-meta-types.md +++ b/docs/page-meta-types.md @@ -19,7 +19,7 @@ Each page's `metaTypeId` is set at creation time and **cannot be changed** after On workspace init, `ensurePageMetaTypes` creates: 1. Hidden space `Meta Types` (`description: __colanode_meta_types__`) — hidden from the Spaces sidebar -2. Default meta type database named `Page` +2. Default meta type database named `Page` with fields: Status, Category, Tags, Owner, Created (`created_at`), Updated (`updated_at`) 3. Built-in starter meta types (if missing): `Bookmark`, `Project`, `Issue`, `Repo`, `Team`, `Events`, `People` 4. Backfills missing or invalid `metaTypeId` on existing pages via `node.update` (creates syncable `node_updates`; SQLite is reconciled from YDoc when CRDT and SQLite drift) 5. When local sync is enabled, the first import runs before `ensurePageMetaTypes` so a remote default `Page` schema is not duplicated locally diff --git a/packages/client/src/lib/page-meta-types.ts b/packages/client/src/lib/page-meta-types.ts index 05659f826..490effed8 100644 --- a/packages/client/src/lib/page-meta-types.ts +++ b/packages/client/src/lib/page-meta-types.ts @@ -3,8 +3,10 @@ import { createDebugger, DatabaseAttributes, DEFAULT_PAGE_META_TYPE_NAME, + FieldAttributes, generateFractionalIndex, generateId, + getNextFractionalIndex, IdType, META_TYPES_SPACE_MARKER, NodeAttributes, @@ -48,6 +50,23 @@ type FieldPreset = name: string; options: SelectOptionPreset[]; default?: string; + } + | { + type: 'multi_select'; + name: string; + options: SelectOptionPreset[]; + } + | { + type: 'collaborator'; + name: string; + } + | { + type: 'created_at'; + name: string; + } + | { + type: 'updated_at'; + name: string; }; type MetaTypePreset = { @@ -55,6 +74,52 @@ type MetaTypePreset = { fields: FieldPreset[]; }; +const DEFAULT_PAGE_META_TYPE_FIELD_PRESETS: FieldPreset[] = [ + { + type: 'select', + name: 'Status', + options: [ + { name: 'Draft', color: 'gray' }, + { name: 'In Review', color: 'blue' }, + { name: 'Published', color: 'green' }, + { name: 'Archived', color: 'zinc' }, + ], + }, + { + type: 'select', + name: 'Category', + options: [ + { name: 'Note', color: 'gray' }, + { name: 'Spec', color: 'blue' }, + { name: 'Guide', color: 'green' }, + { name: 'Meeting', color: 'purple' }, + { name: 'Reference', color: 'yellow' }, + ], + }, + { + type: 'multi_select', + name: 'Tags', + options: [ + { name: 'Doc', color: 'blue' }, + { name: 'Design', color: 'purple' }, + { name: 'API', color: 'green' }, + { name: 'Ops', color: 'orange' }, + { name: 'Personal', color: 'gray' }, + ], + }, + { + type: 'collaborator', + name: 'Owner', + }, + { + type: 'created_at', + name: 'Created', + }, + { + type: 'updated_at', + name: 'Updated', + }, +]; const DEFAULT_META_TYPE_PRESETS: MetaTypePreset[] = [ { @@ -298,6 +363,31 @@ export const buildPresetFields = ( continue; } + if (preset.type === 'multi_select') { + fields[fieldId] = { + id: fieldId, + type: 'multi_select', + name: preset.name, + index: fieldIndex, + options: buildSelectOptions(preset.options), + }; + continue; + } + + if ( + preset.type === 'collaborator' || + preset.type === 'created_at' || + preset.type === 'updated_at' + ) { + fields[fieldId] = { + id: fieldId, + type: preset.type, + name: preset.name, + index: fieldIndex, + }; + continue; + } + fields[fieldId] = { id: fieldId, type: preset.type, @@ -312,6 +402,77 @@ export const buildPresetFields = ( return fields; }; +const mergeMissingPresetFields = ( + existingFields: Record, + presets: FieldPreset[] +): Record | null => { + const existingNames = new Set( + Object.values(existingFields).map((field) => field.name.trim().toLowerCase()) + ); + + const missingPresets = presets.filter( + (preset) => !existingNames.has(preset.name.trim().toLowerCase()) + ); + + if (missingPresets.length === 0) { + return null; + } + + const newFields = buildPresetFields(missingPresets); + let nextIndex = getNextFractionalIndex( + Object.values(existingFields).map((field) => field.index) + ); + + const merged = { ...existingFields }; + for (const field of Object.values(newFields)) { + merged[field.id] = { + ...field, + index: nextIndex, + }; + nextIndex = generateFractionalIndex(nextIndex, null); + } + + return merged; +}; + +const ensureDefaultPageMetaTypeFields = async ( + workspace: WorkspaceService, + pageMetaTypeId: string +): Promise => { + const row = await workspace.database + .selectFrom('nodes') + .select(['id', 'attributes']) + .where('id', '=', pageMetaTypeId) + .executeTakeFirst(); + + if (!row) { + return; + } + + const attributes = JSON.parse(row.attributes) as DatabaseAttributes; + const merged = mergeMissingPresetFields( + attributes.fields ?? {}, + DEFAULT_PAGE_META_TYPE_FIELD_PRESETS + ); + + if (!merged) { + return; + } + + const result = await workspace.nodes.updateNode(pageMetaTypeId, (draft) => { + if (draft.type !== 'database') { + return draft; + } + + draft.fields = merged; + return draft; + }); + + if (result === 'success') { + debug(`Upgraded default Page meta type fields on ${pageMetaTypeId}`); + } +}; + const listPageMetaTypes = async ( workspace: WorkspaceService ): Promise => { @@ -553,7 +714,7 @@ const createDefaultPageMetaType = async ( parentId: spaceId, purpose: 'meta_type', instanceKind: 'page', - fields: {}, + fields: buildPresetFields(DEFAULT_PAGE_META_TYPE_FIELD_PRESETS), }); await workspace.nodes.insertNode(viewId, { @@ -641,6 +802,8 @@ export const ensurePageMetaTypes = async ( defaultMetaTypeId = await createDefaultPageMetaType(workspace, spaceId); } + await ensureDefaultPageMetaTypeFields(workspace, defaultMetaTypeId); + const metaTypesSpaceId = await findOrCreateMetaTypesSpace(workspace); await ensureDefaultMetaTypePresets(workspace, metaTypesSpaceId); diff --git a/packages/client/test/page-meta-types.test.ts b/packages/client/test/page-meta-types.test.ts index 3024ff531..89a68007d 100644 --- a/packages/client/test/page-meta-types.test.ts +++ b/packages/client/test/page-meta-types.test.ts @@ -454,6 +454,100 @@ describe('page meta types', () => { expect.arrayContaining(['URL', 'Language', 'License', 'Default Branch']) ); + const pageMetaType = await database + .selectFrom('nodes') + .where('type', '=', 'database') + .select(['attributes']) + .execute() + .then((rows) => + rows + .map((row) => JSON.parse(row.attributes) as Record) + .find( + (attributes) => + attributes.purpose === 'meta_type' && + attributes.name === DEFAULT_PAGE_META_TYPE_NAME + ) + ); + + const pageFields = Object.values( + ((pageMetaType?.fields as Record) ?? {}) + ).map((field) => field.name); + expect(pageFields).toEqual( + expect.arrayContaining([ + 'Status', + 'Category', + 'Tags', + 'Owner', + 'Created', + 'Updated', + ]) + ); + }); + + it('upgrades empty Page meta type with default fields', async () => { + const spaceId = generateId(IdType.Space); + const pageMetaTypeId = generateId(IdType.Database); + + await insertNode( + database, + spaceId, + { + type: 'space', + name: 'Meta Types', + description: '__colanode_meta_types__', + collaborators: { [userId]: 'admin' }, + visibility: 'private', + }, + userId + ); + + await insertNode( + database, + pageMetaTypeId, + { + type: 'database', + name: DEFAULT_PAGE_META_TYPE_NAME, + parentId: spaceId, + purpose: 'meta_type', + instanceKind: 'page', + fields: {}, + }, + userId + ); + + const workspace = { + userId, + workspaceId: generateId(IdType.Workspace), + accountId: generateId(IdType.Account), + role: 'owner', + database, + account: { app: { meta: { localOnly: true } } }, + } as unknown as WorkspaceService; + + workspace.nodes = new NodeService(workspace); + await ensurePageMetaTypes(workspace); + + const pageMetaType = await database + .selectFrom('nodes') + .where('id', '=', pageMetaTypeId) + .select(['attributes']) + .executeTakeFirstOrThrow(); + + const pageFields = Object.values( + (JSON.parse(pageMetaType.attributes) as { fields: Record }) + .fields ?? {} + ).map((field) => field.name); + + expect(pageFields).toEqual( + expect.arrayContaining([ + 'Status', + 'Category', + 'Tags', + 'Owner', + 'Created', + 'Updated', + ]) + ); }); it('PageByMetaTypeQueryHandler returns pages for a meta type', async () => { From a18c7959a7b82220e6dd86b7aceaf58ed9d5dd08 Mon Sep 17 00:00:00 2001 From: barry Date: Tue, 7 Jul 2026 19:00:00 +0800 Subject: [PATCH 06/20] Implement upgrade functionality for default Page meta type schema and integrate it into AppService. This includes enhancements to ensure default fields are applied to existing Page meta types, improving overall data consistency. --- packages/client/src/lib/page-meta-types.ts | 16 ++++++++++++++++ packages/client/src/services/app-service.ts | 4 +++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/client/src/lib/page-meta-types.ts b/packages/client/src/lib/page-meta-types.ts index 490effed8..50d57888b 100644 --- a/packages/client/src/lib/page-meta-types.ts +++ b/packages/client/src/lib/page-meta-types.ts @@ -470,7 +470,23 @@ const ensureDefaultPageMetaTypeFields = async ( if (result === 'success') { debug(`Upgraded default Page meta type fields on ${pageMetaTypeId}`); + return; + } + + console.warn( + `Colanode | Failed to upgrade default Page meta type fields (${result})` + ); +}; + +export const upgradeDefaultPageMetaTypeSchema = async ( + workspace: WorkspaceService +): Promise => { + const pageMetaTypeId = await findDefaultPageMetaTypeId(workspace); + if (!pageMetaTypeId) { + return; } + + await ensureDefaultPageMetaTypeFields(workspace, pageMetaTypeId); }; const listPageMetaTypes = async ( diff --git a/packages/client/src/services/app-service.ts b/packages/client/src/services/app-service.ts index 7ed925be7..26164f337 100644 --- a/packages/client/src/services/app-service.ts +++ b/packages/client/src/services/app-service.ts @@ -10,7 +10,7 @@ import { Mediator } from '@colanode/client/handlers'; import { eventBus } from '@colanode/client/lib/event-bus'; import { mapAccount, mapWorkspace } from '@colanode/client/lib/mappers'; import { ensureFileMetaTypes } from '@colanode/client/lib/file-meta-types'; -import { ensurePageMetaTypes } from '@colanode/client/lib/page-meta-types'; +import { ensurePageMetaTypes, upgradeDefaultPageMetaTypeSchema } from '@colanode/client/lib/page-meta-types'; import { ensureScheduledTaskMetaType } from '@colanode/client/lib/scheduled-task-meta-type'; import { ensureTodoListSystemDatabase } from '@colanode/client/lib/todo-list-system'; import { AccountService } from '@colanode/client/services/accounts/account-service'; @@ -285,6 +285,7 @@ export class AppService { await ensureFileMetaTypes(existing); await ensureScheduledTaskMetaType(existing); await ensureTodoListSystemDatabase(existing); + await upgradeDefaultPageMetaTypeSchema(existing); return existing; } @@ -302,6 +303,7 @@ export class AppService { await ensureFileMetaTypes(workspaceService); await ensureScheduledTaskMetaType(workspaceService); await ensureTodoListSystemDatabase(workspaceService); + await upgradeDefaultPageMetaTypeSchema(workspaceService); this.workspaces.set(workspace.user_id, workspaceService); return workspaceService; From d727fce782b6c3fe7044f37a5fb3affddf0cb6c7 Mon Sep 17 00:00:00 2001 From: barry Date: Wed, 8 Jul 2026 12:01:45 +0800 Subject: [PATCH 07/20] Add local workspace member management for LOCAL_ONLY mode. Introduce workspace.member create/update/delete mutations, UserService CRUD, Members UI in settings, and docs/local-members.md for collaborator-style roster management without server accounts. Co-authored-by: Cursor --- .cursor/skills/colanode/SKILL.md | 2 +- AGENTS.md | 2 +- docs/local-members.md | 75 ++++++++ docs/roadmap.md | 2 + .../client/src/handlers/mutations/index.ts | 6 + .../workspaces/workspace-member-create.ts | 56 ++++++ .../workspaces/workspace-member-delete.ts | 47 +++++ .../workspaces/workspace-member-update.ts | 58 +++++++ packages/client/src/mutations/index.ts | 3 + .../workspaces/workspace-member-create.ts | 24 +++ .../workspaces/workspace-member-delete.ts | 20 +++ .../workspaces/workspace-member-update.ts | 22 +++ .../src/services/workspaces/user-service.ts | 162 ++++++++++++++++++ .../client/test/workspace-members.test.ts | 129 ++++++++++++++ .../layouts/global-command-palette.tsx | 25 ++- .../layouts/sidebars/sidebar-settings.tsx | 20 +-- .../workspace-member-delete-button.tsx | 80 +++++++++ .../workspace-member-edit-dialog.tsx | 109 ++++++++++++ .../workspaces/workspace-user-invite.tsx | 143 +++++++++++++++- .../workspaces/workspace-users-breadcrumb.tsx | 11 +- .../workspaces/workspace-users-container.tsx | 56 +++++- .../workspaces/workspace-users-tab.tsx | 11 +- 22 files changed, 1024 insertions(+), 39 deletions(-) create mode 100644 docs/local-members.md create mode 100644 packages/client/src/handlers/mutations/workspaces/workspace-member-create.ts create mode 100644 packages/client/src/handlers/mutations/workspaces/workspace-member-delete.ts create mode 100644 packages/client/src/handlers/mutations/workspaces/workspace-member-update.ts create mode 100644 packages/client/src/mutations/workspaces/workspace-member-create.ts create mode 100644 packages/client/src/mutations/workspaces/workspace-member-delete.ts create mode 100644 packages/client/src/mutations/workspaces/workspace-member-update.ts create mode 100644 packages/client/test/workspace-members.test.ts create mode 100644 packages/ui/src/components/workspaces/workspace-member-delete-button.tsx create mode 100644 packages/ui/src/components/workspaces/workspace-member-edit-dialog.tsx diff --git a/.cursor/skills/colanode/SKILL.md b/.cursor/skills/colanode/SKILL.md index edb6d993c..e3871270b 100644 --- a/.cursor/skills/colanode/SKILL.md +++ b/.cursor/skills/colanode/SKILL.md @@ -46,7 +46,7 @@ description: >- | Assets library | `settings/assets` | `ASSET_LIBRARY_SYSTEM_KIND`, `META_TYPES_SPACE_MARKER` | | Notifications | `settings/notifications` + sidebar bell | `notification.*` mutations | -See `docs/page-meta-types.md`, `docs/todo-list.md`, `docs/form-view.md`, `docs/asset-library.md`. Database Airtable alignment plan: `docs/database-airtable-alignment.md`. +See `docs/page-meta-types.md`, `docs/todo-list.md`, `docs/local-members.md`, `docs/form-view.md`, `docs/asset-library.md`. Database Airtable alignment plan: `docs/database-airtable-alignment.md`. ## Adding a client operation diff --git a/AGENTS.md b/AGENTS.md index 1aabdac07..b1fe4669c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ Colanode is a local-first collaboration workspace. Clients use local SQLite stor ## Planning & feature docs - Product roadmap and candidate directions: `docs/roadmap.md` -- Shipped features: `docs/page-meta-types.md`, `docs/todo-list.md`, `docs/local-sync.md`, `docs/asset-library.md`, `docs/form-view.md`, etc. +- Shipped features: `docs/page-meta-types.md`, `docs/todo-list.md`, `docs/local-sync.md`, `docs/local-members.md`, `docs/asset-library.md`, `docs/form-view.md`, etc. - Planned: `docs/database-airtable-alignment.md` (database / 多维表格 → Airtable). Workspace tags: see `docs/tags-implementation-guide.md` (**not on this branch**; `feat/sync_local` only). ## AI agents (MCP + Skills) diff --git a/docs/local-members.md b/docs/local-members.md new file mode 100644 index 000000000..ef11b68ac --- /dev/null +++ b/docs/local-members.md @@ -0,0 +1,75 @@ +# Local workspace members + +In **LOCAL_ONLY** mode (web worker / single-user desktop), Colanode does not sync accounts from a server. Workspace **members** are rows in the per-workspace `users` table — the roster used by `collaborator` fields, TodoList **Assignees**, Page **Owner**, mentions, and user search. + +This is **not** login/account management. The app still has one **account** (`accounts` table); members are workspace-scoped people you can assign to work. + +## Model + +| Concept | Storage | Notes | +|---------|---------|-------| +| Account | `accounts` | App identity; web auto-seeds `local@colanode.local` | +| Workspace member | `users` (per workspace DB) | Name, optional email, role, avatar | +| Operator | One member row with `id === workspace.userId` | Seeded by `UserService.ensureLocalUser` as owner | + +Members created without an email get a synthetic placeholder: `{userId}@local.colanode` (hidden in the Members UI). + +## Roles + +| Role | Create via UI | Edit / delete | +|------|---------------|---------------| +| Owner | No (operator seed) | No | +| Admin | No | No | +| Collaborator | Yes (default) | Yes | +| Guest | Yes | Yes | + +Only **owner** or **admin** can add, edit, or remove members. You cannot remove yourself. + +## UI entry points + +- **Settings sidebar → Members** (`/workspace/$userId/users`) — shown in LOCAL_ONLY; labeled **Users** when not local-only +- **Add member** — name, optional email, role (Collaborator / Guest) +- **Member list** — `(You)` on the current operator; edit name / remove for collaborator & guest rows +- **Command palette** — “Open workspace members” (local) or “Open workspace users” + +## Mutations (local-only) + +| Mutation | Purpose | +|----------|---------| +| `workspace.member.create` | Add member (`name` required; `email?`, `avatar?`, `role?`) | +| `workspace.member.update` | Change `name` / `avatar` (email is insert-only per schema) | +| `workspace.member.delete` | Remove collaborator or guest | + +Handlers require `app.meta.localOnly` and caller role `owner` | `admin`. + +## Events + +Member changes publish on the client event bus so live collections update: + +- `user.created` +- `user.updated` +- `user.deleted` + +`packages/ui/src/collections/users.ts` subscribes and refreshes the Members list. + +## Code map + +| Area | Path | +|------|------| +| Service | `packages/client/src/services/workspaces/user-service.ts` — `createLocalMember`, `updateLocalMember`, `deleteLocalMember` | +| Mutations | `packages/client/src/mutations/workspaces/workspace-member-*.ts` | +| Handlers | `packages/client/src/handlers/mutations/workspaces/workspace-member-*.ts` | +| UI | `packages/ui/src/components/workspaces/workspace-users-container.tsx`, `workspace-user-invite.tsx`, `workspace-member-*` | +| Tests | `packages/client/test/workspace-members.test.ts` | + +## Verification + +1. Web: close all `localhost:4000` tabs, restart `apps/web` dev server (worker holds `navigator.locks`). +2. Open **Settings → Members**, add a few people. +3. In a Page **Owner** field or TodoList **Assignees**, confirm new members appear in search. + +## See also + +- [todo-list.md](./todo-list.md) — Assignees uses `collaborator` field type +- [page-meta-types.md](./page-meta-types.md) — default Page meta type includes Owner (collaborator) +- [local-sync.md](./local-sync.md) — LOCAL_ONLY / sync context diff --git a/docs/roadmap.md b/docs/roadmap.md index ec707b5b6..50d2a7199 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -13,6 +13,7 @@ | 多维表格 → Airtable 对齐 | [database-airtable-alignment.md](./database-airtable-alignment.md) | | TodoList(系统任务库) | [todo-list.md](./todo-list.md) | | 本地同步 | [local-sync.md](./local-sync.md) | +| 本地成员管理(LOCAL_ONLY) | [local-members.md](./local-members.md) | | Page Mermaid | [mermaid.md](./mermaid.md) | | Workspace 标签(仅 `feat/sync_local`,未合入) | [tags-implementation-guide.md](./tags-implementation-guide.md) | | 频道搜索与 @ 提及 | [channel-search-and-mentions.md](./channel-search-and-mentions.md) | @@ -46,6 +47,7 @@ - 本地备份/同步、频道消息增强等(见上表链接) - **全局 Assets 库**:系统级 `asset_library` 数据库;Folder 为过滤标签;入口 Settings → Assets(见 [asset-library.md](./asset-library.md)) - **TodoList 系统任务库**:`systemKind: todo_list` 的 records 数据库;侧栏快速录入 + List 分栏详情 + Target date 到期提醒(见 [todo-list.md](./todo-list.md)) +- **本地成员管理(LOCAL_ONLY)**:工作区 `users` 表 CRUD,供 collaborator / Assignees / Owner 选人(见 [local-members.md](./local-members.md)) - **MCP + Skills(薄 MVP)**:项目 Skill + 只读 MCP server + **`colapp` CLI**(见 [mcp-skills.md](./mcp-skills.md)) --- diff --git a/packages/client/src/handlers/mutations/index.ts b/packages/client/src/handlers/mutations/index.ts index 16c303d99..de5710dc6 100644 --- a/packages/client/src/handlers/mutations/index.ts +++ b/packages/client/src/handlers/mutations/index.ts @@ -43,6 +43,9 @@ import { LocalSyncSetPausedMutationHandler } from './workspaces/local-sync-set-p import { LocalSyncTriggerMutationHandler } from './workspaces/local-sync-trigger'; import { WorkspaceCreateMutationHandler } from './workspaces/workspace-create'; import { WorkspaceDeleteMutationHandler } from './workspaces/workspace-delete'; +import { WorkspaceMemberCreateMutationHandler } from './workspaces/workspace-member-create'; +import { WorkspaceMemberDeleteMutationHandler } from './workspaces/workspace-member-delete'; +import { WorkspaceMemberUpdateMutationHandler } from './workspaces/workspace-member-update'; import { WorkspaceUpdateMutationHandler } from './workspaces/workspace-update'; export type MutationHandlerMap = { @@ -73,6 +76,9 @@ export const buildMutationHandlerMap = ( 'node.reaction.delete': new NodeReactionDeleteMutationHandler(app), 'workspace.create': new WorkspaceCreateMutationHandler(app), 'workspace.update': new WorkspaceUpdateMutationHandler(app), + 'workspace.member.create': new WorkspaceMemberCreateMutationHandler(app), + 'workspace.member.update': new WorkspaceMemberUpdateMutationHandler(app), + 'workspace.member.delete': new WorkspaceMemberDeleteMutationHandler(app), 'avatar.upload': new AvatarUploadMutationHandler(app), 'file.create': new FileCreateMutationHandler(app), 'file.download': new FileDownloadMutationHandler(app), diff --git a/packages/client/src/handlers/mutations/workspaces/workspace-member-create.ts b/packages/client/src/handlers/mutations/workspaces/workspace-member-create.ts new file mode 100644 index 000000000..d37289492 --- /dev/null +++ b/packages/client/src/handlers/mutations/workspaces/workspace-member-create.ts @@ -0,0 +1,56 @@ +import { WorkspaceMutationHandlerBase } from '@colanode/client/handlers/mutations/workspace-mutation-handler-base'; +import { MutationHandler } from '@colanode/client/lib/types'; +import { MutationError, MutationErrorCode } from '@colanode/client/mutations'; +import { + WorkspaceMemberCreateMutationInput, + WorkspaceMemberCreateMutationOutput, +} from '@colanode/client/mutations/workspaces/workspace-member-create'; + +export class WorkspaceMemberCreateMutationHandler + extends WorkspaceMutationHandlerBase + implements MutationHandler +{ + async handleMutation( + input: WorkspaceMemberCreateMutationInput + ): Promise { + const workspace = this.getWorkspace(input.userId); + + if (!workspace.account.app.meta.localOnly) { + throw new MutationError( + MutationErrorCode.Unknown, + 'Workspace members can only be created in local-only mode.' + ); + } + + if (workspace.role !== 'owner' && workspace.role !== 'admin') { + throw new MutationError( + MutationErrorCode.Unknown, + 'You do not have permission to add workspace members.' + ); + } + + const name = input.name.trim(); + if (name.length < 1) { + throw new MutationError( + MutationErrorCode.Unknown, + 'Member name is required.' + ); + } + + try { + const user = await workspace.users.createLocalMember({ + name, + email: input.email, + avatar: input.avatar, + role: input.role, + }); + + return { user }; + } catch (error) { + throw new MutationError( + MutationErrorCode.Unknown, + error instanceof Error ? error.message : 'Failed to create member.' + ); + } + } +} diff --git a/packages/client/src/handlers/mutations/workspaces/workspace-member-delete.ts b/packages/client/src/handlers/mutations/workspaces/workspace-member-delete.ts new file mode 100644 index 000000000..5f9a596ae --- /dev/null +++ b/packages/client/src/handlers/mutations/workspaces/workspace-member-delete.ts @@ -0,0 +1,47 @@ +import { WorkspaceMutationHandlerBase } from '@colanode/client/handlers/mutations/workspace-mutation-handler-base'; +import { MutationHandler } from '@colanode/client/lib/types'; +import { MutationError, MutationErrorCode } from '@colanode/client/mutations'; +import { + WorkspaceMemberDeleteMutationInput, + WorkspaceMemberDeleteMutationOutput, +} from '@colanode/client/mutations/workspaces/workspace-member-delete'; + +export class WorkspaceMemberDeleteMutationHandler + extends WorkspaceMutationHandlerBase + implements MutationHandler +{ + async handleMutation( + input: WorkspaceMemberDeleteMutationInput + ): Promise { + const workspace = this.getWorkspace(input.userId); + + if (!workspace.account.app.meta.localOnly) { + throw new MutationError( + MutationErrorCode.Unknown, + 'Workspace members can only be removed in local-only mode.' + ); + } + + if (workspace.role !== 'owner' && workspace.role !== 'admin') { + throw new MutationError( + MutationErrorCode.Unknown, + 'You do not have permission to remove workspace members.' + ); + } + + try { + const user = await workspace.users.deleteLocalMember(input.memberId); + return { user }; + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to remove member.'; + + throw new MutationError( + message.includes('not found') + ? MutationErrorCode.UserNotFound + : MutationErrorCode.Unknown, + message + ); + } + } +} diff --git a/packages/client/src/handlers/mutations/workspaces/workspace-member-update.ts b/packages/client/src/handlers/mutations/workspaces/workspace-member-update.ts new file mode 100644 index 000000000..d3a650aec --- /dev/null +++ b/packages/client/src/handlers/mutations/workspaces/workspace-member-update.ts @@ -0,0 +1,58 @@ +import { WorkspaceMutationHandlerBase } from '@colanode/client/handlers/mutations/workspace-mutation-handler-base'; +import { MutationHandler } from '@colanode/client/lib/types'; +import { MutationError, MutationErrorCode } from '@colanode/client/mutations'; +import { + WorkspaceMemberUpdateMutationInput, + WorkspaceMemberUpdateMutationOutput, +} from '@colanode/client/mutations/workspaces/workspace-member-update'; + +export class WorkspaceMemberUpdateMutationHandler + extends WorkspaceMutationHandlerBase + implements MutationHandler +{ + async handleMutation( + input: WorkspaceMemberUpdateMutationInput + ): Promise { + const workspace = this.getWorkspace(input.userId); + + if (!workspace.account.app.meta.localOnly) { + throw new MutationError( + MutationErrorCode.Unknown, + 'Workspace members can only be updated in local-only mode.' + ); + } + + if (workspace.role !== 'owner' && workspace.role !== 'admin') { + throw new MutationError( + MutationErrorCode.Unknown, + 'You do not have permission to edit workspace members.' + ); + } + + if (input.name !== undefined && input.name.trim().length < 1) { + throw new MutationError( + MutationErrorCode.Unknown, + 'Member name is required.' + ); + } + + try { + const user = await workspace.users.updateLocalMember(input.memberId, { + name: input.name, + avatar: input.avatar, + }); + + return { user }; + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to update member.'; + + throw new MutationError( + message.includes('not found') + ? MutationErrorCode.UserNotFound + : MutationErrorCode.Unknown, + message + ); + } + } +} diff --git a/packages/client/src/mutations/index.ts b/packages/client/src/mutations/index.ts index 2ac97f1e2..82454399c 100644 --- a/packages/client/src/mutations/index.ts +++ b/packages/client/src/mutations/index.ts @@ -20,6 +20,9 @@ export * from './spaces/space-child-reorder'; export * from './workspaces/workspace-create'; export * from './workspaces/workspace-delete'; export * from './workspaces/workspace-update'; +export * from './workspaces/workspace-member-create'; +export * from './workspaces/workspace-member-update'; +export * from './workspaces/workspace-member-delete'; export * from './workspaces/local-sync-trigger'; export * from './workspaces/local-sync-set-paused'; export * from './files/temp-file-create'; diff --git a/packages/client/src/mutations/workspaces/workspace-member-create.ts b/packages/client/src/mutations/workspaces/workspace-member-create.ts new file mode 100644 index 000000000..f5363c619 --- /dev/null +++ b/packages/client/src/mutations/workspaces/workspace-member-create.ts @@ -0,0 +1,24 @@ +import { User } from '@colanode/client/types/users'; +import { WorkspaceRole } from '@colanode/core'; + +export type WorkspaceMemberCreateMutationInput = { + type: 'workspace.member.create'; + userId: string; + name: string; + email?: string | null; + avatar?: string | null; + role?: Extract; +}; + +export type WorkspaceMemberCreateMutationOutput = { + user: User; +}; + +declare module '@colanode/client/mutations' { + interface MutationMap { + 'workspace.member.create': { + input: WorkspaceMemberCreateMutationInput; + output: WorkspaceMemberCreateMutationOutput; + }; + } +} diff --git a/packages/client/src/mutations/workspaces/workspace-member-delete.ts b/packages/client/src/mutations/workspaces/workspace-member-delete.ts new file mode 100644 index 000000000..9d76e6f50 --- /dev/null +++ b/packages/client/src/mutations/workspaces/workspace-member-delete.ts @@ -0,0 +1,20 @@ +import { User } from '@colanode/client/types/users'; + +export type WorkspaceMemberDeleteMutationInput = { + type: 'workspace.member.delete'; + userId: string; + memberId: string; +}; + +export type WorkspaceMemberDeleteMutationOutput = { + user: User; +}; + +declare module '@colanode/client/mutations' { + interface MutationMap { + 'workspace.member.delete': { + input: WorkspaceMemberDeleteMutationInput; + output: WorkspaceMemberDeleteMutationOutput; + }; + } +} diff --git a/packages/client/src/mutations/workspaces/workspace-member-update.ts b/packages/client/src/mutations/workspaces/workspace-member-update.ts new file mode 100644 index 000000000..5f40e5fc0 --- /dev/null +++ b/packages/client/src/mutations/workspaces/workspace-member-update.ts @@ -0,0 +1,22 @@ +import { User } from '@colanode/client/types/users'; + +export type WorkspaceMemberUpdateMutationInput = { + type: 'workspace.member.update'; + userId: string; + memberId: string; + name?: string; + avatar?: string | null; +}; + +export type WorkspaceMemberUpdateMutationOutput = { + user: User; +}; + +declare module '@colanode/client/mutations' { + interface MutationMap { + 'workspace.member.update': { + input: WorkspaceMemberUpdateMutationInput; + output: WorkspaceMemberUpdateMutationOutput; + }; + } +} diff --git a/packages/client/src/services/workspaces/user-service.ts b/packages/client/src/services/workspaces/user-service.ts index ff28712ba..39032e5bc 100644 --- a/packages/client/src/services/workspaces/user-service.ts +++ b/packages/client/src/services/workspaces/user-service.ts @@ -3,10 +3,14 @@ import { mapUser } from '@colanode/client/lib/mappers'; import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service'; import { createDebugger, + generateId, + IdType, SyncUserData, UserOutput, UserStatus, + WorkspaceRole, } from '@colanode/core'; +import { User } from '@colanode/client/types/users'; const debug = createDebugger('desktop:service:user'); @@ -118,6 +122,164 @@ export class UserService { } } + private assertLocalOnlyMode(): void { + if (!this.workspace.account.app.meta.localOnly) { + throw new Error('Workspace members can only be managed in local-only mode.'); + } + } + + private publishUserCreated(user: User): void { + eventBus.publish({ + type: 'user.created', + workspace: { + workspaceId: this.workspace.workspaceId, + userId: this.workspace.userId, + accountId: this.workspace.accountId, + }, + user, + }); + } + + private publishUserUpdated(user: User): void { + eventBus.publish({ + type: 'user.updated', + workspace: { + workspaceId: this.workspace.workspaceId, + userId: this.workspace.userId, + accountId: this.workspace.accountId, + }, + user, + }); + } + + private publishUserDeleted(user: User): void { + eventBus.publish({ + type: 'user.deleted', + workspace: { + workspaceId: this.workspace.workspaceId, + userId: this.workspace.userId, + accountId: this.workspace.accountId, + }, + user, + }); + } + + public async createLocalMember(input: { + name: string; + email?: string | null; + avatar?: string | null; + role?: Extract; + }): Promise { + this.assertLocalOnlyMode(); + + const memberId = generateId(IdType.User); + const now = new Date().toISOString(); + const name = input.name.trim(); + const email = input.email?.trim() || `${memberId}@local.colanode`; + + debug( + `Creating local workspace member ${memberId} in workspace ${this.workspace.workspaceId}` + ); + + const createdUser = await this.workspace.database + .insertInto('users') + .returningAll() + .values({ + id: memberId, + email, + name, + avatar: input.avatar ?? null, + custom_name: null, + custom_avatar: null, + role: input.role ?? 'collaborator', + status: UserStatus.Active, + revision: '1', + created_at: now, + updated_at: null, + }) + .executeTakeFirstOrThrow(); + + const user = mapUser(createdUser); + this.publishUserCreated(user); + return user; + } + + public async updateLocalMember( + memberId: string, + input: { + name?: string; + avatar?: string | null; + } + ): Promise { + this.assertLocalOnlyMode(); + + const existing = await this.workspace.database + .selectFrom('users') + .selectAll() + .where('id', '=', memberId) + .executeTakeFirst(); + + if (!existing) { + throw new Error('Workspace member not found.'); + } + + if (existing.role === 'owner') { + throw new Error('The workspace owner cannot be edited here.'); + } + + const now = new Date().toISOString(); + const nextRevision = String(Number(existing.revision) + 1); + const name = + input.name !== undefined ? input.name.trim() : existing.name; + + const updatedUser = await this.workspace.database + .updateTable('users') + .set({ + name, + avatar: input.avatar !== undefined ? input.avatar : existing.avatar, + updated_at: now, + revision: nextRevision, + }) + .where('id', '=', memberId) + .returningAll() + .executeTakeFirstOrThrow(); + + const user = mapUser(updatedUser); + this.publishUserUpdated(user); + return user; + } + + public async deleteLocalMember(memberId: string): Promise { + this.assertLocalOnlyMode(); + + if (memberId === this.workspace.userId) { + throw new Error('You cannot remove yourself from the workspace.'); + } + + const existing = await this.workspace.database + .selectFrom('users') + .selectAll() + .where('id', '=', memberId) + .executeTakeFirst(); + + if (!existing) { + throw new Error('Workspace member not found.'); + } + + if (existing.role === 'owner' || existing.role === 'admin') { + throw new Error('This workspace member cannot be removed.'); + } + + await this.workspace.database + .deleteFrom('users') + .where('id', '=', memberId) + .execute(); + + const user = mapUser(existing); + this.publishUserDeleted(user); + return user; + } + public async syncServerUser(user: SyncUserData) { if (this.workspace.account.app.meta.localOnly) { return; diff --git a/packages/client/test/workspace-members.test.ts b/packages/client/test/workspace-members.test.ts new file mode 100644 index 000000000..fa74c0476 --- /dev/null +++ b/packages/client/test/workspace-members.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { WorkspaceDatabaseSchema } from '@colanode/client/databases'; +import { eventBus } from '@colanode/client/lib/event-bus'; +import { UserService } from '@colanode/client/services/workspaces/user-service'; +import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service'; +import { generateId, IdType, UserStatus } from '@colanode/core'; +import { Kysely } from 'kysely'; + +import { createWorkspaceDatabase } from './helpers/sqlite'; + +describe('workspace local members', () => { + let database: Kysely; + const ownerId = generateId(IdType.User); + const workspaceId = generateId(IdType.Workspace); + const accountId = generateId(IdType.Account); + + beforeEach(async () => { + database = await createWorkspaceDatabase(); + await database + .insertInto('users') + .values({ + id: ownerId, + email: 'owner@example.com', + name: 'Owner', + role: 'owner', + status: UserStatus.Active, + revision: '1', + created_at: new Date().toISOString(), + updated_at: null, + avatar: null, + custom_name: null, + custom_avatar: null, + }) + .execute(); + }); + + afterEach(async () => { + await database.destroy(); + }); + + const createWorkspace = () => { + const workspace = { + userId: ownerId, + workspaceId, + accountId, + role: 'owner', + database, + account: { app: { meta: { localOnly: true } } }, + } as unknown as WorkspaceService; + + workspace.users = new UserService(workspace); + return workspace; + }; + + it('creates a local member with synthetic email when email is omitted', async () => { + const workspace = createWorkspace(); + const publishSpy = vi.spyOn(eventBus, 'publish'); + + const member = await workspace.users.createLocalMember({ + name: 'Jane Doe', + }); + + expect(member.name).toBe('Jane Doe'); + expect(member.email).toBe(`${member.id}@local.colanode`); + expect(member.role).toBe('collaborator'); + + const row = await database + .selectFrom('users') + .selectAll() + .where('id', '=', member.id) + .executeTakeFirstOrThrow(); + + expect(row.name).toBe('Jane Doe'); + expect(publishSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'user.created', + user: expect.objectContaining({ id: member.id }), + }) + ); + }); + + it('updates a local member name', async () => { + const workspace = createWorkspace(); + const member = await workspace.users.createLocalMember({ + name: 'Jane Doe', + }); + + const updated = await workspace.users.updateLocalMember(member.id, { + name: 'Jane Smith', + }); + + expect(updated.name).toBe('Jane Smith'); + }); + + it('deletes a local member but not the owner', async () => { + const workspace = createWorkspace(); + const member = await workspace.users.createLocalMember({ + name: 'Guest User', + role: 'guest', + }); + + const deleted = await workspace.users.deleteLocalMember(member.id); + expect(deleted.id).toBe(member.id); + + const remaining = await database + .selectFrom('users') + .select('id') + .where('id', '=', member.id) + .executeTakeFirst(); + + expect(remaining).toBeUndefined(); + + await expect( + workspace.users.deleteLocalMember(ownerId) + ).rejects.toThrow('cannot remove yourself'); + }); + + it('rejects member management outside local-only mode', async () => { + const workspace = createWorkspace(); + workspace.account = { + app: { meta: { localOnly: false } }, + } as WorkspaceService['account']; + + await expect( + workspace.users.createLocalMember({ name: 'Jane Doe' }) + ).rejects.toThrow('local-only mode'); + }); +}); diff --git a/packages/ui/src/components/layouts/global-command-palette.tsx b/packages/ui/src/components/layouts/global-command-palette.tsx index 250b2a6aa..194020bc1 100644 --- a/packages/ui/src/components/layouts/global-command-palette.tsx +++ b/packages/ui/src/components/layouts/global-command-palette.tsx @@ -135,9 +135,6 @@ export const GlobalCommandPalette = ({ } if (item.type === 'user') { - if (localOnly) { - return; - } openLocation(`/workspace/${activeWorkspaceUserId}/users`); } else { openLocation(`/workspace/${activeWorkspaceUserId}/${item.id}`); @@ -230,17 +227,17 @@ export const GlobalCommandPalette = ({ Open notification center - {!localOnly && ( - { - openLocation(`/workspace/${activeWorkspaceUserId}/users`); - }} - > - - Open workspace users - - )} + { + openLocation(`/workspace/${activeWorkspaceUserId}/users`); + }} + > + + + {localOnly ? 'Open workspace members' : 'Open workspace users'} + + )} diff --git a/packages/ui/src/components/layouts/sidebars/sidebar-settings.tsx b/packages/ui/src/components/layouts/sidebars/sidebar-settings.tsx index 4c0b52bba..5bbd9f3b7 100644 --- a/packages/ui/src/components/layouts/sidebars/sidebar-settings.tsx +++ b/packages/ui/src/components/layouts/sidebars/sidebar-settings.tsx @@ -108,17 +108,15 @@ export const SidebarSettings = () => { )} - {!localOnly && ( - - {({ isActive }) => ( - - )} - - )} + + {({ isActive }) => ( + + )} + {app.type === 'desktop' && ( {({ isActive }) => ( diff --git a/packages/ui/src/components/workspaces/workspace-member-delete-button.tsx b/packages/ui/src/components/workspaces/workspace-member-delete-button.tsx new file mode 100644 index 000000000..c74944d65 --- /dev/null +++ b/packages/ui/src/components/workspaces/workspace-member-delete-button.tsx @@ -0,0 +1,80 @@ +import { Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +import { User } from '@colanode/client/types'; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@colanode/ui/components/ui/alert-dialog'; +import { Button } from '@colanode/ui/components/ui/button'; +import { Spinner } from '@colanode/ui/components/ui/spinner'; +import { useWorkspace } from '@colanode/ui/contexts/workspace'; +import { useMutation } from '@colanode/ui/hooks/use-mutation'; + +interface WorkspaceMemberDeleteButtonProps { + user: User; +} + +export const WorkspaceMemberDeleteButton = ({ + user, +}: WorkspaceMemberDeleteButtonProps) => { + const workspace = useWorkspace(); + const { mutate, isPending } = useMutation(); + const [open, setOpen] = useState(false); + + return ( + <> + + + + + Remove member? + + {user.name ?? 'This member'} will be removed from the workspace. + Existing references in collaborator fields may show unknown users. + + + + Cancel + + + + + + ); +}; diff --git a/packages/ui/src/components/workspaces/workspace-member-edit-dialog.tsx b/packages/ui/src/components/workspaces/workspace-member-edit-dialog.tsx new file mode 100644 index 000000000..87c9f739b --- /dev/null +++ b/packages/ui/src/components/workspaces/workspace-member-edit-dialog.tsx @@ -0,0 +1,109 @@ +import { Pencil } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +import { User } from '@colanode/client/types'; +import { Button } from '@colanode/ui/components/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@colanode/ui/components/ui/dialog'; +import { Field, FieldLabel } from '@colanode/ui/components/ui/field'; +import { Input } from '@colanode/ui/components/ui/input'; +import { Spinner } from '@colanode/ui/components/ui/spinner'; +import { useWorkspace } from '@colanode/ui/contexts/workspace'; +import { useMutation } from '@colanode/ui/hooks/use-mutation'; + +interface WorkspaceMemberEditDialogProps { + user: User; +} + +export const WorkspaceMemberEditDialog = ({ + user, +}: WorkspaceMemberEditDialogProps) => { + const workspace = useWorkspace(); + const { mutate, isPending } = useMutation(); + const [open, setOpen] = useState(false); + const [name, setName] = useState(user.name ?? ''); + + const resetForm = () => { + setName(user.name ?? ''); + }; + + return ( + <> + + { + setOpen(nextOpen); + if (!nextOpen) { + resetForm(); + } + }} + > + + + Edit member + +
+ + Name + setName(event.target.value)} + disabled={isPending} + /> + +
+ + + + +
+
+ + ); +}; diff --git a/packages/ui/src/components/workspaces/workspace-user-invite.tsx b/packages/ui/src/components/workspaces/workspace-user-invite.tsx index 33a154f06..4d7a73d17 100644 --- a/packages/ui/src/components/workspaces/workspace-user-invite.tsx +++ b/packages/ui/src/components/workspaces/workspace-user-invite.tsx @@ -1,9 +1,144 @@ +import { Check, ChevronDown } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +import { WorkspaceRole } from '@colanode/core'; +import { Button } from '@colanode/ui/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@colanode/ui/components/ui/dropdown-menu'; +import { Field, FieldLabel } from '@colanode/ui/components/ui/field'; +import { Input } from '@colanode/ui/components/ui/input'; +import { Spinner } from '@colanode/ui/components/ui/spinner'; +import { useWorkspace } from '@colanode/ui/contexts/workspace'; +import { useMutation } from '@colanode/ui/hooks/use-mutation'; +import { isLocalOnlyMode } from '@colanode/ui/routes/utils'; + +type LocalMemberRole = Extract; + +const memberRoles: { name: string; value: LocalMemberRole; description: string }[] = + [ + { + name: 'Collaborator', + value: 'collaborator', + description: 'Can contribute in content', + }, + { + name: 'Guest', + value: 'guest', + description: 'Can view content', + }, + ]; + export const WorkspaceUserInvite = () => { + const workspace = useWorkspace(); + const { mutate, isPending } = useMutation(); + const localOnly = isLocalOnlyMode(); + + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [role, setRole] = useState('collaborator'); + + if (!localOnly) { + return ( +
+

+ Invite is unavailable in LOCAL_ONLY mode. +

+
+ ); + } + + const currentRole = memberRoles.find((item) => item.value === role); + return ( -
-

- Invite is unavailable in LOCAL_ONLY mode. -

+
+
+ + Name + setName(event.target.value)} + placeholder="Jane Doe" + disabled={isPending} + /> + + + Email (optional) + setEmail(event.target.value)} + placeholder="jane@example.com" + disabled={isPending} + /> + +
+
+ + Role + + + + + + {memberRoles.map((item) => ( + setRole(item.value)} + > +
+
+ {item.name} + + {item.description} + +
+ {role === item.value && } +
+
+ ))} +
+
+
+ +
); }; diff --git a/packages/ui/src/components/workspaces/workspace-users-breadcrumb.tsx b/packages/ui/src/components/workspaces/workspace-users-breadcrumb.tsx index fa8cf53a6..ec63cf5ce 100644 --- a/packages/ui/src/components/workspaces/workspace-users-breadcrumb.tsx +++ b/packages/ui/src/components/workspaces/workspace-users-breadcrumb.tsx @@ -1,6 +1,15 @@ import { BreadcrumbItem } from '@colanode/ui/components/layouts/containers/breadcrumb-item'; import { defaultIcons } from '@colanode/ui/lib/assets'; +import { isLocalOnlyMode } from '@colanode/ui/routes/utils'; export const WorkspaceUsersBreadcrumb = () => { - return ; + const localOnly = isLocalOnlyMode(); + + return ( + + ); }; diff --git a/packages/ui/src/components/workspaces/workspace-users-container.tsx b/packages/ui/src/components/workspaces/workspace-users-container.tsx index fe8445b47..ab9f4abc6 100644 --- a/packages/ui/src/components/workspaces/workspace-users-container.tsx +++ b/packages/ui/src/components/workspaces/workspace-users-container.tsx @@ -5,15 +5,32 @@ import { WorkspaceRole } from '@colanode/core'; import { Avatar } from '@colanode/ui/components/avatars/avatar'; import { Container } from '@colanode/ui/components/layouts/containers/container'; import { Separator } from '@colanode/ui/components/ui/separator'; +import { WorkspaceMemberDeleteButton } from '@colanode/ui/components/workspaces/workspace-member-delete-button'; +import { WorkspaceMemberEditDialog } from '@colanode/ui/components/workspaces/workspace-member-edit-dialog'; import { WorkspaceUserInvite } from '@colanode/ui/components/workspaces/workspace-user-invite'; import { WorkspaceUserRoleDropdown } from '@colanode/ui/components/workspaces/workspace-user-role-dropdown'; import { WorkspaceUsersBreadcrumb } from '@colanode/ui/components/workspaces/workspace-users-breadcrumb'; import { useWorkspace } from '@colanode/ui/contexts/workspace'; +import { isLocalOnlyMode } from '@colanode/ui/routes/utils'; const USERS_PER_PAGE = 50; +const formatMemberEmail = (email: string | null | undefined) => { + if (!email || email.endsWith('@local.colanode')) { + return null; + } + + return email; +}; + +const canManageLocalMember = (role: WorkspaceRole) => { + return role === 'collaborator' || role === 'guest'; +}; + export const WorkspaceUsersContainer = () => { const workspace = useWorkspace(); + const localOnly = isLocalOnlyMode(); + const membersLabel = localOnly ? 'Members' : 'Users'; const canEditUsers = workspace.role === 'owner' || workspace.role === 'admin'; const usersQuery = useLiveInfiniteQuery( @@ -37,7 +54,9 @@ export const WorkspaceUsersContainer = () => { {canEditUsers && (
-

Invite

+

+ {localOnly ? 'Add member' : 'Invite'} +

@@ -46,18 +65,28 @@ export const WorkspaceUsersContainer = () => {
-

Users

+

+ {membersLabel} +

- The list of all users on the workspace + {localOnly + ? 'People in this workspace for assignments and collaborator fields' + : 'The list of all users on the workspace'}

{users.map((user) => { const name: string = user.name ?? 'Unknown'; - const email: string = user.email ?? ' '; + const email = formatMemberEmail(user.email); const avatar: string | null | undefined = user.avatar; const role: WorkspaceRole = user.role; + const isCurrentUser = user.id === workspace.userId; + const showMemberActions = + localOnly && + canEditUsers && + canManageLocalMember(role) && + !isCurrentUser; if (!role) { return null; @@ -67,9 +96,24 @@ export const WorkspaceUsersContainer = () => {
-

{name}

-

{email}

+

+ {name} + {isCurrentUser && ( + + (You) + + )} +

+ {email && ( +

{email}

+ )}
+ {showMemberActions && ( +
+ + +
+ )} { - return ; + const localOnly = isLocalOnlyMode(); + + return ( + + ); }; From 50052f8eac48a516f9bcb1159bb27cb979eeff79 Mon Sep 17 00:00:00 2001 From: barry Date: Wed, 8 Jul 2026 17:13:40 +0800 Subject: [PATCH 08/20] Harden web Docker packaging for remote deployment. Fix apps/web Docker build paths for repo-root context, add required cross-origin isolation headers in nginx, and document GHCR-friendly deployment steps. Co-authored-by: Cursor --- .dockerignore | 11 +++++++ apps/web/Dockerfile | 31 ++++++++++++-------- docs/web-docker-deploy.md | 62 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 13 deletions(-) create mode 100644 .dockerignore create mode 100644 docs/web-docker-deploy.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..849560287 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +**/node_modules +dist +**/dist +.turbo +.git +.github +*.log +coverage +**/coverage +.DS_Store diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 01ad4db01..156c78eb3 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -6,28 +6,28 @@ WORKDIR /app COPY package.json package-lock.json ./ # Copy scripts -COPY ../../scripts scripts +COPY scripts scripts # Copy assets -COPY ../../assets assets +COPY assets assets # Copy all package.json files first -COPY ../../packages/core/package.json packages/core/package.json -COPY ../../packages/crdt/package.json packages/crdt/package.json -COPY ../../packages/client/package.json packages/client/package.json -COPY ../../packages/ui/package.json packages/ui/package.json -COPY ../../apps/web/package.json apps/web/package.json +COPY packages/core/package.json packages/core/package.json +COPY packages/crdt/package.json packages/crdt/package.json +COPY packages/client/package.json packages/client/package.json +COPY packages/ui/package.json packages/ui/package.json +COPY apps/web/package.json apps/web/package.json # Install dependencies RUN npm ci # Copy source files -COPY ../../packages/core packages/core -COPY ../../packages/crdt packages/crdt -COPY ../../packages/client packages/client -COPY ../../packages/ui packages/ui -COPY ../../apps/web apps/web -COPY ../../tsconfig.base.json ./ +COPY packages/core packages/core +COPY packages/crdt packages/crdt +COPY packages/client packages/client +COPY packages/ui packages/ui +COPY apps/web apps/web +COPY tsconfig.base.json ./ # Build packages RUN npm run build -w @colanode/core && \ @@ -49,6 +49,11 @@ server { root /usr/share/nginx/html; index index.html; + # Required by sqlite-wasm / SharedArrayBuffer runtime + add_header Cross-Origin-Opener-Policy "same-origin" always; + add_header Cross-Origin-Embedder-Policy "require-corp" always; + add_header Cross-Origin-Resource-Policy "same-origin" always; + location / { try_files $uri $uri/ /index.html; } diff --git a/docs/web-docker-deploy.md b/docs/web-docker-deploy.md new file mode 100644 index 000000000..96b48013e --- /dev/null +++ b/docs/web-docker-deploy.md @@ -0,0 +1,62 @@ +# Web Docker deployment + +This document describes how to package and deploy `apps/web` to a remote Linux server using Docker. + +## Build image locally + +From repository root: + +```bash +docker build -f apps/web/Dockerfile -t colanode-web:latest . +``` + +Run locally to verify: + +```bash +docker run --rm -p 8080:80 colanode-web:latest +``` + +Open `http://localhost:8080`. + +## Push image to registry + +Use your own registry/repository tag: + +```bash +docker tag colanode-web:latest ghcr.io//colanode-web: +docker push ghcr.io//colanode-web: +``` + +## Deploy on remote server + +SSH into your server, then: + +```bash +docker pull ghcr.io//colanode-web: +docker rm -f colanode-web 2>/dev/null || true +docker run -d \ + --name colanode-web \ + --restart unless-stopped \ + -p 80:80 \ + ghcr.io//colanode-web: +``` + +## Reverse proxy (optional) + +If your server already uses Nginx/Caddy/Traefik, run this container on an internal port instead: + +```bash +docker run -d \ + --name colanode-web \ + --restart unless-stopped \ + -p 127.0.0.1:8080:80 \ + ghcr.io//colanode-web: +``` + +Then proxy your domain to `127.0.0.1:8080`. + +## Notes + +- The image serves a static Vite build via Nginx. +- SPA routing is enabled (`try_files ... /index.html`). +- Cross-origin isolation headers are included, required for sqlite-wasm runtime behavior. From 7a58b81880cd3b5859cdcead8557c7ac5e3bbcf7 Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 9 Jul 2026 16:05:36 +0800 Subject: [PATCH 09/20] Implement server synchronization features and enhance workspace management. This commit introduces server sync capabilities, allowing users to join and delete remote workspaces via the Colanode sync API. It updates the UI to reflect server sync status and modifies the Docker configuration for improved deployment. Additionally, it enhances the workspace creation process and integrates server sync checks into the application logic. Co-authored-by: Cursor --- apps/desktop/src/main.ts | 1 + apps/desktop/src/preload.ts | 4 + apps/server/.data/server.sqlite | Bin 0 -> 4096 bytes apps/server/.data/server.sqlite-shm | Bin 0 -> 32768 bytes apps/server/.data/server.sqlite-wal | Bin 0 -> 2084752 bytes apps/server/Dockerfile | 36 + apps/server/README.md | 71 ++ apps/server/package.json | 28 + apps/server/src/app.ts | 70 ++ apps/server/src/config.ts | 64 + apps/server/src/db/database.ts | 23 + apps/server/src/db/schema.ts | 64 + apps/server/src/files/routes.ts | 166 +++ apps/server/src/index.ts | 19 + apps/server/src/storage/factory.ts | 35 + apps/server/src/storage/local-fs-provider.ts | 119 ++ apps/server/src/storage/path-utils.ts | 32 + apps/server/src/storage/s3-provider.ts | 169 +++ apps/server/src/storage/types.ts | 47 + apps/server/src/sync/realtime-hub.ts | 68 + apps/server/src/sync/repository-types.ts | 69 + apps/server/src/sync/routes.ts | 145 +++ apps/server/src/sync/sqlite-repository.ts | 362 ++++++ apps/server/src/sync/types.ts | 56 + apps/server/src/sync/ws-routes.ts | 24 + apps/server/src/types/api.ts | 40 + apps/server/test/realtime-hub.test.ts | 76 ++ apps/server/test/sqlite-repository.test.ts | 105 ++ apps/server/tsconfig.json | 12 + apps/web/Dockerfile | 30 + apps/web/src/lib/types.ts | 10 + apps/web/src/main.tsx | 11 +- apps/web/src/workers/dedicated.ts | 76 +- docker-compose.yml | 41 + docs/roadmap.md | 1 + docs/server-sync.md | 121 ++ package-lock.json | 1113 ++++++++++++++++- .../client/src/lib/list-server-workspaces.ts | 14 + packages/client/src/lib/op-log-export.ts | 263 ++++ packages/client/src/lib/op-log-import.ts | 110 ++ packages/client/src/lib/sync-state.ts | 31 + packages/client/src/services/app-meta.ts | 7 + packages/client/src/services/app-service.ts | 41 +- .../workspaces/server-sync-api-client.ts | 181 +++ .../workspaces/server-sync-realtime.ts | 134 ++ .../workspaces/server-sync-service.ts | 523 ++++++++ .../services/workspaces/workspace-service.ts | 26 +- packages/core/src/index.ts | 1 + packages/core/src/types/server-sync.ts | 76 ++ .../workspaces/workspace-create.tsx | 23 +- .../workspaces/workspace-local-sync.tsx | 163 ++- .../workspaces/workspace-remote-format.ts | 44 + .../workspaces/workspace-server-join.tsx | 144 +++ packages/ui/src/routes/utils.tsx | 14 + packages/ui/src/window.ts | 5 + 55 files changed, 5035 insertions(+), 73 deletions(-) create mode 100644 apps/server/.data/server.sqlite create mode 100644 apps/server/.data/server.sqlite-shm create mode 100644 apps/server/.data/server.sqlite-wal create mode 100644 apps/server/Dockerfile create mode 100644 apps/server/README.md create mode 100644 apps/server/package.json create mode 100644 apps/server/src/app.ts create mode 100644 apps/server/src/config.ts create mode 100644 apps/server/src/db/database.ts create mode 100644 apps/server/src/db/schema.ts create mode 100644 apps/server/src/files/routes.ts create mode 100644 apps/server/src/index.ts create mode 100644 apps/server/src/storage/factory.ts create mode 100644 apps/server/src/storage/local-fs-provider.ts create mode 100644 apps/server/src/storage/path-utils.ts create mode 100644 apps/server/src/storage/s3-provider.ts create mode 100644 apps/server/src/storage/types.ts create mode 100644 apps/server/src/sync/realtime-hub.ts create mode 100644 apps/server/src/sync/repository-types.ts create mode 100644 apps/server/src/sync/routes.ts create mode 100644 apps/server/src/sync/sqlite-repository.ts create mode 100644 apps/server/src/sync/types.ts create mode 100644 apps/server/src/sync/ws-routes.ts create mode 100644 apps/server/src/types/api.ts create mode 100644 apps/server/test/realtime-hub.test.ts create mode 100644 apps/server/test/sqlite-repository.test.ts create mode 100644 apps/server/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 docs/server-sync.md create mode 100644 packages/client/src/lib/list-server-workspaces.ts create mode 100644 packages/client/src/lib/op-log-export.ts create mode 100644 packages/client/src/lib/op-log-import.ts create mode 100644 packages/client/src/lib/sync-state.ts create mode 100644 packages/client/src/services/workspaces/server-sync-api-client.ts create mode 100644 packages/client/src/services/workspaces/server-sync-realtime.ts create mode 100644 packages/client/src/services/workspaces/server-sync-service.ts create mode 100644 packages/core/src/types/server-sync.ts create mode 100644 packages/ui/src/components/workspaces/workspace-remote-format.ts create mode 100644 packages/ui/src/components/workspaces/workspace-server-join.tsx diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b3ff2e809..6d99ae8a4 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1135,5 +1135,6 @@ ipcMain.handle( manifest ); await workspace.cloudSync?.importRemote(); + return { userId: workspace.userId }; } ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 7df2f1e61..d05e4028e 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -143,6 +143,10 @@ contextBridge.exposeInMainWorld('colanode', { return ipcRenderer.invoke('local-sync-join-workspace', params); }, + localSyncDeleteRemoteWorkspace: async () => { + throw new Error('Remote workspace delete is only available on web for now'); + }, + onNotificationNavigate: (callback) => { const handler = (_: unknown, target: NotificationNavigateTarget) => callback(target); diff --git a/apps/server/.data/server.sqlite b/apps/server/.data/server.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..4a3492ca9ee32d110ce3484c72386f9ff8651a2c GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYC*>iWj6^fNV2HHI9bB nXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDP#6LLGPMVA literal 0 HcmV?d00001 diff --git a/apps/server/.data/server.sqlite-shm b/apps/server/.data/server.sqlite-shm new file mode 100644 index 0000000000000000000000000000000000000000..e3f502fc8b97120ba1fb86765b34498ecad9667f GIT binary patch literal 32768 zcmeI5XOIHubsssZlC@8@Im_ZDn zs6-=zSulHgcc*;yoCl5U?5(Z3UG?qT?dj=0@67(4zDawli$*pAk$k`L$0Gi|XUNXF z2S#t5UOaE9K}|OQzOwYF_4{Y6shD=nKeMKO zrs|XIsek84{S5BWdac#EoFo^?P4bYuEsMjm((Nm zN!VPi(Q6GztQVS3luOh{>l>1=>*{NuNoyLB#-s^pN}7@8qy=e7T9MYI4QWf-k@lnm z=}68bXOXi>*gT!3*UlmF-z!lrtWT`&PV99q=}fwiuB033PI{2@$oZrvxqw_qdXe6w z59v$#k^W==xriKVrrxV{1Ifi?5V?e0N(Ph5$mQe;awQo;t|CLpFfyE6O-7KBWE6?@ zL;GtkOov{(CZt|-N2|X!N7f|M|@6uXfCXW{{LPV*CQ;aJ+w}HTu*KY z`3&nH+&3nqKIjwgv)0DuRFCGu`m6VPZ7hkuCqAcIG^cZwME$hpMiSdk=V&g`eX;-l zLG3u!k0%qzL^6p?CR4~wWGcCt+(K?8)5vXPI+;OclH180-$}jG`~&1cl9^;7L0)sQegxOEGOv2|`a>kh>wQPd>HMHpd&K*q_dXm`6ZAWt zT%b327P0dJ&2u>;{(3M=ugxR#<7yJ=4eA%LUT5j^)$gJA2f3iP_SIbAgZ7NCU&vhS zchH_;K4~wlji0HxME&*JBI3h4;2rP|cn7=#-U07`cfdQ~9qnrOPO79^TinQzOkvqbUU6 zInE|IxGf?NH$+t9R)XGqOMg7yYJO7Qkx%4XzR-FKl28jTNgH&qQ@KrkKFD{-^V8{-@+k z*(N*WQ~6B3mha>jd0BoJAUpD)Fe;!bYNG*~qAkuwH(Z|%E;cvYG%6j`98HYA=k7Af znuqttZCLHlE!{%mGBR@)Pz~;w>BfC8eYulmBKKX);2w+R@|L_SAIdKITn@eD=j3@9c)~B(v zEzLGvo5qvPgHsD1Io=!6xW2r(-$jm{(ydP}BRluM)sjxqpF6lFb7#@h^1kemA0-Ql z;S6-Zd6GNt5il^bi}#1 z5OeV+w&7EJjc@TwL^>;)^SsFy=CHn}@$@u8gLzjk)c3Ou=;AiCLJ3N3ax6 zVGUlyTX+{cunPz99S%qO)EAqTMpIbH+;&i;J{((y?x%DadCkLLGgn4a*p_=yr*Mzt zN;xQh$;l{=7U+qG@H)PXdxkS`7w(J8MWZRa>yq$vx@9YyCtc#uB^5cwtTdX!{N}c= zn=7L!Y{yN-J-C5*05=0qt1thRHTJ)$bj=hnPkTeQnrovdY;SJ*JYK@v4xj5Xv(jh^ zt0r%YxAY$D&?VKUTdG|~LG$qK^0DkQSEqep2Xo7pu>~LE8)r}UakJ8B3Uzb3EiQBB zQHs8C$(z+}TBE2cWNx}&zLG=c>a;KHn0!8irSB_v54-U_jyQU@Psno?nUU4#Zg*Q; z?#Q7)-Bg|*pr-}6iX`*kpXE3C+gzRYgH4z17{U2qeoVg;VYGk5_Tu@C$4S%hxt*-y66)6`tX^Dc*T zc75~wA&#BW^Nn0aQSxZF#Cpdv8=L13clbP2;j literal 0 HcmV?d00001 diff --git a/apps/server/.data/server.sqlite-wal b/apps/server/.data/server.sqlite-wal new file mode 100644 index 0000000000000000000000000000000000000000..b8885f06e7868fe1b0eed1bd34685d695df70874 GIT binary patch literal 2084752 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/files/...`) + +Restarting the server preserves sync history because metadata is stored in SQLite. + +## Realtime sync + +Clients can subscribe to `GET /v1/sync/ws?workspaceId=...&deviceId=...` (WebSocket). When another device pushes new op-log records, the server broadcasts: + +```json +{ "type": "sync.updated", "workspaceId": "...", "fromDeviceId": "...", "watermarkSeq": 123 } +``` + +The web client uses this to pull immediately instead of waiting for the 60s fallback poll. Local edits trigger an export within ~500ms. + +## Web client (server sync mode) + +Set in `apps/web/.env.local`: + +```bash +VITE_SERVER_SYNC_URL=http://localhost:4400 +``` + +Then run web + server. The web worker will push/pull workspace op-log through the sync API. diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 000000000..54b289f92 --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@colanode/server", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "compile": "tsc --noEmit", + "dev": "tsx src/index.ts", + "start": "node dist/index.js", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.896.0", + "@aws-sdk/s3-request-presigner": "^3.896.0", + "@fastify/websocket": "^11.3.0", + "better-sqlite3": "^12.11.1", + "fastify": "^5.6.1", + "zod": "^4.1.11" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/ws": "^8.18.1", + "tsx": "^4.21.0", + "vitest": "^4.1.2" + } +} diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts new file mode 100644 index 000000000..f9d0f5b7c --- /dev/null +++ b/apps/server/src/app.ts @@ -0,0 +1,70 @@ +import Fastify from 'fastify'; +import websocket from '@fastify/websocket'; + +import { AppConfig } from './config.js'; +import { createDatabase } from './db/database.js'; +import { registerFileRoutes } from './files/routes.js'; +import { createStorageProvider } from './storage/factory.js'; +import { SyncRealtimeHub } from './sync/realtime-hub.js'; +import { SqliteSyncRepository } from './sync/sqlite-repository.js'; +import { registerSyncRoutes } from './sync/routes.js'; +import { registerSyncWsRoutes } from './sync/ws-routes.js'; +import { ApiError, ApiErrorCode, failure } from './types/api.js'; + +export const buildApp = async (config: AppConfig) => { + const app = Fastify({ + logger: true, + }); + + await app.register(websocket); + + app.addHook('onRequest', async (request, reply) => { + reply.header('Access-Control-Allow-Origin', '*'); + reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + reply.header('Access-Control-Allow-Headers', 'Content-Type'); + + if (request.method === 'OPTIONS') { + return reply.status(204).send(); + } + }); + + const database = await createDatabase(config.dbPath); + const storage = createStorageProvider(config.storage); + await storage.init(); + + const syncRepository = new SqliteSyncRepository(database); + const syncRealtimeHub = new SyncRealtimeHub(); + registerSyncRoutes(app, syncRepository, storage, syncRealtimeHub); + registerSyncWsRoutes(app, syncRealtimeHub); + registerFileRoutes(app, storage, database); + + app.get('/healthz', async (_request, reply) => { + return reply.send({ + ok: true, + dbPath: config.dbPath, + storage: storage.name, + timestamp: new Date().toISOString(), + }); + }); + + app.setErrorHandler((error, request, reply) => { + if (error instanceof ApiError) { + return reply + .status(error.statusCode) + .send(failure(request.id, error.code, error.message)); + } + + request.log.error(error); + return reply + .status(500) + .send( + failure( + request.id, + ApiErrorCode.SyncInternalError, + 'Internal server error.' + ) + ); + }); + + return app; +}; diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts new file mode 100644 index 000000000..5a5e5457f --- /dev/null +++ b/apps/server/src/config.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; + +import { StorageConfig } from './storage/factory.js'; + +const baseSchema = z.object({ + PORT: z.coerce.number().int().positive().default(4400), + SYNC_DB_PATH: z.string().default('./.data/server.sqlite'), + SYNC_STORAGE_TYPE: z.enum(['local', 's3']).default('local'), + SYNC_STORAGE_LOCAL_ROOT: z.string().default('./.data/storage'), + SYNC_STORAGE_S3_BUCKET: z.string().optional(), + SYNC_STORAGE_S3_REGION: z.string().default('us-east-1'), + SYNC_STORAGE_S3_ENDPOINT: z.string().optional(), + SYNC_STORAGE_S3_ACCESS_KEY_ID: z.string().optional(), + SYNC_STORAGE_S3_SECRET_ACCESS_KEY: z.string().optional(), + SYNC_STORAGE_S3_FORCE_PATH_STYLE: z + .string() + .optional() + .transform((value) => value === 'true'), +}); + +export interface AppConfig { + port: number; + dbPath: string; + storage: StorageConfig; +} + +export const loadConfig = (): AppConfig => { + const env = baseSchema.parse(process.env); + + if (env.SYNC_STORAGE_TYPE === 'local') { + return { + port: env.PORT, + dbPath: env.SYNC_DB_PATH, + storage: { + type: 'local', + rootDir: env.SYNC_STORAGE_LOCAL_ROOT, + }, + }; + } + + if ( + !env.SYNC_STORAGE_S3_BUCKET || + !env.SYNC_STORAGE_S3_ACCESS_KEY_ID || + !env.SYNC_STORAGE_S3_SECRET_ACCESS_KEY + ) { + throw new Error( + 'Missing required S3 configuration for SYNC_STORAGE_TYPE=s3.' + ); + } + + return { + port: env.PORT, + dbPath: env.SYNC_DB_PATH, + storage: { + type: 's3', + bucket: env.SYNC_STORAGE_S3_BUCKET, + region: env.SYNC_STORAGE_S3_REGION, + endpoint: env.SYNC_STORAGE_S3_ENDPOINT, + accessKeyId: env.SYNC_STORAGE_S3_ACCESS_KEY_ID, + secretAccessKey: env.SYNC_STORAGE_S3_SECRET_ACCESS_KEY, + forcePathStyle: env.SYNC_STORAGE_S3_FORCE_PATH_STYLE, + }, + }; +}; diff --git a/apps/server/src/db/database.ts b/apps/server/src/db/database.ts new file mode 100644 index 000000000..db8655769 --- /dev/null +++ b/apps/server/src/db/database.ts @@ -0,0 +1,23 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import Database from 'better-sqlite3'; + +import { SERVER_DB_MIGRATIONS } from './schema.js'; + +export type ServerDatabase = Database.Database; + +export const createDatabase = async (dbPath: string): Promise => { + const directory = path.dirname(dbPath); + await fs.mkdir(directory, { recursive: true }); + + const database = new Database(dbPath); + database.pragma('journal_mode = WAL'); + database.pragma('foreign_keys = OFF'); + + for (const migration of SERVER_DB_MIGRATIONS) { + database.exec(migration); + } + + return database; +}; diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts new file mode 100644 index 000000000..201d95e85 --- /dev/null +++ b/apps/server/src/db/schema.ts @@ -0,0 +1,64 @@ +export const SERVER_DB_MIGRATIONS = [ + ` + CREATE TABLE IF NOT EXISTS sync_batches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL, + device_id TEXT NOT NULL, + batch_id TEXT NOT NULL, + accepted_count INTEGER NOT NULL DEFAULT 0, + duplicate_count INTEGER NOT NULL DEFAULT 0, + first_server_seq INTEGER, + last_server_seq INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `, + ` + CREATE UNIQUE INDEX IF NOT EXISTS uk_sync_batches_ws_dev_batch + ON sync_batches(workspace_id, device_id, batch_id); + `, + ` + CREATE TABLE IF NOT EXISTS sync_records ( + server_seq INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL, + from_device_id TEXT NOT NULL, + kind TEXT NOT NULL, + record_id TEXT NOT NULL, + root_id TEXT, + happened_at TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `, + ` + CREATE INDEX IF NOT EXISTS idx_sync_records_ws_seq + ON sync_records(workspace_id, server_seq); + `, + ` + CREATE TABLE IF NOT EXISTS file_objects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL, + file_id TEXT NOT NULL, + extension TEXT NOT NULL, + object_key TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + sha256 TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'ready', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `, + ` + CREATE UNIQUE INDEX IF NOT EXISTS uk_file_objects_ws_file + ON file_objects(workspace_id, file_id); + `, + ` + CREATE TABLE IF NOT EXISTS workspace_manifests ( + workspace_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + created_at TEXT NOT NULL, + created_by_device_id TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `, +] as const; diff --git a/apps/server/src/files/routes.ts b/apps/server/src/files/routes.ts new file mode 100644 index 000000000..d74618a33 --- /dev/null +++ b/apps/server/src/files/routes.ts @@ -0,0 +1,166 @@ +import { createHash } from 'node:crypto'; + +import { FastifyInstance } from 'fastify'; +import { z } from 'zod'; + +import type { ServerDatabase } from '../db/database.js'; +import { StorageProvider } from '../storage/types.js'; +import { ApiError, ApiErrorCode, success } from '../types/api.js'; + +const uploadSchema = z.object({ + workspaceId: z.string().min(1), + fileId: z.string().min(1), + extension: z.string().min(1), + contentType: z.string().optional(), + sha256: z.string().min(1), + bodyBase64: z.string().min(1), +}); + +const downloadSchema = z.object({ + workspaceId: z.string().min(1), + fileId: z.string().min(1), + extension: z.string().min(1), +}); + +const makeFileKey = ( + workspaceId: string, + fileId: string, + extension: string +): string => { + const ext = extension.startsWith('.') ? extension : `.${extension}`; + return `workspaces/${workspaceId}/files/${fileId}${ext}`; +}; + +const upsertFileObject = ( + database: ServerDatabase, + input: { + workspaceId: string; + fileId: string; + extension: string; + objectKey: string; + sizeBytes: number; + sha256: string; + } +) => { + database + .prepare( + ` + INSERT INTO file_objects ( + workspace_id, + file_id, + extension, + object_key, + size_bytes, + sha256, + status, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, 'ready', datetime('now')) + ON CONFLICT(workspace_id, file_id) DO UPDATE SET + extension = excluded.extension, + object_key = excluded.object_key, + size_bytes = excluded.size_bytes, + sha256 = excluded.sha256, + status = 'ready', + updated_at = datetime('now') + ` + ) + .run( + input.workspaceId, + input.fileId, + input.extension, + input.objectKey, + input.sizeBytes, + input.sha256 + ); +}; + +export const registerFileRoutes = ( + app: FastifyInstance, + storage: StorageProvider, + database: ServerDatabase +): void => { + app.post('/v1/files/upload', async (request, reply) => { + const requestId = request.id; + const parsed = uploadSchema.safeParse(request.body); + if (!parsed.success) { + throw new ApiError( + ApiErrorCode.SyncInvalidPayload, + parsed.error.message, + 400 + ); + } + + const body = Buffer.from(parsed.data.bodyBase64, 'base64'); + const checksum = createHash('sha256').update(body).digest('hex'); + if (checksum !== parsed.data.sha256.toLowerCase()) { + throw new ApiError( + ApiErrorCode.FileChecksumMismatch, + 'File checksum mismatch.', + 400 + ); + } + + const key = makeFileKey( + parsed.data.workspaceId, + parsed.data.fileId, + parsed.data.extension + ); + + await storage.putObject({ + key, + body, + contentType: parsed.data.contentType, + metadata: { + sha256: checksum, + }, + }); + + upsertFileObject(database, { + workspaceId: parsed.data.workspaceId, + fileId: parsed.data.fileId, + extension: parsed.data.extension, + objectKey: key, + sizeBytes: body.length, + sha256: checksum, + }); + + return reply.send( + success(requestId, { + key, + size: body.length, + sha256: checksum, + }) + ); + }); + + app.get('/v1/files/download', async (request, reply) => { + const requestId = request.id; + const parsed = downloadSchema.safeParse(request.query); + if (!parsed.success) { + throw new ApiError( + ApiErrorCode.SyncInvalidPayload, + parsed.error.message, + 400 + ); + } + + const key = makeFileKey( + parsed.data.workspaceId, + parsed.data.fileId, + parsed.data.extension + ); + + if (!(await storage.exists(key))) { + throw new ApiError(ApiErrorCode.FileNotFound, 'File not found.', 404); + } + + const body = await storage.getObject(key); + return reply.send( + success(requestId, { + key, + size: body.length, + bodyBase64: body.toString('base64'), + }) + ); + }); +}; diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 000000000..3433777ef --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,19 @@ +import { buildApp } from './app.js'; +import { loadConfig } from './config.js'; + +const start = async () => { + const config = loadConfig(); + const app = await buildApp(config); + + try { + await app.listen({ + host: '0.0.0.0', + port: config.port, + }); + } catch (error) { + app.log.error(error); + process.exit(1); + } +}; + +await start(); diff --git a/apps/server/src/storage/factory.ts b/apps/server/src/storage/factory.ts new file mode 100644 index 000000000..9dfad05dd --- /dev/null +++ b/apps/server/src/storage/factory.ts @@ -0,0 +1,35 @@ +import { LocalFsStorageProvider } from './local-fs-provider.js'; +import { S3StorageProvider } from './s3-provider.js'; +import { StorageProvider } from './types.js'; + +export type StorageConfig = + | { + type: 'local'; + rootDir: string; + } + | { + type: 's3'; + bucket: string; + region: string; + endpoint?: string; + accessKeyId: string; + secretAccessKey: string; + forcePathStyle?: boolean; + }; + +export const createStorageProvider = (config: StorageConfig): StorageProvider => { + if (config.type === 'local') { + return new LocalFsStorageProvider({ + rootDir: config.rootDir, + }); + } + + return new S3StorageProvider({ + bucket: config.bucket, + region: config.region, + endpoint: config.endpoint, + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + forcePathStyle: config.forcePathStyle, + }); +}; diff --git a/apps/server/src/storage/local-fs-provider.ts b/apps/server/src/storage/local-fs-provider.ts new file mode 100644 index 000000000..144055496 --- /dev/null +++ b/apps/server/src/storage/local-fs-provider.ts @@ -0,0 +1,119 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { + ListObjectsInput, + ListObjectsOutput, + StorageObjectItem, + StorageProvider, +} from './types.js'; +import { toAbsoluteStoragePath } from './path-utils.js'; + +interface LocalFsProviderOptions { + rootDir: string; +} + +const DEFAULT_LIST_LIMIT = 500; + +const listFilesRecursively = async ( + baseDir: string, + currentDir: string +): Promise => { + const entries = await fs.readdir(currentDir, { withFileTypes: true }); + const items: StorageObjectItem[] = []; + + for (const entry of entries) { + const absolutePath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + const nested = await listFilesRecursively(baseDir, absolutePath); + items.push(...nested); + continue; + } + + if (!entry.isFile()) { + continue; + } + + const stat = await fs.stat(absolutePath); + const relative = path.relative(baseDir, absolutePath).replaceAll('\\', '/'); + + items.push({ + key: relative, + size: stat.size, + lastModified: stat.mtime.toISOString(), + }); + } + + return items; +}; + +export class LocalFsStorageProvider implements StorageProvider { + public readonly name = 'local-fs'; + private readonly rootDir: string; + + constructor(options: LocalFsProviderOptions) { + this.rootDir = options.rootDir; + } + + public async init(): Promise { + await fs.mkdir(this.rootDir, { recursive: true }); + } + + public async putObject(input: { + key: string; + body: Buffer; + }): Promise { + const destination = toAbsoluteStoragePath(this.rootDir, input.key); + const destinationDir = path.dirname(destination); + await fs.mkdir(destinationDir, { recursive: true }); + + const tempPath = `${destination}.tmp-${Date.now()}`; + await fs.writeFile(tempPath, input.body); + await fs.rename(tempPath, destination); + } + + public async getObject(key: string): Promise { + const absolute = toAbsoluteStoragePath(this.rootDir, key); + return fs.readFile(absolute); + } + + public async exists(key: string): Promise { + const absolute = toAbsoluteStoragePath(this.rootDir, key); + try { + await fs.access(absolute); + return true; + } catch { + return false; + } + } + + public async deleteObject(key: string): Promise { + const absolute = toAbsoluteStoragePath(this.rootDir, key); + try { + await fs.unlink(absolute); + } catch (error) { + const message = error instanceof Error ? error.message : ''; + if (message.includes('ENOENT')) { + return; + } + throw error; + } + } + + public async listObjects(input: ListObjectsInput): Promise { + const limit = input.limit ?? DEFAULT_LIST_LIMIT; + const allItems = await listFilesRecursively(this.rootDir, this.rootDir); + const filtered = allItems + .filter((item) => item.key.startsWith(input.prefix)) + .sort((a, b) => a.key.localeCompare(b.key)); + + const start = input.cursor ? Number(input.cursor) : 0; + const page = filtered.slice(start, start + limit); + const nextIndex = start + page.length; + + return { + items: page, + nextCursor: nextIndex >= filtered.length ? null : String(nextIndex), + }; + } +} diff --git a/apps/server/src/storage/path-utils.ts b/apps/server/src/storage/path-utils.ts new file mode 100644 index 000000000..34b01a656 --- /dev/null +++ b/apps/server/src/storage/path-utils.ts @@ -0,0 +1,32 @@ +import path from 'node:path'; + +export const normalizeStorageKey = (key: string): string => { + const normalized = key.replaceAll('\\', '/').replace(/^\/+/, ''); + if ( + normalized.length === 0 || + normalized.includes('..') || + normalized.startsWith('/') + ) { + throw new Error('Invalid storage key.'); + } + + return normalized; +}; + +export const toAbsoluteStoragePath = ( + rootDir: string, + key: string +): string => { + const normalized = normalizeStorageKey(key); + const absolute = path.resolve(rootDir, normalized); + const rootAbsolute = path.resolve(rootDir); + + if ( + absolute !== rootAbsolute && + !absolute.startsWith(`${rootAbsolute}${path.sep}`) + ) { + throw new Error('Invalid storage key path.'); + } + + return absolute; +}; diff --git a/apps/server/src/storage/s3-provider.ts b/apps/server/src/storage/s3-provider.ts new file mode 100644 index 000000000..3f0c6db01 --- /dev/null +++ b/apps/server/src/storage/s3-provider.ts @@ -0,0 +1,169 @@ +import { + DeleteObjectCommand, + GetObjectCommand, + HeadObjectCommand, + ListObjectsV2Command, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; + +import { + ListObjectsInput, + ListObjectsOutput, + PresignDownloadInput, + PresignUploadInput, + StorageProvider, +} from './types.js'; + +interface S3StorageProviderOptions { + bucket: string; + region: string; + endpoint?: string; + accessKeyId: string; + secretAccessKey: string; + forcePathStyle?: boolean; +} + +const DEFAULT_PRESIGN_SECONDS = 300; +const DEFAULT_LIST_LIMIT = 500; + +const streamToBuffer = async ( + stream: NodeJS.ReadableStream +): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +}; + +export class S3StorageProvider implements StorageProvider { + public readonly name = 's3'; + private readonly client: S3Client; + private readonly bucket: string; + + constructor(options: S3StorageProviderOptions) { + this.bucket = options.bucket; + this.client = new S3Client({ + region: options.region, + endpoint: options.endpoint, + forcePathStyle: options.forcePathStyle, + credentials: { + accessKeyId: options.accessKeyId, + secretAccessKey: options.secretAccessKey, + }, + }); + } + + public async init(): Promise { + await this.client.send( + new ListObjectsV2Command({ + Bucket: this.bucket, + MaxKeys: 1, + }) + ); + } + + public async putObject(input: { + key: string; + body: Buffer; + contentType?: string; + metadata?: Record; + }): Promise { + await this.client.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: input.key, + Body: input.body, + ContentType: input.contentType, + Metadata: input.metadata, + }) + ); + } + + public async getObject(key: string): Promise { + const output = await this.client.send( + new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + }) + ); + + if (!output.Body) { + throw new Error(`S3 object ${key} has no body.`); + } + + return streamToBuffer(output.Body as NodeJS.ReadableStream); + } + + public async exists(key: string): Promise { + try { + await this.client.send( + new HeadObjectCommand({ + Bucket: this.bucket, + Key: key, + }) + ); + return true; + } catch { + return false; + } + } + + public async deleteObject(key: string): Promise { + await this.client.send( + new DeleteObjectCommand({ + Bucket: this.bucket, + Key: key, + }) + ); + } + + public async listObjects(input: ListObjectsInput): Promise { + const output = await this.client.send( + new ListObjectsV2Command({ + Bucket: this.bucket, + Prefix: input.prefix, + MaxKeys: input.limit ?? DEFAULT_LIST_LIMIT, + ContinuationToken: input.cursor ?? undefined, + }) + ); + + return { + items: + output.Contents?.map((item) => ({ + key: item.Key ?? '', + size: item.Size ?? 0, + etag: item.ETag ?? undefined, + lastModified: item.LastModified?.toISOString(), + })) ?? [], + nextCursor: output.IsTruncated ? output.NextContinuationToken ?? null : null, + }; + } + + public async presignUpload(input: PresignUploadInput): Promise { + const command = new PutObjectCommand({ + Bucket: this.bucket, + Key: input.key, + ContentType: input.contentType, + }); + + return getSignedUrl(this.client, command, { + expiresIn: input.expiresInSec ?? DEFAULT_PRESIGN_SECONDS, + }); + } + + public async presignDownload( + input: PresignDownloadInput + ): Promise { + const command = new GetObjectCommand({ + Bucket: this.bucket, + Key: input.key, + }); + + return getSignedUrl(this.client, command, { + expiresIn: input.expiresInSec ?? DEFAULT_PRESIGN_SECONDS, + }); + } +} diff --git a/apps/server/src/storage/types.ts b/apps/server/src/storage/types.ts new file mode 100644 index 000000000..936a0acc0 --- /dev/null +++ b/apps/server/src/storage/types.ts @@ -0,0 +1,47 @@ +export interface PutObjectInput { + key: string; + body: Buffer; + contentType?: string; + metadata?: Record; +} + +export interface ListObjectsInput { + prefix: string; + limit?: number; + cursor?: string; +} + +export interface StorageObjectItem { + key: string; + size: number; + etag?: string; + lastModified?: string; +} + +export interface ListObjectsOutput { + items: StorageObjectItem[]; + nextCursor: string | null; +} + +export interface PresignUploadInput { + key: string; + contentType?: string; + expiresInSec?: number; +} + +export interface PresignDownloadInput { + key: string; + expiresInSec?: number; +} + +export interface StorageProvider { + readonly name: string; + init(): Promise; + putObject(input: PutObjectInput): Promise; + getObject(key: string): Promise; + exists(key: string): Promise; + deleteObject(key: string): Promise; + listObjects(input: ListObjectsInput): Promise; + presignUpload?(input: PresignUploadInput): Promise; + presignDownload?(input: PresignDownloadInput): Promise; +} diff --git a/apps/server/src/sync/realtime-hub.ts b/apps/server/src/sync/realtime-hub.ts new file mode 100644 index 000000000..3061b676a --- /dev/null +++ b/apps/server/src/sync/realtime-hub.ts @@ -0,0 +1,68 @@ +import type { WebSocket } from 'ws'; + +type Subscriber = { + socket: WebSocket; + workspaceId: string; + deviceId: string; +}; + +export type SyncUpdatedMessage = { + type: 'sync.updated'; + workspaceId: string; + fromDeviceId: string; + watermarkSeq: number; +}; + +export class SyncRealtimeHub { + private readonly subscribers = new Set(); + + public subscribe( + socket: WebSocket, + workspaceId: string, + deviceId: string + ): void { + const subscriber: Subscriber = { socket, workspaceId, deviceId }; + this.subscribers.add(subscriber); + + const cleanup = () => { + this.subscribers.delete(subscriber); + }; + + socket.on('close', cleanup); + socket.on('error', cleanup); + + socket.send(JSON.stringify({ type: 'connected', workspaceId, deviceId })); + } + + public notify(input: { + workspaceId: string; + fromDeviceId: string; + watermarkSeq: number; + }): void { + if (input.watermarkSeq <= 0) { + return; + } + + const message: SyncUpdatedMessage = { + type: 'sync.updated', + workspaceId: input.workspaceId, + fromDeviceId: input.fromDeviceId, + watermarkSeq: input.watermarkSeq, + }; + const payload = JSON.stringify(message); + + for (const subscriber of this.subscribers) { + if (subscriber.workspaceId !== input.workspaceId) { + continue; + } + if (subscriber.deviceId === input.fromDeviceId) { + continue; + } + if (subscriber.socket.readyState !== 1) { + continue; + } + + subscriber.socket.send(payload); + } + } +} diff --git a/apps/server/src/sync/repository-types.ts b/apps/server/src/sync/repository-types.ts new file mode 100644 index 000000000..8db436bbe --- /dev/null +++ b/apps/server/src/sync/repository-types.ts @@ -0,0 +1,69 @@ +import { OpLogRecord } from './types.js'; + +export interface PushBatchResult { + acceptedCount: number; + duplicateCount: number; + fromSeq: number | null; + toSeq: number | null; +} + +export interface StoredRecord { + serverSeq: number; + workspaceId: string; + fromDeviceId: string; + kind: OpLogRecord['kind']; + id: string; + rootId?: string; + happenedAt: string; + record: OpLogRecord; +} + +export interface PullResult { + records: StoredRecord[]; + nextAfterSeq: number; + hasMore: boolean; + watermarkSeq: number; +} + +export interface WorkspaceManifest { + workspaceId: string; + userId: string; + name: string; + createdAt: string; + createdByDeviceId: string; + recordCount: number; + lastActivityAt: string | null; +} + +export interface SyncRepository { + appendBatch(input: { + workspaceId: string; + deviceId: string; + batchId: string; + records: OpLogRecord[]; + }): PushBatchResult; + + pull(input: { + workspaceId: string; + deviceId: string; + afterSeq: number; + limit: number; + }): PullResult; + + upsertWorkspaceManifest(manifest: { + workspaceId: string; + userId: string; + name: string; + createdAt: string; + createdByDeviceId: string; + }): void; + + listWorkspaces(): WorkspaceManifest[]; + + deleteWorkspace(workspaceId: string): { + deletedRecords: number; + deletedFiles: number; + }; + + listFileObjectKeys(workspaceId: string): string[]; +} diff --git a/apps/server/src/sync/routes.ts b/apps/server/src/sync/routes.ts new file mode 100644 index 000000000..f575f04c9 --- /dev/null +++ b/apps/server/src/sync/routes.ts @@ -0,0 +1,145 @@ +import { FastifyInstance } from 'fastify'; +import { z } from 'zod'; + +import { ApiError, ApiErrorCode, success } from '../types/api.js'; +import { StorageProvider } from '../storage/types.js'; +import { SyncRealtimeHub } from './realtime-hub.js'; +import { SyncRepository } from './repository-types.js'; +import { + syncPullQuerySchema, + syncPushRequestSchema, + workspaceManifestSchema, +} from './types.js'; + +export const registerSyncRoutes = ( + app: FastifyInstance, + repository: SyncRepository, + storage?: StorageProvider, + hub?: SyncRealtimeHub +): void => { + app.post('/v1/sync/push', async (request, reply) => { + const requestId = request.id; + const parsed = syncPushRequestSchema.safeParse(request.body); + if (!parsed.success) { + throw new ApiError( + ApiErrorCode.SyncInvalidPayload, + parsed.error.message, + 400 + ); + } + + const result = repository.appendBatch(parsed.data); + if (hub && result.acceptedCount > 0 && result.toSeq) { + hub.notify({ + workspaceId: parsed.data.workspaceId, + fromDeviceId: parsed.data.deviceId, + watermarkSeq: result.toSeq, + }); + } + + return reply.send( + success(requestId, { + acceptedCount: result.acceptedCount, + duplicateCount: result.duplicateCount, + serverSeqRange: { + from: result.fromSeq, + to: result.toSeq, + }, + serverTime: new Date().toISOString(), + }) + ); + }); + + app.get('/v1/sync/pull', async (request, reply) => { + const requestId = request.id; + const parsed = syncPullQuerySchema.safeParse(request.query); + if (!parsed.success) { + throw new ApiError( + ApiErrorCode.SyncInvalidCursor, + parsed.error.message, + 400 + ); + } + + const result = repository.pull(parsed.data); + return reply.send( + success(requestId, { + records: result.records, + nextAfterSeq: result.nextAfterSeq, + hasMore: result.hasMore, + watermarkSeq: result.watermarkSeq, + }) + ); + }); + + app.get('/v1/sync/workspaces', async (request, reply) => { + const requestId = request.id; + return reply.send( + success(requestId, { + workspaces: repository.listWorkspaces(), + }) + ); + }); + + app.put('/v1/sync/workspaces/:workspaceId/manifest', async (request, reply) => { + const requestId = request.id; + const params = z + .object({ workspaceId: z.string().min(1) }) + .safeParse(request.params); + if (!params.success) { + throw new ApiError( + ApiErrorCode.SyncInvalidPayload, + params.error.message, + 400 + ); + } + + const parsed = workspaceManifestSchema.safeParse(request.body); + if (!parsed.success) { + throw new ApiError( + ApiErrorCode.SyncInvalidPayload, + parsed.error.message, + 400 + ); + } + + if (parsed.data.workspaceId !== params.data.workspaceId) { + throw new ApiError( + ApiErrorCode.SyncInvalidPayload, + 'workspaceId in path and body must match.', + 400 + ); + } + + repository.upsertWorkspaceManifest(parsed.data); + return reply.send(success(requestId, { ok: true })); + }); + + app.delete('/v1/sync/workspaces/:workspaceId', async (request, reply) => { + const requestId = request.id; + const params = z + .object({ workspaceId: z.string().min(1) }) + .safeParse(request.params); + if (!params.success) { + throw new ApiError( + ApiErrorCode.SyncInvalidPayload, + params.error.message, + 400 + ); + } + + const objectKeys = repository.listFileObjectKeys(params.data.workspaceId); + if (storage) { + for (const key of objectKeys) { + try { + await storage.deleteObject(key); + } catch { + // Continue deleting metadata even if blob cleanup fails. + } + } + } + + const result = repository.deleteWorkspace(params.data.workspaceId); + return reply.send(success(requestId, result)); + }); +}; diff --git a/apps/server/src/sync/sqlite-repository.ts b/apps/server/src/sync/sqlite-repository.ts new file mode 100644 index 000000000..abbf18052 --- /dev/null +++ b/apps/server/src/sync/sqlite-repository.ts @@ -0,0 +1,362 @@ +import type { ServerDatabase } from '../db/database.js'; +import { + PullResult, + PushBatchResult, + StoredRecord, + SyncRepository, + WorkspaceManifest, +} from './repository-types.js'; +import { OpLogRecord } from './types.js'; + +interface BatchRow { + accepted_count: number; + duplicate_count: number; + first_server_seq: number | null; + last_server_seq: number | null; +} + +interface RecordRow { + server_seq: number; + workspace_id: string; + from_device_id: string; + kind: OpLogRecord['kind']; + record_id: string; + root_id: string | null; + happened_at: string; + payload_json: string; +} + +const getRecordId = (record: OpLogRecord): string => { + const data = record.data as { id?: string }; + return data.id ?? record.kind; +}; + +const getRootId = (record: OpLogRecord): string | null => { + const data = record.data as { rootId?: string }; + return data.rootId ?? null; +}; + +const getHappenedAt = (record: OpLogRecord): string => { + const data = record.data as { createdAt?: string; deletedAt?: string; updatedAt?: string }; + return data.deletedAt ?? data.createdAt ?? data.updatedAt ?? new Date().toISOString(); +}; + +const parseRecord = (row: RecordRow): StoredRecord => { + const record = JSON.parse(row.payload_json) as OpLogRecord; + + return { + serverSeq: row.server_seq, + workspaceId: row.workspace_id, + fromDeviceId: row.from_device_id, + kind: row.kind, + id: row.record_id, + rootId: row.root_id ?? undefined, + happenedAt: row.happened_at, + record, + }; +}; + +export class SqliteSyncRepository implements SyncRepository { + constructor(private readonly database: ServerDatabase) {} + + public appendBatch(input: { + workspaceId: string; + deviceId: string; + batchId: string; + records: OpLogRecord[]; + }): PushBatchResult { + const existing = this.database + .prepare( + ` + SELECT accepted_count, duplicate_count, first_server_seq, last_server_seq + FROM sync_batches + WHERE workspace_id = ? AND device_id = ? AND batch_id = ? + ` + ) + .get(input.workspaceId, input.deviceId, input.batchId) as + | BatchRow + | undefined; + + if (existing) { + return { + acceptedCount: existing.accepted_count, + duplicateCount: input.records.length, + fromSeq: existing.first_server_seq, + toSeq: existing.last_server_seq, + }; + } + + const insertRecord = this.database.prepare(` + INSERT INTO sync_records ( + workspace_id, + from_device_id, + kind, + record_id, + root_id, + happened_at, + payload_json + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `); + + const insertBatch = this.database.prepare(` + INSERT INTO sync_batches ( + workspace_id, + device_id, + batch_id, + accepted_count, + duplicate_count, + first_server_seq, + last_server_seq + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `); + + const append = this.database.transaction(() => { + let fromSeq: number | null = null; + let toSeq: number | null = null; + + for (const record of input.records) { + const result = insertRecord.run( + input.workspaceId, + input.deviceId, + record.kind, + getRecordId(record), + getRootId(record), + getHappenedAt(record), + JSON.stringify(record) + ); + + const serverSeq = Number(result.lastInsertRowid); + if (fromSeq === null) { + fromSeq = serverSeq; + } + toSeq = serverSeq; + } + + insertBatch.run( + input.workspaceId, + input.deviceId, + input.batchId, + input.records.length, + 0, + fromSeq, + toSeq + ); + + return { + acceptedCount: input.records.length, + duplicateCount: 0, + fromSeq, + toSeq, + } satisfies PushBatchResult; + }); + + return append(); + } + + public pull(input: { + workspaceId: string; + deviceId: string; + afterSeq: number; + limit: number; + }): PullResult { + const watermarkRow = this.database + .prepare( + ` + SELECT COALESCE(MAX(server_seq), 0) AS watermark_seq + FROM sync_records + WHERE workspace_id = ? + ` + ) + .get(input.workspaceId) as { watermark_seq: number }; + + const rows = this.database + .prepare( + ` + SELECT + server_seq, + workspace_id, + from_device_id, + kind, + record_id, + root_id, + happened_at, + payload_json + FROM sync_records + WHERE workspace_id = ? + AND server_seq > ? + AND from_device_id != ? + ORDER BY server_seq ASC + LIMIT ? + ` + ) + .all( + input.workspaceId, + input.afterSeq, + input.deviceId, + input.limit + 1 + ) as RecordRow[]; + + const hasMore = rows.length > input.limit; + const page = hasMore ? rows.slice(0, input.limit) : rows; + const records = page.map(parseRecord); + const lastSeq = records.at(-1)?.serverSeq ?? input.afterSeq; + + return { + records, + nextAfterSeq: lastSeq, + hasMore, + watermarkSeq: watermarkRow.watermark_seq, + }; + } + + public upsertWorkspaceManifest(manifest: { + workspaceId: string; + userId: string; + name: string; + createdAt: string; + createdByDeviceId: string; + }): void { + this.database + .prepare( + ` + INSERT INTO workspace_manifests ( + workspace_id, + user_id, + name, + created_at, + created_by_device_id, + updated_at + ) VALUES (?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(workspace_id) DO UPDATE SET + user_id = excluded.user_id, + name = excluded.name, + updated_at = datetime('now') + ` + ) + .run( + manifest.workspaceId, + manifest.userId, + manifest.name, + manifest.createdAt, + manifest.createdByDeviceId + ); + } + + public listWorkspaces(): WorkspaceManifest[] { + const rows = this.database + .prepare( + ` + SELECT + COALESCE(m.workspace_id, r.workspace_id) AS workspace_id, + COALESCE( + NULLIF(m.user_id, ''), + ( + SELECT json_extract(payload_json, '$.data.createdBy') + FROM sync_records AS sr + WHERE sr.workspace_id = r.workspace_id + AND sr.kind = 'node.update' + ORDER BY sr.server_seq ASC + LIMIT 1 + ), + '' + ) AS user_id, + COALESCE(NULLIF(m.name, ''), r.workspace_id) AS name, + COALESCE(NULLIF(m.created_at, ''), '') AS created_at, + COALESCE(m.created_by_device_id, '') AS created_by_device_id, + r.record_count AS record_count, + r.last_activity_at AS last_activity_at + FROM ( + SELECT + workspace_id, + COUNT(*) AS record_count, + MAX(happened_at) AS last_activity_at + FROM sync_records + GROUP BY workspace_id + ) AS r + LEFT JOIN workspace_manifests AS m + ON m.workspace_id = r.workspace_id + ORDER BY r.last_activity_at DESC, name ASC + ` + ) + .all() as Array<{ + workspace_id: string; + user_id: string; + name: string; + created_at: string; + created_by_device_id: string; + record_count: number; + last_activity_at: string | null; + }>; + + return rows.map((row) => ({ + workspaceId: row.workspace_id, + userId: row.user_id, + name: row.name, + createdAt: row.created_at || new Date(0).toISOString(), + createdByDeviceId: row.created_by_device_id, + recordCount: row.record_count, + lastActivityAt: row.last_activity_at, + })); + } + + public listFileObjectKeys(workspaceId: string): string[] { + const rows = this.database + .prepare( + ` + SELECT object_key + FROM file_objects + WHERE workspace_id = ? + ` + ) + .all(workspaceId) as Array<{ object_key: string }>; + + return rows.map((row) => row.object_key); + } + + public deleteWorkspace(workspaceId: string): { + deletedRecords: number; + deletedFiles: number; + } { + const countRecords = this.database + .prepare( + ` + SELECT COUNT(*) AS count + FROM sync_records + WHERE workspace_id = ? + ` + ) + .get(workspaceId) as { count: number }; + + const countFiles = this.database + .prepare( + ` + SELECT COUNT(*) AS count + FROM file_objects + WHERE workspace_id = ? + ` + ) + .get(workspaceId) as { count: number }; + + const remove = this.database.transaction(() => { + this.database + .prepare(`DELETE FROM sync_records WHERE workspace_id = ?`) + .run(workspaceId); + this.database + .prepare(`DELETE FROM sync_batches WHERE workspace_id = ?`) + .run(workspaceId); + this.database + .prepare(`DELETE FROM workspace_manifests WHERE workspace_id = ?`) + .run(workspaceId); + this.database + .prepare(`DELETE FROM file_objects WHERE workspace_id = ?`) + .run(workspaceId); + }); + + remove(); + + return { + deletedRecords: countRecords.count, + deletedFiles: countFiles.count, + }; + } +} diff --git a/apps/server/src/sync/types.ts b/apps/server/src/sync/types.ts new file mode 100644 index 000000000..783ba3c5f --- /dev/null +++ b/apps/server/src/sync/types.ts @@ -0,0 +1,56 @@ +import { z } from 'zod'; + +const opLogRecordSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('node.update'), + data: z.record(z.string(), z.unknown()), + }), + z.object({ + kind: z.literal('document.update'), + data: z.record(z.string(), z.unknown()), + }), + z.object({ + kind: z.literal('node.delete'), + data: z.record(z.string(), z.unknown()), + }), + z.object({ + kind: z.literal('node.reaction'), + data: z.record(z.string(), z.unknown()), + }), +]); + +export type OpLogRecord = z.infer; + +export const syncPushRequestSchema = z.object({ + workspaceId: z.string().min(1), + deviceId: z.string().min(1), + batchId: z.string().min(1), + clientTime: z.string().datetime().optional(), + clientCursor: z + .object({ + lastPulledServerSeq: z.number().int().nonnegative(), + }) + .optional(), + records: z.array(opLogRecordSchema).max(5000), +}); + +export type SyncPushRequest = z.infer; + +export const syncPullQuerySchema = z.object({ + workspaceId: z.string().min(1), + deviceId: z.string().min(1), + afterSeq: z.coerce.number().int().nonnegative().default(0), + limit: z.coerce.number().int().positive().max(1000).default(200), +}); + +export type SyncPullQuery = z.infer; + +export const workspaceManifestSchema = z.object({ + workspaceId: z.string().min(1), + userId: z.string().min(1), + name: z.string().min(1), + createdAt: z.string().min(1), + createdByDeviceId: z.string().min(1), +}); + +export type WorkspaceManifestInput = z.infer; diff --git a/apps/server/src/sync/ws-routes.ts b/apps/server/src/sync/ws-routes.ts new file mode 100644 index 000000000..eb32ad239 --- /dev/null +++ b/apps/server/src/sync/ws-routes.ts @@ -0,0 +1,24 @@ +import { FastifyInstance } from 'fastify'; +import { z } from 'zod'; + +import { SyncRealtimeHub } from './realtime-hub.js'; + +const subscribeQuerySchema = z.object({ + workspaceId: z.string().min(1), + deviceId: z.string().min(1), +}); + +export const registerSyncWsRoutes = ( + app: FastifyInstance, + hub: SyncRealtimeHub +): void => { + app.get('/v1/sync/ws', { websocket: true }, (socket, request) => { + const parsed = subscribeQuerySchema.safeParse(request.query); + if (!parsed.success) { + socket.close(1008, 'Invalid subscription query'); + return; + } + + hub.subscribe(socket, parsed.data.workspaceId, parsed.data.deviceId); + }); +}; diff --git a/apps/server/src/types/api.ts b/apps/server/src/types/api.ts new file mode 100644 index 000000000..b6c97c8c6 --- /dev/null +++ b/apps/server/src/types/api.ts @@ -0,0 +1,40 @@ +export enum ApiErrorCode { + SyncInvalidPayload = 'SYNC_INVALID_PAYLOAD', + SyncInvalidCursor = 'SYNC_INVALID_CURSOR', + SyncBatchTooLarge = 'SYNC_BATCH_TOO_LARGE', + SyncDuplicateBatch = 'SYNC_DUPLICATE_BATCH', + SyncInternalError = 'SYNC_INTERNAL_ERROR', + FileNotFound = 'FILE_NOT_FOUND', + FileChecksumMismatch = 'FILE_CHECKSUM_MISMATCH', + FileUploadIncomplete = 'FILE_UPLOAD_INCOMPLETE', + StorageUnavailable = 'STORAGE_UNAVAILABLE', +} + +export class ApiError extends Error { + constructor( + public readonly code: ApiErrorCode, + message: string, + public readonly statusCode = 400 + ) { + super(message); + } +} + +export const success = (requestId: string, data: T) => ({ + success: true as const, + requestId, + data, +}); + +export const failure = ( + requestId: string, + code: ApiErrorCode, + message: string +) => ({ + success: false as const, + requestId, + error: { + code, + message, + }, +}); diff --git a/apps/server/test/realtime-hub.test.ts b/apps/server/test/realtime-hub.test.ts new file mode 100644 index 000000000..77a834ebd --- /dev/null +++ b/apps/server/test/realtime-hub.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { SyncRealtimeHub } from '../src/sync/realtime-hub.js'; + +const createSocket = () => { + const listeners = new Map void>>(); + return { + readyState: 1, + sent: [] as string[], + send(payload: string) { + this.sent.push(payload); + }, + on(event: string, listener: () => void) { + const current = listeners.get(event) ?? []; + current.push(listener); + listeners.set(event, current); + }, + close() { + for (const listener of listeners.get('close') ?? []) { + listener(); + } + }, + }; +}; + +describe('SyncRealtimeHub', () => { + it('notifies other devices in the same workspace', () => { + const hub = new SyncRealtimeHub(); + const deviceA = createSocket(); + const deviceB = createSocket(); + + hub.subscribe(deviceA as never, 'ws_1', 'dev_a'); + hub.subscribe(deviceB as never, 'ws_1', 'dev_b'); + + hub.notify({ + workspaceId: 'ws_1', + fromDeviceId: 'dev_a', + watermarkSeq: 42, + }); + + const syncMessages = deviceB.sent + .map((payload) => JSON.parse(payload) as { type: string }) + .filter((message) => message.type === 'sync.updated'); + + expect(syncMessages).toHaveLength(1); + expect(syncMessages[0]).toMatchObject({ + type: 'sync.updated', + watermarkSeq: 42, + }); + }); + + it('does not echo updates back to the sender device', () => { + const hub = new SyncRealtimeHub(); + const sender = createSocket(); + const peer = createSocket(); + + hub.subscribe(sender as never, 'ws_1', 'dev_a'); + hub.subscribe(peer as never, 'ws_1', 'dev_b'); + + hub.notify({ + workspaceId: 'ws_1', + fromDeviceId: 'dev_a', + watermarkSeq: 7, + }); + + const peerSyncMessages = peer.sent + .map((payload) => JSON.parse(payload) as { type: string }) + .filter((message) => message.type === 'sync.updated'); + const senderSyncMessages = sender.sent + .map((payload) => JSON.parse(payload) as { type: string }) + .filter((message) => message.type === 'sync.updated'); + + expect(senderSyncMessages).toHaveLength(0); + expect(peerSyncMessages).toHaveLength(1); + }); +}); diff --git a/apps/server/test/sqlite-repository.test.ts b/apps/server/test/sqlite-repository.test.ts new file mode 100644 index 000000000..d6d73ce05 --- /dev/null +++ b/apps/server/test/sqlite-repository.test.ts @@ -0,0 +1,105 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { createDatabase } from '../src/db/database.js'; +import { SqliteSyncRepository } from '../src/sync/sqlite-repository.js'; + +describe('sqlite sync repository', () => { + let dbPath: string; + let repository: SqliteSyncRepository; + + beforeEach(async () => { + dbPath = path.join( + os.tmpdir(), + `colanode-server-${Date.now()}-${Math.random()}.sqlite` + ); + const database = await createDatabase(dbPath); + repository = new SqliteSyncRepository(database); + }); + + afterEach(async () => { + await fs.rm(dbPath, { force: true }); + }); + + it('persists and replays pushed records', () => { + const pushResult = repository.appendBatch({ + workspaceId: 'ws_1', + deviceId: 'dev_a', + batchId: 'batch_1', + records: [ + { + kind: 'node.update', + data: { + id: 'rec_1', + nodeId: 'node_1', + rootId: 'root_1', + workspaceId: 'ws_1', + revision: '0', + data: 'Zm9v', + createdAt: '2026-07-09T00:00:00.000Z', + createdBy: 'user_1', + mergedUpdates: null, + }, + }, + ], + }); + + expect(pushResult.acceptedCount).toBe(1); + expect(pushResult.fromSeq).toBe(1); + expect(pushResult.toSeq).toBe(1); + + const pullResult = repository.pull({ + workspaceId: 'ws_1', + deviceId: 'dev_b', + afterSeq: 0, + limit: 100, + }); + + expect(pullResult.records).toHaveLength(1); + expect(pullResult.records[0]?.record.kind).toBe('node.update'); + expect(pullResult.watermarkSeq).toBe(1); + }); + + it('returns duplicate result for repeated batch id', () => { + const input = { + workspaceId: 'ws_1', + deviceId: 'dev_a', + batchId: 'batch_dup', + records: [ + { + kind: 'node.update' as const, + data: { + id: 'rec_1', + nodeId: 'node_1', + rootId: 'root_1', + workspaceId: 'ws_1', + revision: '0', + data: 'Zm9v', + createdAt: '2026-07-09T00:00:00.000Z', + createdBy: 'user_1', + mergedUpdates: null, + }, + }, + ], + }; + + const first = repository.appendBatch(input); + const second = repository.appendBatch(input); + + expect(first.acceptedCount).toBe(1); + expect(second.duplicateCount).toBe(1); + expect(second.fromSeq).toBe(first.fromSeq); + expect(second.toSeq).toBe(first.toSeq); + + const pullResult = repository.pull({ + workspaceId: 'ws_1', + deviceId: 'dev_b', + afterSeq: 0, + limit: 100, + }); + + expect(pullResult.records).toHaveLength(1); + }); +}); diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 000000000..1cb9508a0 --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Server", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 156c78eb3..099b5f68a 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -2,6 +2,10 @@ FROM node:22-alpine AS build WORKDIR /app +ARG VITE_SERVER_SYNC_URL=/ + +ENV VITE_SERVER_SYNC_URL=$VITE_SERVER_SYNC_URL + # Copy root workspace files COPY package.json package-lock.json ./ @@ -43,6 +47,10 @@ FROM nginx:1.27-alpine COPY --from=build /app/apps/web/dist /usr/share/nginx/html RUN cat <<'EOF' > /etc/nginx/conf.d/default.conf +upstream colanode_server { + server server:4400; +} + server { listen 80; server_name _; @@ -54,6 +62,28 @@ server { add_header Cross-Origin-Embedder-Policy "require-corp" always; add_header Cross-Origin-Resource-Policy "same-origin" always; + location /v1/sync/ws { + proxy_pass http://colanode_server; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 3600s; + } + + location /v1/ { + proxy_pass http://colanode_server; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location = /healthz { + proxy_pass http://colanode_server; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 09167915b..1099ba66f 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -22,6 +22,16 @@ export interface ColanodeWorkerApi { unsubscribe: (subscriptionId: string) => Promise; publish: (event: Event) => void; saveTempFile: (file: File) => Promise; + localSyncListRemoteWorkspaces: () => Promise< + import('@colanode/ui/window').RemoteWorkspaceManifest[] + >; + localSyncJoinWorkspace: (params: { + userId: string; + remoteWorkspaceId: string; + }) => Promise<{ userId: string }>; + localSyncDeleteRemoteWorkspace: (params: { + workspaceId: string; + }) => Promise; } declare global { diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index f327e6ee0..88e60623c 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -125,9 +125,14 @@ const initializeApp = async () => { localSyncClear: async () => { // No-op on web }, - localSyncListRemoteWorkspaces: async () => [], - localSyncJoinWorkspace: async () => { - throw new Error('Local sync is not available on the web'); + localSyncListRemoteWorkspaces: async () => { + return workerApi.localSyncListRemoteWorkspaces(); + }, + localSyncJoinWorkspace: async (params) => { + return workerApi.localSyncJoinWorkspace(params); + }, + localSyncDeleteRemoteWorkspace: async (params) => { + await workerApi.localSyncDeleteRemoteWorkspace(params); }, }; diff --git a/apps/web/src/workers/dedicated.ts b/apps/web/src/workers/dedicated.ts index fb25998e8..43cfb16e2 100644 --- a/apps/web/src/workers/dedicated.ts +++ b/apps/web/src/workers/dedicated.ts @@ -1,6 +1,8 @@ import * as Comlink from 'comlink'; import { eventBus } from '@colanode/client/lib'; +import { listServerWorkspaces } from '@colanode/client/lib/list-server-workspaces'; +import { ServerSyncApiClient } from '@colanode/client/services/workspaces/server-sync-api-client'; import { mapAccount } from '@colanode/client/lib/mappers'; import { MutationInput, @@ -8,7 +10,7 @@ import { TempFileCreateMutationInput, } from '@colanode/client/mutations'; import { QueryInput, QueryMap } from '@colanode/client/queries'; -import { AppMeta } from '@colanode/client/services/app-meta'; +import { AppMeta, ServerSyncConfig } from '@colanode/client/services/app-meta'; import { AppService } from '@colanode/client/services/app-service'; import { AppInitOutput } from '@colanode/client/types'; import { @@ -40,6 +42,30 @@ const debug = createDebugger('web:worker:dedicated'); const LOCAL_ACCOUNT_EMAIL = 'local@colanode.local'; const LOCAL_ACCOUNT_NAME = 'Local User'; +const resolveServerSyncConfig = async ( + app: AppService, + baseUrl: string | undefined +): Promise => { + if (!baseUrl) { + return undefined; + } + + const normalizedBaseUrl = baseUrl.replace(/\/$/, ''); + const existingDevice = await app.metadata.get('app', 'serverSync.deviceId'); + const deviceId = existingDevice + ? (JSON.parse(existingDevice.value) as string) + : generateId(IdType.Device); + + if (!existingDevice) { + await app.metadata.set('app', 'serverSync.deviceId', deviceId); + } + + return { + baseUrl: normalizedBaseUrl, + deviceId, + }; +}; + const fs = new WebFileSystem(); const path = new WebPathService(); let app: AppService | null = null; @@ -112,10 +138,14 @@ const ensureLocalOnlyAccountBootstrap = async (app: AppService) => { navigator.locks.request('colanode', async () => { try { + const serverSyncUrl = import.meta.env.VITE_SERVER_SYNC_URL as + | string + | undefined; + const appMeta: AppMeta = { type: 'web', platform: navigator.userAgent, - localOnly: true, + localOnly: !serverSyncUrl, }; const bootstrap = await WebBootstrapService.create(path, fs); @@ -127,13 +157,23 @@ navigator.locks.request('colanode', async () => { app = new AppService(appMeta, fs, new WebKyselyService(), path); await app.migrate(); + + const serverSync = await resolveServerSyncConfig(app, serverSyncUrl); + if (serverSync) { + app.meta.serverSync = serverSync; + } + await app.init(); await ensureLocalOnlyAccountBootstrap(app); await bootstrap.updateVersion(build.version); await app.metadata.set('app', 'version', build.version); await app.metadata.set('app', 'platform', appMeta.platform); - await app.metadata.set('app', 'mode.localOnly', true); + await app.metadata.set('app', 'mode.localOnly', !serverSync); + if (serverSync) { + await app.metadata.set('app', 'mode.serverSync', true); + await app.metadata.set('app', 'serverSync.baseUrl', serverSync.baseUrl); + } finishInit('success'); @@ -526,6 +566,36 @@ const api: ColanodeWorkerApi = { url, }; }, + async localSyncListRemoteWorkspaces() { + if (!app || appInitOutput !== 'success' || !app.meta.serverSync) { + return []; + } + + return listServerWorkspaces(app.meta.serverSync); + }, + async localSyncJoinWorkspace(params: { + userId: string; + remoteWorkspaceId: string; + }) { + if (!app || appInitOutput !== 'success') { + throw new Error('App not initialized'); + } + + const workspace = await app.joinServerWorkspace( + params.userId, + params.remoteWorkspaceId + ); + + return { userId: workspace.userId }; + }, + async localSyncDeleteRemoteWorkspace(params: { workspaceId: string }) { + if (!app || appInitOutput !== 'success' || !app.meta.serverSync) { + throw new Error('Server sync is not configured'); + } + + const client = new ServerSyncApiClient(app.meta.serverSync.baseUrl); + await client.deleteWorkspace(params.workspaceId); + }, }; Comlink.expose(api); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..8b36c9f29 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +services: + server: + build: + context: . + dockerfile: apps/server/Dockerfile + container_name: colanode-server + restart: unless-stopped + volumes: + - colanode-server-data:/data + expose: + - "4400" + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:4400/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + web: + build: + context: . + dockerfile: apps/web/Dockerfile + args: + # Same-origin sync API via nginx proxy (/v1/* → server) + VITE_SERVER_SYNC_URL: / + container_name: colanode-web + restart: unless-stopped + ports: + - "8080:80" + depends_on: + server: + condition: service_healthy + +volumes: + colanode-server-data: diff --git a/docs/roadmap.md b/docs/roadmap.md index 50d2a7199..adb826256 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -14,6 +14,7 @@ | TodoList(系统任务库) | [todo-list.md](./todo-list.md) | | 本地同步 | [local-sync.md](./local-sync.md) | | 本地成员管理(LOCAL_ONLY) | [local-members.md](./local-members.md) | +| 自托管 Server Sync | [server-sync.md](./server-sync.md) | | Page Mermaid | [mermaid.md](./mermaid.md) | | Workspace 标签(仅 `feat/sync_local`,未合入) | [tags-implementation-guide.md](./tags-implementation-guide.md) | | 频道搜索与 @ 提及 | [channel-search-and-mentions.md](./channel-search-and-mentions.md) | diff --git a/docs/server-sync.md b/docs/server-sync.md new file mode 100644 index 000000000..21339367a --- /dev/null +++ b/docs/server-sync.md @@ -0,0 +1,121 @@ +# Server sync (self-hosted) + +Colanode Web can sync workspace data through a self-hosted **sync API** (`apps/server`). Data stays local-first in the browser (SQLite); the server stores op-log replicas and file blobs for multi-device sync. + +## Architecture + +``` +Browser (Web) Sync Server +┌─────────────────────┐ ┌──────────────────────────┐ +│ SQLite (workspace) │ push/pull│ SQLite (sync metadata) │ +│ OPFS (files) │ ──────► │ Local FS / S3 (blobs) │ +│ ServerSyncService │ │ Fastify API │ +└─────────────────────┘ └──────────────────────────┘ +``` + +- **Local-first**: reads/writes go to browser SQLite first. +- **Background sync**: `ServerSyncService` pulls then pushes every ~10s. +- **Multi-device**: new browser/device must **Join** a workspace from the server list (Settings → Local Sync). +- **No auth yet**: run server on a trusted network or behind VPN. + +## Quick start (development) + +**Terminal 1 — server** + +```bash +cd apps/server && npm run dev +``` + +**Terminal 2 — web** + +Create `apps/web/.env.local`: + +```bash +VITE_SERVER_SYNC_URL=http://localhost:4400 +``` + +```bash +cd apps/web && npm run dev +``` + +Open http://localhost:4000, create or use a workspace. Check Settings → Local Sync for export/import status. + +## Quick start (Docker Compose) + +From repository root: + +```bash +docker compose up --build -d +``` + +Open http://localhost:8080 + +- Web image is built with `VITE_SERVER_SYNC_URL=/` (same-origin). +- Nginx proxies `/v1/*` and `/healthz` to the server container. +- Server data persists in Docker volume `colanode-server-data`. + +Stop: + +```bash +docker compose down +``` + +## API + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/healthz` | Health check | +| GET | `/v1/sync/workspaces` | List workspaces on server | +| PUT | `/v1/sync/workspaces/:id/manifest` | Register workspace metadata | +| DELETE | `/v1/sync/workspaces/:id` | Delete workspace sync data on server | +| POST | `/v1/sync/push` | Push op-log batch | +| GET | `/v1/sync/pull` | Pull remote op-log | +| POST | `/v1/files/upload` | Upload file blob | +| GET | `/v1/files/download` | Download file blob | +| WS | `/v1/sync/ws` | Realtime sync notifications | + +## Realtime sync + +When `VITE_SERVER_SYNC_URL` is configured, the client opens a WebSocket to `/v1/sync/ws` and pulls immediately when another device pushes changes. Local edits export within ~500ms. A 60s poll remains as a fallback if the socket disconnects. + +## Multi-device workflow + +1. **Device A**: configure `VITE_SERVER_SYNC_URL`, use workspace normally. Data auto-pushes to server. +2. **Device B** (new browser / incognito): + - Same `VITE_SERVER_SYNC_URL` (or use Docker Compose same-origin setup). + - On `/create`, use **Join from server** when workspaces are listed. + - Or Settings → Local Sync → **Join** (requires an existing local workspace). +3. Device B downloads server op-log; spaces/pages appear after import. + +Duplicate names (e.g. multiple "Home") are test leftovers — use **record count**, **last active**, and **Current** badge. Remove orphans via Settings → Local Sync → **Delete** (server sync only). + +## Environment + +### Web + +| Variable | Example | Description | +|----------|---------|-------------| +| `VITE_SERVER_SYNC_URL` | `http://localhost:4400` | Sync API base URL. Use `/` for same-origin (Docker Compose). Omit for pure local-only mode. | + +### Server + +See [apps/server/README.md](../apps/server/README.md). + +## Data layout (server) + +| Path | Content | +|------|---------| +| `SYNC_DB_PATH` | SQLite: `sync_batches`, `sync_records`, `workspace_manifests`, `file_objects` | +| `SYNC_STORAGE_LOCAL_ROOT` | File blobs: `workspaces//files/...` | + +## Related docs + +- [local-sync.md](./local-sync.md) — peer-to-peer sync via shared storage (desktop) +- [web-docker-deploy.md](./web-docker-deploy.md) — web-only Docker deploy (no server) + +## Roadmap (not yet implemented) + +- Authentication (OIDC / JWT) and workspace ACLs +- Server-side workspace delete / compaction +- Realtime push (WebSocket) instead of polling +- Postgres option for metadata at scale diff --git a/package-lock.json b/package-lock.json index c7f946659..0a0629966 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,6 +68,24 @@ "vite": "^7.3.1" } }, + "apps/server": { + "name": "@colanode/server", + "version": "1.0.0", + "dependencies": { + "@aws-sdk/client-s3": "^3.896.0", + "@aws-sdk/s3-request-presigner": "^3.896.0", + "@fastify/websocket": "^11.3.0", + "better-sqlite3": "^12.11.1", + "fastify": "^5.6.1", + "zod": "^4.1.11" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/ws": "^8.18.1", + "tsx": "^4.21.0", + "vitest": "^4.1.2" + } + }, "apps/web": { "name": "@colanode/web", "version": "1.0.0", @@ -253,6 +271,331 @@ "dev": true, "license": "MIT" }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.14.tgz", + "integrity": "sha512-9j0TSNmYVywV+shhBXXkIhaazZwZ+nDSGfG6V47nFLqXdk6mPYk/OJkV4+uRvIg2Ju5F6HTvgM/sfsuTsbAYwg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1081.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1081.0.tgz", + "integrity": "sha512-mHAdLBibsbFwcW2YHrH5sIQlaXjB3zha7Nkooa2gqTlXxRpVJ+ba/v/mg19B6rJeZmOEteRHFz+INOlAbCbxJg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.14", + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/credential-provider-node": "^3.972.64", + "@aws-sdk/middleware-sdk-s3": "^3.972.60", + "@aws-sdk/signature-v4-multi-region": "^3.996.38", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/fetch-http-handler": "^5.6.2", + "@smithy/node-http-handler": "^4.9.2", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.29.tgz", + "integrity": "sha512-yqKcltLbtRh1ubzhRSldIs8jFHNZlyMlgoIccCC0aDVbrB99nXaBdmfr89mK7obWX/NVg4rAMpCpZ6dCDiVBtA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.15", + "@aws-sdk/xml-builder": "^3.972.33", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.29.0", + "@smithy/signature-v4": "^5.6.1", + "@smithy/types": "^4.15.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.55.tgz", + "integrity": "sha512-Ah36tYkqyaVnaHkx7VseoTYrHUmwgBps3V+wnrC1idhIIMGlviH0FtrX9EIPdAlVHvXC7FQZLhmHBRz+pLaiWg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.57.tgz", + "integrity": "sha512-/vp6i5YEliJqRm5k/BDmYjAyRAMTdkjW6UciVRk9oh/0OfDCWeb/ih7hqte4lFvKXkIbsqe9AdK9LQK6NGardw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/fetch-http-handler": "^5.6.2", + "@smithy/node-http-handler": "^4.9.2", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.62", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.62.tgz", + "integrity": "sha512-pQIRiQQs+MUlVnJdWJ7/6KS0WxcLRVfut57OFgwC3cnM1F8mXw3Kh4gAVwj6AtvD6CWx8x6+po4ENRcqe64XrQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/credential-provider-env": "^3.972.55", + "@aws-sdk/credential-provider-http": "^3.972.57", + "@aws-sdk/credential-provider-login": "^3.972.61", + "@aws-sdk/credential-provider-process": "^3.972.55", + "@aws-sdk/credential-provider-sso": "^3.972.61", + "@aws-sdk/credential-provider-web-identity": "^3.972.61", + "@aws-sdk/nested-clients": "^3.997.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/credential-provider-imds": "^4.4.5", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.61", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.61.tgz", + "integrity": "sha512-jtrxWwC7slqxh7DnAWHrwsA3UwCsnlypdYtavGT7EX5p791wxWQys7QzkCZ7JvOMAyylDtPoxyV+ic0zg3rV9g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/nested-clients": "^3.997.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.64", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.64.tgz", + "integrity": "sha512-zyKVYDyMR9VQL/kPi03ygN2vtD9uLMuWRLoJ77KxgZZaS1VlJloI+SzleF9Zg4HWUI+AIu+ZRs8zsJFNqbrxsw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.55", + "@aws-sdk/credential-provider-http": "^3.972.57", + "@aws-sdk/credential-provider-ini": "^3.972.62", + "@aws-sdk/credential-provider-process": "^3.972.55", + "@aws-sdk/credential-provider-sso": "^3.972.61", + "@aws-sdk/credential-provider-web-identity": "^3.972.61", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/credential-provider-imds": "^4.4.5", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.55.tgz", + "integrity": "sha512-x0XjjF0l1WGRtK2vEhTZqCguQuAIZLep9l2+eeEmuxQQjjD3BlGQXY5xADR+l3t576UX+dxRkRtTjEu40l81Vw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.61", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.61.tgz", + "integrity": "sha512-d/V0VRsz73i+PHhbult/tx0Y1+de1SNQVsXkcQCmpfeBq7uODy/RTxNsOLpT9ZVHxcRNzbQFuywLKC33fUMIxA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/nested-clients": "^3.997.29", + "@aws-sdk/token-providers": "3.1081.0", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.61", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.61.tgz", + "integrity": "sha512-Bv4n3NOI6hPy+rmr6Bw9R6LnBVRkcp3ncj2E2IKSYJG+0UkysSitWMvbgndNvMxDw7gE1pQ/ErwkNceuKwj7zQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/nested-clients": "^3.997.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.60", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.60.tgz", + "integrity": "sha512-f8SREKL6uQUYt8fwreiUgaAJQ31/M+UkWfJMDY/TBJC5FdHgNzxuvRFeZ9eMbnf2ydQ3p4pcKEhaLk35DDIPxw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/signature-v4-multi-region": "^3.996.38", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.29.tgz", + "integrity": "sha512-ot6v8J5W8P0w6ryyuIkXP1bHZHTlvwtn83mVCYaBE0GJ6tJX4vPSBx7M98w9O4wmmDruFsDBUMjhEHA+OosUFQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/signature-v4-multi-region": "^3.996.38", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/fetch-http-handler": "^5.6.2", + "@smithy/node-http-handler": "^4.9.2", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1081.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1081.0.tgz", + "integrity": "sha512-u5AMr8XlpN0lKB3zAfbHrlUcnaDRKcWPFwdi/gY8Ku9/8oWMLsEcwwAYZnBnqgRXZ/n92HM8KBjj4R/xOO7qZQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/signature-v4-multi-region": "^3.996.38", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.38.tgz", + "integrity": "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.15", + "@smithy/signature-v4": "^5.6.1", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1081.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1081.0.tgz", + "integrity": "sha512-kduAeI6cL+zqwj3gjPh9LhuX7kBZ83msYxutavaR+UPm5K8J7iThJBvNRAsFNyWTji92CSU8dogUgvi9T0BehA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.29", + "@aws-sdk/nested-clients": "^3.997.29", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.15.tgz", + "integrity": "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.33", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.33.tgz", + "integrity": "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1957,6 +2300,10 @@ "resolved": "scripts", "link": true }, + "node_modules/@colanode/server": { + "resolved": "apps/server", + "link": true + }, "node_modules/@colanode/ui": { "resolved": "packages/ui", "link": true @@ -4442,6 +4789,186 @@ "npm": ">=10" } }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/proxy-addr/node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@fastify/websocket": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.3.0.tgz", + "integrity": "sha512-g89ag4BCcD9YP5wBZXixzoLnuf5j89p/sXFcfpCiv2pdEkYYukBEoK3heVzqsp0EAtszVDc2BBZG0KZqeAShIA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "duplexify": "^4.1.3", + "fastify-plugin": "^6.0.0", + "ws": "^8.16.0" + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -5539,6 +6066,12 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -7692,6 +8225,87 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@smithy/core": { + "version": "3.29.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.1.tgz", + "integrity": "sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.6.tgz", + "integrity": "sha512-B2WQ/PV/H6Jeg3lrIq6bKUfa6Hy01mtK7CGs6lhjzHA6k4aagldH6T6eEjnzKl4HI0cJnAsxfJ19pgb5PV+CVQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.1", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.3.tgz", + "integrity": "sha512-CwCc/7SMTj45y97MUnDTbTaxvtAsiNNRm81z3abROIuMbMsC2Iy5EKfkkVdsKrz8WExQAAMx1EJapq+9j4fFTQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.1", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.9.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.3.tgz", + "integrity": "sha512-qZTa4gQFUo8RM02rk6q5UVTDLNrQ1oS20LsepBzqq1QBVc/EHJ03OOUADcqMZiXHArW+Y7+OGY0BpdTwZRq/Yg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.1", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.2.tgz", + "integrity": "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.1", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.15.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.1.tgz", + "integrity": "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@so-ric/colorspace": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", @@ -9315,6 +9929,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -9901,6 +10525,12 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -10395,6 +11025,15 @@ "node": ">= 4.0.0" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/author-regex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/author-regex/-/author-regex-1.0.0.tgz", @@ -10421,6 +11060,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.17", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", @@ -10668,6 +11327,12 @@ "dev": true, "license": "MIT" }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/bplist-creator": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.0.8.tgz", @@ -12189,6 +12854,18 @@ "node": ">= 0.4" } }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -13961,6 +14638,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -13983,34 +14666,97 @@ "dev": true, "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.0.tgz", + "integrity": "sha512-YV53BAbR3Qwq37wfD1oZ97YJ0nYj6CwfzKXQ38ock9XxI2EnLOdl5psKms6Evook6ACckytZJOaKY0ThmIi1uw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, - "engines": { - "node": ">=8.6.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", + "node_modules/fast-json-stringify/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.1" + "ajv": "^8.0.0" }, - "engines": { - "node": ">= 6" + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, + "node_modules/fast-json-stringify/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -14020,6 +14766,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -14036,11 +14791,59 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastify": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz", + "integrity": "sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -14219,6 +15022,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -16124,6 +16941,25 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -16296,6 +17132,56 @@ "url": "https://github.com/sponsors/dmonad" } }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -18035,6 +18921,15 @@ ], "license": "MIT" }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -18515,6 +19410,43 @@ "node": ">=0.10.0" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -18751,6 +19683,22 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -19109,6 +20057,12 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -19541,6 +20495,15 @@ "node": ">= 6" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", @@ -19847,11 +20810,19 @@ "dev": true, "license": "ISC" }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -19862,7 +20833,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, "license": "MIT" }, "node_modules/rimraf": { @@ -20108,6 +21078,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", @@ -20208,6 +21200,22 @@ "dev": true, "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -20351,6 +21359,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -20645,6 +21659,15 @@ "node": ">= 14" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -20726,6 +21749,15 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -20811,6 +21843,12 @@ "node": ">= 0.10.0" } }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -21689,6 +22727,24 @@ "dev": true, "license": "MIT" }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/tiny-each-async": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/tiny-each-async/-/tiny-each-async-2.0.3.tgz", @@ -21837,6 +22893,15 @@ "node": ">=8.0" } }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/packages/client/src/lib/list-server-workspaces.ts b/packages/client/src/lib/list-server-workspaces.ts new file mode 100644 index 000000000..84973d6c7 --- /dev/null +++ b/packages/client/src/lib/list-server-workspaces.ts @@ -0,0 +1,14 @@ +import type { ServerSyncConfig } from '@colanode/client/services/app-meta'; +import { ServerSyncApiClient } from '@colanode/client/services/workspaces/server-sync-api-client'; +import type { WorkspaceManifest } from '@colanode/client/services/workspaces/workspace-manifest'; + +export type ServerWorkspaceSummary = WorkspaceManifest & { + recordCount: number; +}; + +export const listServerWorkspaces = async ( + config: ServerSyncConfig +): Promise => { + const client = new ServerSyncApiClient(config.baseUrl); + return client.listWorkspaces(); +}; diff --git a/packages/client/src/lib/op-log-export.ts b/packages/client/src/lib/op-log-export.ts new file mode 100644 index 000000000..6628783cf --- /dev/null +++ b/packages/client/src/lib/op-log-export.ts @@ -0,0 +1,263 @@ +import { encodeState } from '@colanode/crdt'; +import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service'; +import { + SyncDocumentUpdateData, + SyncNodeReactionData, + SyncNodeTombstoneData, + SyncNodeUpdateData, +} from '@colanode/core'; + +export type OpLogSyncRecord = + | { kind: 'node.update'; data: SyncNodeUpdateData } + | { kind: 'document.update'; data: SyncDocumentUpdateData } + | { kind: 'node.delete'; data: SyncNodeTombstoneData } + | { kind: 'node.reaction'; data: SyncNodeReactionData }; + +export type ExportCursorUpdates = { + nodeUpdates?: string; + documentUpdates?: string; + tombstones?: string; + nodeReactions?: string; +}; + +export type ExportRecordCounts = { + nodeUpdates: number; + documentUpdates: number; + reactions: number; + deletes: number; +}; + +const EXPORT_BATCH_SIZE = 500; + +export const collectOpLogExportRecords = async ( + workspace: WorkspaceService, + getCursor: (key: string) => Promise +): Promise<{ + records: OpLogSyncRecord[]; + cursors: ExportCursorUpdates; + counts: ExportRecordCounts; +}> => { + const records: OpLogSyncRecord[] = []; + + const nodeUpdateCursor = await exportNodeUpdates(workspace, records, getCursor); + const documentUpdateCursor = await exportDocumentUpdates( + workspace, + records, + getCursor + ); + const tombstoneCursor = await exportTombstones(workspace, records, getCursor); + const reactionCursor = await exportNodeReactions(workspace, records, getCursor); + + const counts: ExportRecordCounts = { + nodeUpdates: 0, + documentUpdates: 0, + reactions: 0, + deletes: 0, + }; + + for (const record of records) { + if (record.kind === 'node.update') counts.nodeUpdates++; + else if (record.kind === 'document.update') counts.documentUpdates++; + else if (record.kind === 'node.reaction') counts.reactions++; + else if (record.kind === 'node.delete') counts.deletes++; + } + + return { + records, + cursors: { + nodeUpdates: nodeUpdateCursor ?? undefined, + documentUpdates: documentUpdateCursor ?? undefined, + tombstones: tombstoneCursor ?? undefined, + nodeReactions: reactionCursor ?? undefined, + }, + counts, + }; +}; + +const exportNodeUpdates = async ( + workspace: WorkspaceService, + records: OpLogSyncRecord[], + getCursor: (key: string) => Promise +): Promise => { + const cursor = (await getCursor('export.node_updates')) ?? ''; + + const rows = await workspace.database + .selectFrom('node_updates as u') + .innerJoin('nodes as n', 'n.id', 'u.node_id') + .select([ + 'u.id as id', + 'u.node_id as nodeId', + 'u.data as data', + 'u.created_at as createdAt', + 'n.root_id as rootId', + 'n.created_by as createdBy', + ]) + .where('u.id', '>', cursor) + .orderBy('u.id', 'asc') + .limit(EXPORT_BATCH_SIZE) + .execute(); + + let lastId: string | null = null; + for (const row of rows) { + records.push({ + kind: 'node.update', + data: { + id: row.id, + nodeId: row.nodeId, + rootId: row.rootId, + workspaceId: workspace.workspaceId, + revision: '0', + data: encodeState(row.data), + createdAt: row.createdAt, + createdBy: row.createdBy, + mergedUpdates: null, + }, + }); + lastId = row.id; + } + + return lastId; +}; + +const exportDocumentUpdates = async ( + workspace: WorkspaceService, + records: OpLogSyncRecord[], + getCursor: (key: string) => Promise +): Promise => { + const cursor = (await getCursor('export.document_updates')) ?? ''; + + const rows = await workspace.database + .selectFrom('document_updates as u') + .innerJoin('documents as d', 'd.id', 'u.document_id') + .select([ + 'u.id as id', + 'u.document_id as documentId', + 'u.data as data', + 'u.created_at as createdAt', + 'd.created_by as createdBy', + ]) + .where('u.id', '>', cursor) + .orderBy('u.id', 'asc') + .limit(EXPORT_BATCH_SIZE) + .execute(); + + let lastId: string | null = null; + for (const row of rows) { + records.push({ + kind: 'document.update', + data: { + id: row.id, + documentId: row.documentId, + data: encodeState(row.data), + revision: '0', + createdBy: row.createdBy, + createdAt: row.createdAt, + mergedUpdates: null, + }, + }); + lastId = row.id; + } + + return lastId; +}; + +const exportTombstones = async ( + workspace: WorkspaceService, + records: OpLogSyncRecord[], + getCursor: (key: string) => Promise +): Promise => { + const cursor = (await getCursor('export.tombstones')) ?? ''; + + const rows = await workspace.database + .selectFrom('tombstones') + .selectAll() + .orderBy('deleted_at', 'asc') + .orderBy('id', 'asc') + .execute(); + + let lastCursor: string | null = null; + let count = 0; + for (const row of rows) { + const rowCursor = `${row.deleted_at}|${row.id}`; + if (rowCursor <= cursor) { + continue; + } + + let rootId = row.id; + let fileExtension: string | undefined; + try { + const parsed = JSON.parse(row.data) as { + root_id?: string; + type?: string; + attributes?: string | { extension?: string }; + }; + if (parsed?.root_id) { + rootId = parsed.root_id; + } + if (parsed?.type === 'file') { + const attrs = + typeof parsed.attributes === 'string' + ? (JSON.parse(parsed.attributes) as { extension?: string }) + : parsed.attributes; + if (attrs && typeof attrs.extension === 'string') { + fileExtension = attrs.extension; + } + } + } catch { + // keep defaults + } + + records.push({ + kind: 'node.delete', + data: { + id: row.id, + rootId, + workspaceId: workspace.workspaceId, + deletedBy: workspace.userId, + deletedAt: row.deleted_at, + revision: '0', + fileExtension, + }, + }); + + lastCursor = rowCursor; + count++; + if (count >= EXPORT_BATCH_SIZE) { + break; + } + } + + return lastCursor; +}; + +const exportNodeReactions = async ( + workspace: WorkspaceService, + records: OpLogSyncRecord[], + getCursor: (key: string) => Promise +): Promise => { + const cursor = (await getCursor('export.node_reactions')) ?? ''; + + const rows = await workspace.database + .selectFrom('node_reaction_updates') + .selectAll() + .where('id', '>', cursor) + .orderBy('id', 'asc') + .limit(EXPORT_BATCH_SIZE) + .execute(); + + let lastId: string | null = null; + for (const row of rows) { + let data: SyncNodeReactionData | null = null; + try { + data = JSON.parse(row.data) as SyncNodeReactionData; + } catch { + lastId = row.id; + continue; + } + + records.push({ kind: 'node.reaction', data }); + lastId = row.id; + } + + return lastId; +}; diff --git a/packages/client/src/lib/op-log-import.ts b/packages/client/src/lib/op-log-import.ts new file mode 100644 index 000000000..91c935bc1 --- /dev/null +++ b/packages/client/src/lib/op-log-import.ts @@ -0,0 +1,110 @@ +import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service'; +import { + SyncDocumentUpdateData, + SyncNodeReactionData, + SyncNodeTombstoneData, + SyncNodeUpdateData, +} from '@colanode/core'; + +import { OpLogSyncRecord } from './op-log-export.js'; + +export type OpLogImportSummary = { + nodeUpdates: number; + documentUpdates: number; + reactions: number; + deletes: number; +}; + +export const applyOpLogRecords = async ( + workspace: WorkspaceService, + records: OpLogSyncRecord[], + options?: { + deleteRemoteFile?: (fileId: string, extension: string) => Promise; + } +): Promise<{ summary: OpLogImportSummary; semanticWarnings: string[] }> => { + const nodeUpdates: SyncNodeUpdateData[] = []; + const documentUpdates: SyncDocumentUpdateData[] = []; + const nodeDeletes: SyncNodeTombstoneData[] = []; + const nodeReactions: SyncNodeReactionData[] = []; + + for (const record of records) { + if (record.kind === 'node.update') { + nodeUpdates.push(record.data); + } else if (record.kind === 'document.update') { + documentUpdates.push(record.data); + } else if (record.kind === 'node.delete') { + nodeDeletes.push(record.data); + } else if (record.kind === 'node.reaction') { + nodeReactions.push(record.data); + } + } + + nodeUpdates.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + documentUpdates.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + nodeReactions.sort((a, b) => + a.revision < b.revision ? -1 : a.revision > b.revision ? 1 : 0 + ); + nodeDeletes.sort((a, b) => + a.deletedAt < b.deletedAt ? -1 : a.deletedAt > b.deletedAt ? 1 : 0 + ); + + const semanticWarnings: string[] = []; + + for (const data of nodeUpdates) { + await workspace.nodes.applyRemoteNodeUpdate(data); + } + for (const data of documentUpdates) { + await workspace.documents.applyRemoteDocumentUpdate(data); + } + for (const data of nodeReactions) { + await workspace.nodeReactions.applyRemoteNodeReaction(data); + } + for (const data of nodeDeletes) { + const existing = await workspace.database + .selectFrom('nodes') + .select(['id', 'updated_at', 'attributes']) + .where('id', '=', data.id) + .executeTakeFirst(); + + if (existing?.updated_at && data.deletedAt) { + const localUpdated = Date.parse(existing.updated_at); + const remoteDeleted = Date.parse(data.deletedAt); + if ( + Number.isFinite(localUpdated) && + Number.isFinite(remoteDeleted) && + localUpdated > remoteDeleted + ) { + let nodeName = existing.id; + try { + const attributes = JSON.parse(existing.attributes) as { + name?: string; + }; + if (attributes.name?.trim()) { + nodeName = attributes.name.trim(); + } + } catch { + // keep id fallback + } + + semanticWarnings.push( + `"${nodeName}" was deleted on another device after local edits` + ); + } + } + + await workspace.nodes.applyRemoteNodeDelete(data); + if (data.fileExtension && options?.deleteRemoteFile) { + await options.deleteRemoteFile(data.id, data.fileExtension); + } + } + + return { + summary: { + nodeUpdates: nodeUpdates.length, + documentUpdates: documentUpdates.length, + reactions: nodeReactions.length, + deletes: nodeDeletes.length, + }, + semanticWarnings, + }; +}; diff --git a/packages/client/src/lib/sync-state.ts b/packages/client/src/lib/sync-state.ts new file mode 100644 index 000000000..4c4a71e77 --- /dev/null +++ b/packages/client/src/lib/sync-state.ts @@ -0,0 +1,31 @@ +import { Kysely } from 'kysely'; + +import { WorkspaceDatabaseSchema } from '@colanode/client/databases'; + +export const getSyncState = async ( + database: Kysely, + key: string +): Promise => { + const row = await database + .selectFrom('sync_state') + .select('value') + .where('key', '=', key) + .executeTakeFirst(); + + return row?.value ?? null; +}; + +export const setSyncState = async ( + database: Kysely, + key: string, + value: string +): Promise => { + const now = new Date().toISOString(); + await database + .insertInto('sync_state') + .values({ key, value, created_at: now, updated_at: now }) + .onConflict((cb) => + cb.column('key').doUpdateSet({ value, updated_at: now }) + ) + .execute(); +}; diff --git a/packages/client/src/services/app-meta.ts b/packages/client/src/services/app-meta.ts index 90006050c..eb83e5081 100644 --- a/packages/client/src/services/app-meta.ts +++ b/packages/client/src/services/app-meta.ts @@ -16,6 +16,11 @@ export interface LocalSyncConfig { storage: LocalSyncStorageConfig; } +export interface ServerSyncConfig { + deviceId: string; + baseUrl: string; +} + export interface AppMeta { type: 'desktop' | 'web' | 'mobile'; platform: string; @@ -23,4 +28,6 @@ export interface AppMeta { // When set (only meaningful in localOnly mode), peer-to-peer sync is enabled // through the configured object storage. localSync?: LocalSyncConfig; + // When set, workspace data syncs through a Colanode sync API server. + serverSync?: ServerSyncConfig; } diff --git a/packages/client/src/services/app-service.ts b/packages/client/src/services/app-service.ts index 26164f337..d900e25e1 100644 --- a/packages/client/src/services/app-service.ts +++ b/packages/client/src/services/app-service.ts @@ -6,6 +6,8 @@ import { SelectWorkspace, appDatabaseMigrations, } from '@colanode/client/databases/app'; +import { listServerWorkspaces } from '@colanode/client/lib/list-server-workspaces'; +import { setSyncState } from '@colanode/client/lib/sync-state'; import { Mediator } from '@colanode/client/handlers'; import { eventBus } from '@colanode/client/lib/event-bus'; import { mapAccount, mapWorkspace } from '@colanode/client/lib/mappers'; @@ -14,7 +16,7 @@ import { ensurePageMetaTypes, upgradeDefaultPageMetaTypeSchema } from '@colanode import { ensureScheduledTaskMetaType } from '@colanode/client/lib/scheduled-task-meta-type'; import { ensureTodoListSystemDatabase } from '@colanode/client/lib/todo-list-system'; import { AccountService } from '@colanode/client/services/accounts/account-service'; -import { AppMeta, LocalSyncConfig } from '@colanode/client/services/app-meta'; +import { AppMeta, LocalSyncConfig, ServerSyncConfig } from '@colanode/client/services/app-meta'; import { AssetService } from '@colanode/client/services/asset-service'; import { FileSystem } from '@colanode/client/services/file-system'; import { JobService } from '@colanode/client/services/job-service'; @@ -127,6 +129,14 @@ export class AppService { } } + public async reloadServerSync(config: ServerSyncConfig | null): Promise { + this.meta.serverSync = config ?? undefined; + + for (const workspace of this.workspaces.values()) { + await workspace.applyServerSync(config); + } + } + public async replaceWorkspaceIdentity( localUserId: string, manifest: { @@ -215,6 +225,35 @@ export class AppService { return this.initWorkspace(updatedRow); } + public async joinServerWorkspace( + localUserId: string, + remoteWorkspaceId: string + ): Promise { + if (!this.meta.serverSync) { + throw new Error('Server sync is not configured'); + } + + const remoteWorkspaces = await listServerWorkspaces(this.meta.serverSync); + const manifest = remoteWorkspaces.find( + (item) => item.workspaceId === remoteWorkspaceId + ); + if (!manifest) { + throw new Error('Remote workspace not found on the sync server'); + } + + if (!manifest.userId) { + throw new Error( + 'Remote workspace is missing user identity on the server. Open the source device once to register it, then retry.' + ); + } + + const workspace = await this.replaceWorkspaceIdentity(localUserId, manifest); + await setSyncState(workspace.database, 'server.last_pulled_seq', '0'); + await workspace.cloudSync?.importRemote(); + await workspace.cloudSync?.triggerSyncNow(); + return workspace; + } + public async init(): Promise { await this.migrate(); diff --git a/packages/client/src/services/workspaces/server-sync-api-client.ts b/packages/client/src/services/workspaces/server-sync-api-client.ts new file mode 100644 index 000000000..572c8d3e4 --- /dev/null +++ b/packages/client/src/services/workspaces/server-sync-api-client.ts @@ -0,0 +1,181 @@ +import ky from 'ky'; +import { + ServerSyncPullRecord, + ServerSyncPullResponse, + ServerSyncPushResponse, + ServerSyncWorkspaceManifest, + ServerSyncWorkspaceSummary, +} from '@colanode/core'; + +import { OpLogSyncRecord } from '@colanode/client/lib/op-log-export'; + +const bytesToBase64 = (bytes: Uint8Array): string => { + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +}; + +const base64ToBytes = (base64: string): Uint8Array => { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +}; +type ApiEnvelope = + | { success: true; requestId: string; data: T } + | { success: false; requestId: string; error: { code: string; message: string } }; + +export type ClientServerSyncPullRecord = Omit & { + record: OpLogSyncRecord; +}; +export type ClientServerSyncPullResponse = Omit & { + records: ClientServerSyncPullRecord[]; +}; +export type { + ServerSyncPushResponse, + ServerSyncWorkspaceManifest, + ServerSyncWorkspaceSummary, +}; + +export class ServerSyncApiClient { + constructor(private readonly baseUrl: string) {} + + public async push(input: { + workspaceId: string; + deviceId: string; + batchId: string; + records: OpLogSyncRecord[]; + clientCursor?: { lastPulledServerSeq: number }; + }): Promise { + const response = await ky + .post(`${this.baseUrl}/v1/sync/push`, { json: input }) + .json>(); + + if (!response.success) { + throw new Error(response.error.message); + } + + return response.data; + } + + public async pull(input: { + workspaceId: string; + deviceId: string; + afterSeq: number; + limit?: number; + }): Promise { + const response = await ky + .get(`${this.baseUrl}/v1/sync/pull`, { + searchParams: { + workspaceId: input.workspaceId, + deviceId: input.deviceId, + afterSeq: String(input.afterSeq), + limit: String(input.limit ?? 200), + }, + }) + .json>(); + + if (!response.success) { + throw new Error(response.error.message); + } + + return response.data; + } + + public async uploadFile(input: { + workspaceId: string; + fileId: string; + extension: string; + contentType?: string; + sha256: string; + body: Uint8Array; + }): Promise { + const response = await ky + .post(`${this.baseUrl}/v1/files/upload`, { + json: { + workspaceId: input.workspaceId, + fileId: input.fileId, + extension: input.extension, + contentType: input.contentType, + sha256: input.sha256, + bodyBase64: bytesToBase64(input.body), + }, + }) + .json>(); + + if (!response.success) { + throw new Error(response.error.message); + } + } + + public async downloadFile(input: { + workspaceId: string; + fileId: string; + extension: string; + }): Promise { + try { + const response = await ky + .get(`${this.baseUrl}/v1/files/download`, { + searchParams: { + workspaceId: input.workspaceId, + fileId: input.fileId, + extension: input.extension, + }, + }) + .json>(); + + if (!response.success) { + throw new Error(response.error.message); + } + + return base64ToBytes(response.data.bodyBase64); + } catch { + return null; + } + } + + public async listWorkspaces(): Promise { + const response = await ky + .get(`${this.baseUrl}/v1/sync/workspaces`) + .json>(); + + if (!response.success) { + throw new Error(response.error.message); + } + + return response.data.workspaces; + } + + public async upsertWorkspaceManifest( + manifest: ServerSyncWorkspaceManifest + ): Promise { + const response = await ky + .put(`${this.baseUrl}/v1/sync/workspaces/${manifest.workspaceId}/manifest`, { + json: manifest, + }) + .json>(); + + if (!response.success) { + throw new Error(response.error.message); + } + } + + public async deleteWorkspace(workspaceId: string): Promise<{ + deletedRecords: number; + deletedFiles: number; + }> { + const response = await ky + .delete(`${this.baseUrl}/v1/sync/workspaces/${workspaceId}`) + .json>(); + + if (!response.success) { + throw new Error(response.error.message); + } + + return response.data; + } +} diff --git a/packages/client/src/services/workspaces/server-sync-realtime.ts b/packages/client/src/services/workspaces/server-sync-realtime.ts new file mode 100644 index 000000000..01f69106a --- /dev/null +++ b/packages/client/src/services/workspaces/server-sync-realtime.ts @@ -0,0 +1,134 @@ +import { createDebugger } from '@colanode/core'; + +const debug = createDebugger('client:service:server-sync-realtime'); + +export type SyncRealtimeMessage = + | { + type: 'connected'; + workspaceId: string; + deviceId: string; + } + | { + type: 'sync.updated'; + workspaceId: string; + fromDeviceId: string; + watermarkSeq: number; + }; + +export const buildSyncWebSocketUrl = ( + baseUrl: string, + workspaceId: string, + deviceId: string +): string => { + const params = new URLSearchParams({ + workspaceId, + deviceId, + }); + + if (!baseUrl) { + return `/v1/sync/ws?${params.toString()}`; + } + + const httpUrl = new URL(baseUrl); + httpUrl.protocol = httpUrl.protocol === 'https:' ? 'wss:' : 'ws:'; + httpUrl.pathname = '/v1/sync/ws'; + httpUrl.search = params.toString(); + return httpUrl.toString(); +}; + +export class ServerSyncRealtimeClient { + private socket: WebSocket | null = null; + private reconnectTimer: ReturnType | null = null; + private reconnectAttempt = 0; + private disposed = false; + + constructor( + private readonly baseUrl: string, + private readonly workspaceId: string, + private readonly deviceId: string, + private readonly onSyncUpdated: (watermarkSeq: number) => void + ) {} + + public connect(): void { + if (this.disposed) { + return; + } + + this.clearReconnectTimer(); + + const url = buildSyncWebSocketUrl( + this.baseUrl, + this.workspaceId, + this.deviceId + ); + + try { + this.socket = new WebSocket(url); + } catch (error) { + debug(`Failed to open websocket: ${error}`); + this.scheduleReconnect(); + return; + } + + this.socket.addEventListener('open', () => { + this.reconnectAttempt = 0; + debug(`Realtime connected for workspace ${this.workspaceId}`); + }); + + this.socket.addEventListener('message', (event) => { + try { + const message = JSON.parse(String(event.data)) as SyncRealtimeMessage; + if ( + message.type === 'sync.updated' && + message.workspaceId === this.workspaceId && + message.fromDeviceId !== this.deviceId + ) { + this.onSyncUpdated(message.watermarkSeq); + } + } catch (error) { + debug(`Failed to parse websocket message: ${error}`); + } + }); + + this.socket.addEventListener('close', () => { + this.socket = null; + if (!this.disposed) { + this.scheduleReconnect(); + } + }); + + this.socket.addEventListener('error', () => { + this.socket?.close(); + }); + } + + public dispose(): void { + this.disposed = true; + this.clearReconnectTimer(); + if (this.socket) { + this.socket.close(); + this.socket = null; + } + } + + private scheduleReconnect(): void { + if (this.disposed || this.reconnectTimer) { + return; + } + + const delayMs = Math.min(30_000, 1_000 * 2 ** this.reconnectAttempt); + this.reconnectAttempt += 1; + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delayMs); + } + + private clearReconnectTimer(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } +} diff --git a/packages/client/src/services/workspaces/server-sync-service.ts b/packages/client/src/services/workspaces/server-sync-service.ts new file mode 100644 index 000000000..2214293e2 --- /dev/null +++ b/packages/client/src/services/workspaces/server-sync-service.ts @@ -0,0 +1,523 @@ +import { eventBus } from '@colanode/client/lib/event-bus'; +import { collectOpLogExportRecords } from '@colanode/client/lib/op-log-export'; +import { applyOpLogRecords } from '@colanode/client/lib/op-log-import'; +import { getSyncState, setSyncState } from '@colanode/client/lib/sync-state'; +import { ServerSyncConfig } from '@colanode/client/services/app-meta'; +import { ServerSyncApiClient } from '@colanode/client/services/workspaces/server-sync-api-client'; +import { ServerSyncRealtimeClient } from '@colanode/client/services/workspaces/server-sync-realtime'; +import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service'; +import { + LocalSyncExportSummary, + LocalSyncStatus, +} from '@colanode/client/types/workspaces'; +import { DownloadStatus } from '@colanode/client/types/files'; +import { Event } from '@colanode/client/types/events'; +import { createDebugger, generateId, IdType } from '@colanode/core'; +import { sha256 } from 'js-sha256'; + +const debug = createDebugger('client:service:server-sync'); +const FALLBACK_SYNC_INTERVAL_MS = 60_000; +const EXPORT_DEBOUNCE_MS = 500; +const IMPORT_DEBOUNCE_MS = 300; +const EXPORT_SUPPRESS_AFTER_IMPORT_MS = 2_000; +const EXPORT_BATCH_SIZE = 500; + +const LOCAL_CHANGE_EVENT_TYPES = new Set([ + 'node.created', + 'node.updated', + 'node.deleted', + 'document.updated', + 'document.update.created', + 'node.reaction.created', + 'node.reaction.deleted', +]); + +export class ServerSyncService { + private readonly workspace: WorkspaceService; + private readonly config: ServerSyncConfig; + private readonly client: ServerSyncApiClient; + private readonly deviceId: string; + + private timer: ReturnType | null = null; + private exportDebounceTimer: ReturnType | null = null; + private importDebounceTimer: ReturnType | null = null; + private realtime: ServerSyncRealtimeClient | null = null; + private eventSubscriptionId: string | null = null; + private suppressExportUntil = 0; + private running = false; + private initialized = false; + private status: LocalSyncStatus; + + constructor(workspace: WorkspaceService, config: ServerSyncConfig) { + this.workspace = workspace; + this.config = config; + this.deviceId = config.deviceId; + this.client = new ServerSyncApiClient(config.baseUrl); + this.status = { + enabled: true, + deviceId: this.deviceId, + storageDescription: `server: ${config.baseUrl}`, + isRunning: false, + paused: false, + lastExportAt: null, + lastExportError: null, + lastExportSummary: null, + lastImportAt: null, + lastImportError: null, + lastImportSummary: null, + identityDrift: null, + lastSemanticWarning: null, + }; + } + + public getStatus(): LocalSyncStatus { + return { ...this.status }; + } + + private updateStatus(patch: Partial): void { + this.status = { ...this.status, ...patch }; + eventBus.publish({ + type: 'local.sync.status.updated', + workspace: { + workspaceId: this.workspace.workspaceId, + userId: this.workspace.userId, + accountId: this.workspace.accountId, + }, + status: { ...this.status }, + }); + } + + public async init(): Promise { + if (this.initialized) { + return; + } + + this.initialized = true; + await this.registerWorkspaceManifest(); + await this.sync(); + this.startRealtime(); + this.subscribeLocalChanges(); + this.startFallbackTimer(); + } + + public destroy(): void { + this.stopFallbackTimer(); + this.clearDebounceTimers(); + this.unsubscribeLocalChanges(); + this.realtime?.dispose(); + this.realtime = null; + this.updateStatus({ enabled: false, isRunning: false }); + } + + public async triggerSyncNow(): Promise { + await this.sync(true); + } + + public setPaused(paused: boolean): void { + if (this.status.paused === paused) { + return; + } + + this.updateStatus({ paused }); + if (paused) { + this.stopFallbackTimer(); + this.realtime?.dispose(); + this.realtime = null; + return; + } + + this.startRealtime(); + this.startFallbackTimer(); + void this.sync(); + } + + public async sync(force = false): Promise { + if (!this.initialized || this.running) { + return; + } + if (!force && this.status.paused) { + return; + } + + this.running = true; + this.updateStatus({ isRunning: true }); + try { + await this.importRemote(); + await this.exportLocal(); + } catch (error) { + debug(`Server sync cycle failed: ${error}`); + } finally { + this.running = false; + this.updateStatus({ isRunning: false }); + } + } + + public async exportLocal(): Promise { + const summary: LocalSyncExportSummary = { + nodeUpdates: 0, + documentUpdates: 0, + reactions: 0, + deletes: 0, + files: 0, + }; + + try { + const exported = await collectOpLogExportRecords( + this.workspace, + (key) => getSyncState(this.workspace.database, key) + ); + + summary.nodeUpdates = exported.counts.nodeUpdates; + summary.documentUpdates = exported.counts.documentUpdates; + summary.reactions = exported.counts.reactions; + summary.deletes = exported.counts.deletes; + + if (exported.records.length > 0) { + const batchId = generateId(IdType.Update); + const lastPulled = await getSyncState( + this.workspace.database, + 'server.last_pulled_seq' + ); + + await this.client.push({ + workspaceId: this.workspace.workspaceId, + deviceId: this.deviceId, + batchId, + records: exported.records, + clientCursor: lastPulled + ? { lastPulledServerSeq: Number(lastPulled) } + : undefined, + }); + + if (exported.cursors.nodeUpdates) { + await setSyncState( + this.workspace.database, + 'export.node_updates', + exported.cursors.nodeUpdates + ); + } + if (exported.cursors.documentUpdates) { + await setSyncState( + this.workspace.database, + 'export.document_updates', + exported.cursors.documentUpdates + ); + } + if (exported.cursors.tombstones) { + await setSyncState( + this.workspace.database, + 'export.tombstones', + exported.cursors.tombstones + ); + } + if (exported.cursors.nodeReactions) { + await setSyncState( + this.workspace.database, + 'export.node_reactions', + exported.cursors.nodeReactions + ); + } + } + + summary.files = await this.exportFiles(); + + this.updateStatus({ + lastExportAt: new Date().toISOString(), + lastExportError: null, + lastExportSummary: summary, + }); + } catch (error) { + this.updateStatus({ + lastExportAt: new Date().toISOString(), + lastExportError: error instanceof Error ? error.message : String(error), + lastExportSummary: summary, + }); + throw error; + } + } + + private async exportFiles(): Promise { + const cursor = + (await getSyncState(this.workspace.database, 'export.files')) ?? ''; + + const rows = await this.workspace.database + .selectFrom('local_files as lf') + .innerJoin('nodes as n', 'n.id', 'lf.id') + .select([ + 'lf.id as id', + 'lf.path as path', + 'n.attributes as attributes', + ]) + .where('n.type', '=', 'file') + .where('lf.id', '>', cursor) + .where('lf.download_status', '=', DownloadStatus.Completed) + .orderBy('lf.id', 'asc') + .limit(EXPORT_BATCH_SIZE) + .execute(); + + if (rows.length === 0) { + return 0; + } + + let lastUploadedId: string | null = null; + let uploaded = 0; + + for (const row of rows) { + let extension = ''; + try { + const parsed = JSON.parse(row.attributes) as { extension?: string }; + extension = parsed?.extension ?? ''; + } catch { + // keep empty extension + } + + try { + const data = await this.workspace.account.app.fs.readFile(row.path); + await this.uploadFile(row.id, extension, data); + lastUploadedId = row.id; + uploaded++; + } catch (error) { + debug(`Failed to upload file ${row.id}: ${error}`); + break; + } + } + + if (lastUploadedId) { + await setSyncState( + this.workspace.database, + 'export.files', + lastUploadedId + ); + } + + return uploaded; + } + + public async importRemote(): Promise { + this.suppressExportUntil = Date.now() + EXPORT_SUPPRESS_AFTER_IMPORT_MS; + + try { + let afterSeq = Number( + (await getSyncState(this.workspace.database, 'server.last_pulled_seq')) ?? + '0' + ); + let importedSummary = { + nodeUpdates: 0, + documentUpdates: 0, + reactions: 0, + deletes: 0, + }; + const semanticWarnings: string[] = []; + + while (true) { + const page = await this.client.pull({ + workspaceId: this.workspace.workspaceId, + deviceId: this.deviceId, + afterSeq, + limit: 200, + }); + + if (page.records.length === 0) { + afterSeq = page.watermarkSeq; + break; + } + + const records = page.records.map((item) => item.record); + const applied = await applyOpLogRecords(this.workspace, records, { + deleteRemoteFile: (fileId, extension) => + this.deleteRemoteFile(fileId, extension), + }); + + importedSummary = { + nodeUpdates: + importedSummary.nodeUpdates + applied.summary.nodeUpdates, + documentUpdates: + importedSummary.documentUpdates + applied.summary.documentUpdates, + reactions: importedSummary.reactions + applied.summary.reactions, + deletes: importedSummary.deletes + applied.summary.deletes, + }; + semanticWarnings.push(...applied.semanticWarnings); + + afterSeq = page.nextAfterSeq; + await setSyncState( + this.workspace.database, + 'server.last_pulled_seq', + String(afterSeq) + ); + + if (!page.hasMore) { + break; + } + } + + this.updateStatus({ + lastImportAt: new Date().toISOString(), + lastImportError: null, + lastSemanticWarning: + semanticWarnings.length > 0 + ? semanticWarnings.slice(0, 3).join(' · ') + : null, + lastImportSummary: importedSummary, + }); + } catch (error) { + this.updateStatus({ + lastImportAt: new Date().toISOString(), + lastImportError: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + public async uploadFile( + fileId: string, + extension: string, + data: Uint8Array | Buffer + ): Promise { + const bytes = Buffer.isBuffer(data) ? data : Buffer.from(data); + await this.client.uploadFile({ + workspaceId: this.workspace.workspaceId, + fileId, + extension, + sha256: sha256(bytes), + body: bytes, + }); + } + + public async downloadFile( + fileId: string, + extension: string + ): Promise { + return this.client.downloadFile({ + workspaceId: this.workspace.workspaceId, + fileId, + extension, + }); + } + + public async deleteRemoteFile( + _fileId: string, + _extension: string + ): Promise { + // File delete API is not implemented on the server yet. + } + + private async registerWorkspaceManifest(): Promise { + try { + await this.client.upsertWorkspaceManifest({ + workspaceId: this.workspace.workspaceId, + userId: this.workspace.userId, + name: this.workspace.workspace.name, + createdAt: new Date().toISOString(), + createdByDeviceId: this.deviceId, + }); + } catch (error) { + debug(`Failed to register workspace manifest: ${error}`); + } + } + + private startRealtime(): void { + if (this.status.paused || this.realtime) { + return; + } + + this.realtime = new ServerSyncRealtimeClient( + this.config.baseUrl, + this.workspace.workspaceId, + this.deviceId, + () => { + this.scheduleImport(); + } + ); + this.realtime.connect(); + } + + private subscribeLocalChanges(): void { + if (this.eventSubscriptionId) { + return; + } + + this.eventSubscriptionId = eventBus.subscribe((event) => { + if (!LOCAL_CHANGE_EVENT_TYPES.has(event.type)) { + return; + } + + if ( + !('workspace' in event) || + event.workspace.userId !== this.workspace.userId + ) { + return; + } + + this.scheduleExport(); + }); + } + + private unsubscribeLocalChanges(): void { + if (!this.eventSubscriptionId) { + return; + } + + eventBus.unsubscribe(this.eventSubscriptionId); + this.eventSubscriptionId = null; + } + + private scheduleExport(): void { + if (this.status.paused || Date.now() < this.suppressExportUntil) { + return; + } + + if (this.exportDebounceTimer) { + clearTimeout(this.exportDebounceTimer); + } + + this.exportDebounceTimer = setTimeout(() => { + this.exportDebounceTimer = null; + void this.exportLocal(); + }, EXPORT_DEBOUNCE_MS); + } + + private scheduleImport(): void { + if (this.status.paused) { + return; + } + + if (this.importDebounceTimer) { + clearTimeout(this.importDebounceTimer); + } + + this.importDebounceTimer = setTimeout(() => { + this.importDebounceTimer = null; + void this.importRemote(); + }, IMPORT_DEBOUNCE_MS); + } + + private startFallbackTimer(): void { + if (this.timer || this.status.paused) { + return; + } + + this.timer = setInterval(() => { + void this.sync(); + }, FALLBACK_SYNC_INTERVAL_MS); + } + + private stopFallbackTimer(): void { + if (!this.timer) { + return; + } + + clearInterval(this.timer); + this.timer = null; + } + + private clearDebounceTimers(): void { + if (this.exportDebounceTimer) { + clearTimeout(this.exportDebounceTimer); + this.exportDebounceTimer = null; + } + if (this.importDebounceTimer) { + clearTimeout(this.importDebounceTimer); + this.importDebounceTimer = null; + } + } +} diff --git a/packages/client/src/services/workspaces/workspace-service.ts b/packages/client/src/services/workspaces/workspace-service.ts index 133603078..35eb2d01c 100644 --- a/packages/client/src/services/workspaces/workspace-service.ts +++ b/packages/client/src/services/workspaces/workspace-service.ts @@ -8,8 +8,9 @@ import { import { eventBus } from '@colanode/client/lib/event-bus'; import { mapMetadata } from '@colanode/client/lib/mappers'; import { AccountService } from '@colanode/client/services/accounts/account-service'; -import { LocalSyncConfig } from '@colanode/client/services/app-meta'; +import { LocalSyncConfig, ServerSyncConfig } from '@colanode/client/services/app-meta'; import { CloudSyncService } from '@colanode/client/services/workspaces/cloud-sync-service'; +import { ServerSyncService } from '@colanode/client/services/workspaces/server-sync-service'; import { CollaborationService } from '@colanode/client/services/workspaces/collaboration-service'; import { DocumentService } from '@colanode/client/services/workspaces/document-service'; import { FileService } from '@colanode/client/services/workspaces/file-service'; @@ -26,6 +27,8 @@ import { createDebugger, WorkspaceRole, WorkspaceStatus } from '@colanode/core'; const debug = createDebugger('desktop:service:workspace'); +export type WorkspaceRemoteSync = CloudSyncService | ServerSyncService; + export class WorkspaceService { public readonly workspace: Workspace; public readonly database: Kysely; @@ -41,7 +44,7 @@ export class WorkspaceService { public readonly synchronizer: SyncService; public readonly radar: RadarService; public readonly nodeCounters: NodeCountersService; - public cloudSync: CloudSyncService | null = null; + public cloudSync: WorkspaceRemoteSync | null = null; private readonly workspaceFilesCleanJobScheduleId: string; @@ -119,6 +122,20 @@ export class WorkspaceService { await this.cloudSync.init(); } + public async applyServerSync(config: ServerSyncConfig | null): Promise { + if (this.cloudSync) { + this.cloudSync.destroy(); + this.cloudSync = null; + } + + if (!config) { + return; + } + + this.cloudSync = new ServerSyncService(this, config); + await this.cloudSync.init(); + } + public async init() { await this.migrate(); @@ -131,8 +148,11 @@ export class WorkspaceService { await this.radar.init(); await this.files.init(); + const serverSync = this.account.app.meta.serverSync; const localSync = this.account.app.meta.localSync; - if (this.account.app.meta.localOnly && localSync) { + if (serverSync) { + await this.applyServerSync(serverSync); + } else if (this.account.app.meta.localOnly && localSync) { this.cloudSync = new CloudSyncService(this, localSync); await this.cloudSync.init(); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9648da924..48341bc03 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -47,3 +47,4 @@ export * from './types/avatars'; export * from './types/build'; export * from './lib/servers'; export * from './types/auth'; +export * from './types/server-sync'; diff --git a/packages/core/src/types/server-sync.ts b/packages/core/src/types/server-sync.ts new file mode 100644 index 000000000..3334188a0 --- /dev/null +++ b/packages/core/src/types/server-sync.ts @@ -0,0 +1,76 @@ +export type ServerSyncOpLogRecord = + | { + kind: 'node.update'; + data: Record; + } + | { + kind: 'document.update'; + data: Record; + } + | { + kind: 'node.delete'; + data: Record; + } + | { + kind: 'node.reaction'; + data: Record; + }; + +export type ServerSyncPushRequest = { + workspaceId: string; + deviceId: string; + batchId: string; + clientTime?: string; + clientCursor?: { + lastPulledServerSeq: number; + }; + records: ServerSyncOpLogRecord[]; +}; + +export type ServerSyncPushResponse = { + acceptedCount: number; + duplicateCount: number; + serverSeqRange: { + from: number | null; + to: number | null; + }; + serverTime: string; +}; + +export type ServerSyncPullQuery = { + workspaceId: string; + deviceId: string; + afterSeq: number; + limit: number; +}; + +export type ServerSyncPullRecord = { + serverSeq: number; + workspaceId: string; + fromDeviceId: string; + kind: ServerSyncOpLogRecord['kind']; + id: string; + rootId?: string; + happenedAt: string; + record: ServerSyncOpLogRecord; +}; + +export type ServerSyncPullResponse = { + records: ServerSyncPullRecord[]; + nextAfterSeq: number; + hasMore: boolean; + watermarkSeq: number; +}; + +export type ServerSyncWorkspaceManifest = { + workspaceId: string; + userId: string; + name: string; + createdAt: string; + createdByDeviceId: string; +}; + +export type ServerSyncWorkspaceSummary = ServerSyncWorkspaceManifest & { + recordCount: number; + lastActivityAt: string | null; +}; diff --git a/packages/ui/src/components/workspaces/workspace-create.tsx b/packages/ui/src/components/workspaces/workspace-create.tsx index f8d0307e5..04ab65adb 100644 --- a/packages/ui/src/components/workspaces/workspace-create.tsx +++ b/packages/ui/src/components/workspaces/workspace-create.tsx @@ -4,7 +4,9 @@ import { toast } from 'sonner'; import { collections } from '@colanode/ui/collections'; import { WorkspaceForm } from '@colanode/ui/components/workspaces/workspace-form'; +import { WorkspaceServerJoin } from '@colanode/ui/components/workspaces/workspace-server-join'; import { useMutation } from '@colanode/ui/hooks/use-mutation'; +import { isServerSyncMode } from '@colanode/ui/routes/utils'; interface WorkspaceCreateProps { accountId: string; @@ -25,6 +27,7 @@ export const WorkspaceCreate = ({ accountId }: WorkspaceCreateProps) => { ); const workspaces = workspacesQuery.data ?? []; + const serverSyncMode = isServerSyncMode(); const handleCancel = router.history.canGoBack() ? () => router.history.back() : workspaces.length > 0 @@ -41,9 +44,27 @@ export const WorkspaceCreate = ({ accountId }: WorkspaceCreateProps) => {

- Setup your workspace + {serverSyncMode ? 'Get started' : 'Setup your workspace'}

+ {serverSyncMode ? ( +
+ { + router.navigate({ + to: '/workspace/$userId', + params: { userId }, + }); + }} + /> +
+ ) : null} + {serverSyncMode ? ( +

+ Or create a new workspace +

+ ) : null} { mutate({ diff --git a/packages/ui/src/components/workspaces/workspace-local-sync.tsx b/packages/ui/src/components/workspaces/workspace-local-sync.tsx index 5499f8caa..895c234eb 100644 --- a/packages/ui/src/components/workspaces/workspace-local-sync.tsx +++ b/packages/ui/src/components/workspaces/workspace-local-sync.tsx @@ -25,16 +25,13 @@ import { useWorkspace } from '@colanode/ui/contexts/workspace'; import { useMutation } from '@colanode/ui/hooks/use-mutation'; import { useQuery } from '@colanode/ui/hooks/use-query'; -const formatTimestamp = (value: string | null): string => { - if (!value) { - return '—'; - } - try { - return new Date(value).toLocaleString(); - } catch { - return value; - } -}; +import { + formatRemoteWorkspaceMeta, + formatRemoteWorkspaceTitle, + formatRemoteWorkspaceTimestamp, +} from '@colanode/ui/components/workspaces/workspace-remote-format'; + +const formatTimestamp = formatRemoteWorkspaceTimestamp; const formatExportSummary = ( summary: LocalSyncExportSummary | null @@ -118,9 +115,15 @@ export const WorkspaceLocalSync = () => { const [isJoiningRemoteId, setIsJoiningRemoteId] = useState( null ); + const [isDeletingRemoteId, setIsDeletingRemoteId] = useState( + null + ); + + const status = statusQuery.data ?? null; + const isServerSync = status?.storageDescription?.startsWith('server:'); const refreshRemoteWorkspaces = useCallback(async () => { - if (!config) { + if (!config && !isServerSync) { setRemoteWorkspaces([]); return; } @@ -134,7 +137,7 @@ export const WorkspaceLocalSync = () => { } finally { setIsLoadingRemote(false); } - }, [config]); + }, [config, isServerSync]); const refreshConfig = useCallback(async () => { try { @@ -151,9 +154,7 @@ export const WorkspaceLocalSync = () => { useEffect(() => { void refreshRemoteWorkspaces(); - }, [refreshRemoteWorkspaces]); - - const status = statusQuery.data ?? null; + }, [refreshRemoteWorkspaces, status?.storageDescription]); const openConfigure = () => { setDraftConfig( @@ -208,7 +209,9 @@ export const WorkspaceLocalSync = () => { } const confirmed = confirm( - `Join "${remote.name}" from the sync root?\n\nThis replaces local workspace data on this device with the remote op-log.` + isServerSync + ? `Join "${formatRemoteWorkspaceTitle(remote, remoteWorkspaces)}" from the sync server?\n\n${formatRemoteWorkspaceMeta(remote) || remote.workspaceId}\n\nThis replaces local workspace data on this device with the server op-log.` + : `Join "${remote.name}" from the sync root?\n\nThis replaces local workspace data on this device with the remote op-log.` ); if (!confirmed) { return; @@ -232,11 +235,48 @@ export const WorkspaceLocalSync = () => { } }; + const deleteRemoteWorkspace = async (remoteWorkspaceId: string) => { + const remote = remoteWorkspaces.find( + (item) => item.workspaceId === remoteWorkspaceId + ); + if (!remote || !isServerSync) { + return; + } + + const confirmed = confirm( + `Delete "${formatRemoteWorkspaceTitle(remote, remoteWorkspaces)}" from the sync server?\n\n${formatRemoteWorkspaceMeta(remote) || remote.workspaceId}\n\nThis permanently removes server-side sync data. Local data on other devices is not affected until they sync.` + ); + if (!confirmed) { + return; + } + + setIsDeletingRemoteId(remoteWorkspaceId); + try { + await window.colanode.localSyncDeleteRemoteWorkspace({ + workspaceId: remoteWorkspaceId, + }); + toast.success(`Deleted "${remote.name}" from server`); + await refreshRemoteWorkspaces(); + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : 'Failed to delete remote workspace' + ); + } finally { + setIsDeletingRemoteId(null); + } + }; + const remoteWorkspacesSection = - config && remoteWorkspaces.length > 0 ? ( + config || isServerSync ? (
-

Workspaces in sync root

+

+ {isServerSync + ? 'Workspaces on server' + : 'Workspaces in sync root'} +

-
    - {remoteWorkspaces.map((remote) => { + {remoteWorkspaces.length === 0 ? ( +

    + {isServerSync + ? 'No workspaces on the server yet. Create one on another device, or open this workspace there once to register it.' + : 'No remote workspaces found in the sync root.'} +

    + ) : ( +
      + {remoteWorkspaces.map((remote) => { const isCurrent = remote.workspaceId === workspace.workspaceId && remote.userId === workspace.userId; @@ -259,7 +306,17 @@ export const WorkspaceLocalSync = () => { className="flex flex-col gap-2 rounded-md border px-3 py-2 sm:flex-row sm:items-center sm:justify-between" >
      -

      {remote.name}

      +

      + {formatRemoteWorkspaceTitle( + remote, + remoteWorkspaces + )} +

      + {formatRemoteWorkspaceMeta(remote) ? ( +

      + {formatRemoteWorkspaceMeta(remote)} +

      + ) : null}

      {remote.workspaceId}

      @@ -267,28 +324,58 @@ export const WorkspaceLocalSync = () => { {isCurrent ? ( Current ) : ( - + {isServerSync ? ( + + ? 'Deleting…' + : 'Delete'} + + ) : null} +
      )} ); })} -
    +
+ )}
) : null; diff --git a/packages/ui/src/components/workspaces/workspace-remote-format.ts b/packages/ui/src/components/workspaces/workspace-remote-format.ts new file mode 100644 index 000000000..619bf9166 --- /dev/null +++ b/packages/ui/src/components/workspaces/workspace-remote-format.ts @@ -0,0 +1,44 @@ +import type { RemoteWorkspaceManifest } from '@colanode/ui/window'; + +export const formatRemoteWorkspaceTimestamp = (value: string | null): string => { + if (!value) { + return '—'; + } + try { + return new Date(value).toLocaleString(); + } catch { + return value; + } +}; + +export const formatRemoteWorkspaceTitle = ( + remote: RemoteWorkspaceManifest, + all: RemoteWorkspaceManifest[] +): string => { + const duplicateNames = all.filter((item) => item.name === remote.name).length; + if (duplicateNames <= 1) { + return remote.name; + } + + return `${remote.name} (···${remote.workspaceId.slice(-8)})`; +}; + +export const formatRemoteWorkspaceMeta = ( + remote: RemoteWorkspaceManifest +): string => { + const parts: string[] = []; + + if (typeof remote.recordCount === 'number') { + parts.push(`${remote.recordCount} records`); + } + if (remote.lastActivityAt) { + parts.push( + `last active ${formatRemoteWorkspaceTimestamp(remote.lastActivityAt)}` + ); + } + if (remote.createdAt && remote.createdAt !== new Date(0).toISOString()) { + parts.push(`created ${formatRemoteWorkspaceTimestamp(remote.createdAt)}`); + } + + return parts.join(' · '); +}; diff --git a/packages/ui/src/components/workspaces/workspace-server-join.tsx b/packages/ui/src/components/workspaces/workspace-server-join.tsx new file mode 100644 index 000000000..2f81877e9 --- /dev/null +++ b/packages/ui/src/components/workspaces/workspace-server-join.tsx @@ -0,0 +1,144 @@ +import { useCallback, useEffect, useState } from 'react'; +import { toast } from 'sonner'; + +import type { RemoteWorkspaceManifest } from '@colanode/ui/window'; +import { + formatRemoteWorkspaceMeta, + formatRemoteWorkspaceTitle, +} from '@colanode/ui/components/workspaces/workspace-remote-format'; +import { Badge } from '@colanode/ui/components/ui/badge'; +import { Button } from '@colanode/ui/components/ui/button'; +import { useMutation } from '@colanode/ui/hooks/use-mutation'; + +type WorkspaceServerJoinProps = { + accountId: string; + onJoined: (userId: string) => void; +}; + +export const WorkspaceServerJoin = ({ + accountId, + onJoined, +}: WorkspaceServerJoinProps) => { + const { mutate, isPending } = useMutation(); + const [remoteWorkspaces, setRemoteWorkspaces] = useState< + RemoteWorkspaceManifest[] + >([]); + const [isLoading, setIsLoading] = useState(true); + const [joiningId, setJoiningId] = useState(null); + + const refresh = useCallback(async () => { + setIsLoading(true); + try { + const items = await window.colanode.localSyncListRemoteWorkspaces(); + setRemoteWorkspaces(items); + } catch { + setRemoteWorkspaces([]); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const joinRemote = async (remote: RemoteWorkspaceManifest) => { + const confirmed = confirm( + `Join "${formatRemoteWorkspaceTitle(remote, remoteWorkspaces)}" from the sync server?\n\n${formatRemoteWorkspaceMeta(remote) || remote.workspaceId}\n\nThis downloads workspace data from the server to this device.` + ); + if (!confirmed) { + return; + } + + setJoiningId(remote.workspaceId); + try { + const created = await new Promise<{ userId: string }>((resolve, reject) => { + mutate({ + input: { + type: 'workspace.create', + accountId, + name: remote.name, + description: '', + avatar: null, + }, + onSuccess(output) { + resolve({ userId: output.userId }); + }, + onError(error) { + reject(error); + }, + }); + }); + + const joined = await window.colanode.localSyncJoinWorkspace({ + userId: created.userId, + remoteWorkspaceId: remote.workspaceId, + }); + + toast.success(`Joined "${remote.name}"`); + onJoined(joined.userId); + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Failed to join workspace' + ); + } finally { + setJoiningId(null); + } + }; + + if (isLoading) { + return ( +

Checking sync server…

+ ); + } + + if (remoteWorkspaces.length === 0) { + return null; + } + + return ( +
+
+

Join from server

+

+ These workspaces already exist on the sync server. Pick one to download + instead of creating a new empty workspace. +

+
+
    + {remoteWorkspaces.map((remote) => ( +
  • +
    +

    + {formatRemoteWorkspaceTitle(remote, remoteWorkspaces)} +

    + {formatRemoteWorkspaceMeta(remote) ? ( +

    + {formatRemoteWorkspaceMeta(remote)} +

    + ) : null} +

    + {remote.workspaceId} +

    +
    + +
  • + ))} +
+ + {remoteWorkspaces.length} workspace + {remoteWorkspaces.length === 1 ? '' : 's'} on server + +
+ ); +}; diff --git a/packages/ui/src/routes/utils.tsx b/packages/ui/src/routes/utils.tsx index 880ca0c2d..cf5217a4e 100644 --- a/packages/ui/src/routes/utils.tsx +++ b/packages/ui/src/routes/utils.tsx @@ -15,6 +15,20 @@ export const isLocalOnlyMode = () => { } }; +export const isServerSyncMode = () => { + const metadataKey = buildMetadataKey('app', 'mode.serverSync'); + const metadataValue = collections.metadata.get(metadataKey)?.value; + if (!metadataValue) { + return false; + } + + try { + return JSON.parse(metadataValue) === true; + } catch { + return false; + } +}; + export const getDefaultWorkspaceUserId = () => { const workspaceUserIds = collections.workspaces.map( (workspace) => workspace.userId diff --git a/packages/ui/src/window.ts b/packages/ui/src/window.ts index c38fc66dd..9e97c9059 100644 --- a/packages/ui/src/window.ts +++ b/packages/ui/src/window.ts @@ -14,6 +14,8 @@ export type RemoteWorkspaceManifest = { name: string; createdAt: string; createdByDeviceId: string; + recordCount?: number; + lastActivityAt?: string | null; }; interface SaveDialogOptions { @@ -74,6 +76,9 @@ export interface ColanodeWindowApi { localSyncJoinWorkspace: (params: { userId: string; remoteWorkspaceId: string; + }) => Promise<{ userId: string }>; + localSyncDeleteRemoteWorkspace: (params: { + workspaceId: string; }) => Promise; onNotificationNavigate?: ( callback: (target: NotificationNavigateTarget) => void From e4c08735b959b2cc3c15d9ea719dcda92c981ba5 Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 9 Jul 2026 16:24:49 +0800 Subject: [PATCH 10/20] Enhance server synchronization by implementing version checks and updating headers. This commit introduces support for sync protocol versioning in both server and client components. It adds version validation in WebSocket routes and request headers, ensuring compatibility between clients and the server. Additionally, it updates the API client to include the sync version in requests, improving overall synchronization reliability. --- apps/server/src/app.ts | 25 +++++++++++++++++-- apps/server/src/sync/protocol.ts | 2 ++ apps/server/src/sync/ws-routes.ts | 6 +++++ apps/server/src/types/api.ts | 1 + .../workspaces/server-sync-api-client.ts | 22 ++++++++++------ .../workspaces/server-sync-realtime.ts | 2 ++ packages/core/src/types/server-sync.ts | 3 +++ 7 files changed, 52 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/sync/protocol.ts diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index f9d0f5b7c..3eea73d0d 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -5,6 +5,10 @@ import { AppConfig } from './config.js'; import { createDatabase } from './db/database.js'; import { registerFileRoutes } from './files/routes.js'; import { createStorageProvider } from './storage/factory.js'; +import { + SERVER_SYNC_PROTOCOL_VERSION, + SERVER_SYNC_VERSION_HEADER, +} from './sync/protocol.js'; import { SyncRealtimeHub } from './sync/realtime-hub.js'; import { SqliteSyncRepository } from './sync/sqlite-repository.js'; import { registerSyncRoutes } from './sync/routes.js'; @@ -18,14 +22,31 @@ export const buildApp = async (config: AppConfig) => { await app.register(websocket); - app.addHook('onRequest', async (request, reply) => { + app.addHook('onRequest', async (request, reply) => { reply.header('Access-Control-Allow-Origin', '*'); reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - reply.header('Access-Control-Allow-Headers', 'Content-Type'); + reply.header( + 'Access-Control-Allow-Headers', + `Content-Type, ${SERVER_SYNC_VERSION_HEADER}` + ); if (request.method === 'OPTIONS') { return reply.status(204).send(); } + + if ( + request.url.startsWith('/v1/') && + !request.url.startsWith('/v1/sync/ws') + ) { + const version = request.headers[SERVER_SYNC_VERSION_HEADER]; + if (version !== SERVER_SYNC_PROTOCOL_VERSION) { + throw new ApiError( + ApiErrorCode.SyncVersionMismatch, + `Unsupported sync protocol version. Expected ${SERVER_SYNC_PROTOCOL_VERSION}, received ${String(version ?? 'none')}.`, + 400 + ); + } + } }); const database = await createDatabase(config.dbPath); diff --git a/apps/server/src/sync/protocol.ts b/apps/server/src/sync/protocol.ts new file mode 100644 index 000000000..92c378dc4 --- /dev/null +++ b/apps/server/src/sync/protocol.ts @@ -0,0 +1,2 @@ +export const SERVER_SYNC_VERSION_HEADER = 'x-colanode-sync-version'; +export const SERVER_SYNC_PROTOCOL_VERSION = '1'; diff --git a/apps/server/src/sync/ws-routes.ts b/apps/server/src/sync/ws-routes.ts index eb32ad239..5c9794ca4 100644 --- a/apps/server/src/sync/ws-routes.ts +++ b/apps/server/src/sync/ws-routes.ts @@ -1,11 +1,13 @@ import { FastifyInstance } from 'fastify'; import { z } from 'zod'; +import { SERVER_SYNC_PROTOCOL_VERSION } from './protocol.js'; import { SyncRealtimeHub } from './realtime-hub.js'; const subscribeQuerySchema = z.object({ workspaceId: z.string().min(1), deviceId: z.string().min(1), + version: z.string().default('1'), }); export const registerSyncWsRoutes = ( @@ -18,6 +20,10 @@ export const registerSyncWsRoutes = ( socket.close(1008, 'Invalid subscription query'); return; } + if (parsed.data.version !== SERVER_SYNC_PROTOCOL_VERSION) { + socket.close(1008, 'Unsupported sync protocol version'); + return; + } hub.subscribe(socket, parsed.data.workspaceId, parsed.data.deviceId); }); diff --git a/apps/server/src/types/api.ts b/apps/server/src/types/api.ts index b6c97c8c6..920bcf646 100644 --- a/apps/server/src/types/api.ts +++ b/apps/server/src/types/api.ts @@ -3,6 +3,7 @@ export enum ApiErrorCode { SyncInvalidCursor = 'SYNC_INVALID_CURSOR', SyncBatchTooLarge = 'SYNC_BATCH_TOO_LARGE', SyncDuplicateBatch = 'SYNC_DUPLICATE_BATCH', + SyncVersionMismatch = 'SYNC_VERSION_MISMATCH', SyncInternalError = 'SYNC_INTERNAL_ERROR', FileNotFound = 'FILE_NOT_FOUND', FileChecksumMismatch = 'FILE_CHECKSUM_MISMATCH', diff --git a/packages/client/src/services/workspaces/server-sync-api-client.ts b/packages/client/src/services/workspaces/server-sync-api-client.ts index 572c8d3e4..6fcd52a49 100644 --- a/packages/client/src/services/workspaces/server-sync-api-client.ts +++ b/packages/client/src/services/workspaces/server-sync-api-client.ts @@ -1,5 +1,7 @@ import ky from 'ky'; import { + SERVER_SYNC_PROTOCOL_VERSION, + SERVER_SYNC_VERSION_HEADER, ServerSyncPullRecord, ServerSyncPullResponse, ServerSyncPushResponse, @@ -42,6 +44,12 @@ export type { }; export class ServerSyncApiClient { + private readonly http = ky.create({ + headers: { + [SERVER_SYNC_VERSION_HEADER]: SERVER_SYNC_PROTOCOL_VERSION, + }, + }); + constructor(private readonly baseUrl: string) {} public async push(input: { @@ -51,7 +59,7 @@ export class ServerSyncApiClient { records: OpLogSyncRecord[]; clientCursor?: { lastPulledServerSeq: number }; }): Promise { - const response = await ky + const response = await this.http .post(`${this.baseUrl}/v1/sync/push`, { json: input }) .json>(); @@ -68,7 +76,7 @@ export class ServerSyncApiClient { afterSeq: number; limit?: number; }): Promise { - const response = await ky + const response = await this.http .get(`${this.baseUrl}/v1/sync/pull`, { searchParams: { workspaceId: input.workspaceId, @@ -94,7 +102,7 @@ export class ServerSyncApiClient { sha256: string; body: Uint8Array; }): Promise { - const response = await ky + const response = await this.http .post(`${this.baseUrl}/v1/files/upload`, { json: { workspaceId: input.workspaceId, @@ -118,7 +126,7 @@ export class ServerSyncApiClient { extension: string; }): Promise { try { - const response = await ky + const response = await this.http .get(`${this.baseUrl}/v1/files/download`, { searchParams: { workspaceId: input.workspaceId, @@ -139,7 +147,7 @@ export class ServerSyncApiClient { } public async listWorkspaces(): Promise { - const response = await ky + const response = await this.http .get(`${this.baseUrl}/v1/sync/workspaces`) .json>(); @@ -153,7 +161,7 @@ export class ServerSyncApiClient { public async upsertWorkspaceManifest( manifest: ServerSyncWorkspaceManifest ): Promise { - const response = await ky + const response = await this.http .put(`${this.baseUrl}/v1/sync/workspaces/${manifest.workspaceId}/manifest`, { json: manifest, }) @@ -168,7 +176,7 @@ export class ServerSyncApiClient { deletedRecords: number; deletedFiles: number; }> { - const response = await ky + const response = await this.http .delete(`${this.baseUrl}/v1/sync/workspaces/${workspaceId}`) .json>(); diff --git a/packages/client/src/services/workspaces/server-sync-realtime.ts b/packages/client/src/services/workspaces/server-sync-realtime.ts index 01f69106a..797fe5779 100644 --- a/packages/client/src/services/workspaces/server-sync-realtime.ts +++ b/packages/client/src/services/workspaces/server-sync-realtime.ts @@ -1,4 +1,5 @@ import { createDebugger } from '@colanode/core'; +import { SERVER_SYNC_PROTOCOL_VERSION } from '@colanode/core'; const debug = createDebugger('client:service:server-sync-realtime'); @@ -23,6 +24,7 @@ export const buildSyncWebSocketUrl = ( const params = new URLSearchParams({ workspaceId, deviceId, + version: SERVER_SYNC_PROTOCOL_VERSION, }); if (!baseUrl) { diff --git a/packages/core/src/types/server-sync.ts b/packages/core/src/types/server-sync.ts index 3334188a0..2b5e0bf36 100644 --- a/packages/core/src/types/server-sync.ts +++ b/packages/core/src/types/server-sync.ts @@ -1,3 +1,6 @@ +export const SERVER_SYNC_VERSION_HEADER = 'x-colanode-sync-version'; +export const SERVER_SYNC_PROTOCOL_VERSION = '1'; + export type ServerSyncOpLogRecord = | { kind: 'node.update'; From a6ccde3d69aa646f4bd27cd229455cff89cb7e17 Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 9 Jul 2026 16:45:25 +0800 Subject: [PATCH 11/20] Refactor sync version handling and improve WebSocket validation. This commit enhances the synchronization protocol by introducing a version parsing function and updating the WebSocket route to validate the sync version against the server's expected version. It also ensures that the sync version is included in the request headers, improving compatibility checks between clients and the server. --- apps/server/.data/server.sqlite-shm | Bin 32768 -> 32768 bytes apps/server/src/app.ts | 9 ++- apps/server/src/sync/protocol.ts | 30 ++++++++ apps/server/src/sync/ws-routes.ts | 9 ++- apps/server/test/protocol-version.test.ts | 85 ++++++++++++++++++++++ 5 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 apps/server/test/protocol-version.test.ts diff --git a/apps/server/.data/server.sqlite-shm b/apps/server/.data/server.sqlite-shm index e3f502fc8b97120ba1fb86765b34498ecad9667f..5f0cd3dde4041ec25a2c6b3e1d689728a9c202ea 100644 GIT binary patch delta 55 ucmZo@U}|V!;*@x#%K!!wIpqZ}m+XHcuuW?dvLGv1aAK@7D#8Y30aU8EI2V%neoxa#`<~ysFx8m diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 3eea73d0d..aa7a7c1ae 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -6,6 +6,7 @@ import { createDatabase } from './db/database.js'; import { registerFileRoutes } from './files/routes.js'; import { createStorageProvider } from './storage/factory.js'; import { + isSupportedSyncVersion, SERVER_SYNC_PROTOCOL_VERSION, SERVER_SYNC_VERSION_HEADER, } from './sync/protocol.js'; @@ -23,6 +24,7 @@ export const buildApp = async (config: AppConfig) => { await app.register(websocket); app.addHook('onRequest', async (request, reply) => { + reply.header(SERVER_SYNC_VERSION_HEADER, SERVER_SYNC_PROTOCOL_VERSION); reply.header('Access-Control-Allow-Origin', '*'); reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); reply.header( @@ -39,10 +41,13 @@ export const buildApp = async (config: AppConfig) => { !request.url.startsWith('/v1/sync/ws') ) { const version = request.headers[SERVER_SYNC_VERSION_HEADER]; - if (version !== SERVER_SYNC_PROTOCOL_VERSION) { + if ( + typeof version !== 'string' || + !isSupportedSyncVersion(version) + ) { throw new ApiError( ApiErrorCode.SyncVersionMismatch, - `Unsupported sync protocol version. Expected ${SERVER_SYNC_PROTOCOL_VERSION}, received ${String(version ?? 'none')}.`, + `Unsupported sync protocol version. Server=${SERVER_SYNC_PROTOCOL_VERSION}, client=${String(version ?? 'none')}.`, 400 ); } diff --git a/apps/server/src/sync/protocol.ts b/apps/server/src/sync/protocol.ts index 92c378dc4..445e78ed4 100644 --- a/apps/server/src/sync/protocol.ts +++ b/apps/server/src/sync/protocol.ts @@ -1,2 +1,32 @@ export const SERVER_SYNC_VERSION_HEADER = 'x-colanode-sync-version'; export const SERVER_SYNC_PROTOCOL_VERSION = '1'; + +const parseMajor = (version: string): number | null => { + const trimmed = version.trim(); + if (!trimmed) { + return null; + } + + const [major] = trimmed.split('.'); + if (!major || !/^\d+$/.test(major)) { + return null; + } + + return Number(major); +}; + +export const isSupportedSyncVersion = ( + version: string | undefined +): boolean => { + if (!version) { + return false; + } + + const expectedMajor = parseMajor(SERVER_SYNC_PROTOCOL_VERSION); + const requestedMajor = parseMajor(version); + if (expectedMajor === null || requestedMajor === null) { + return false; + } + + return expectedMajor === requestedMajor; +}; diff --git a/apps/server/src/sync/ws-routes.ts b/apps/server/src/sync/ws-routes.ts index 5c9794ca4..a76cfb0a5 100644 --- a/apps/server/src/sync/ws-routes.ts +++ b/apps/server/src/sync/ws-routes.ts @@ -1,13 +1,16 @@ import { FastifyInstance } from 'fastify'; import { z } from 'zod'; -import { SERVER_SYNC_PROTOCOL_VERSION } from './protocol.js'; +import { + isSupportedSyncVersion, + SERVER_SYNC_PROTOCOL_VERSION, +} from './protocol.js'; import { SyncRealtimeHub } from './realtime-hub.js'; const subscribeQuerySchema = z.object({ workspaceId: z.string().min(1), deviceId: z.string().min(1), - version: z.string().default('1'), + version: z.string().default(SERVER_SYNC_PROTOCOL_VERSION), }); export const registerSyncWsRoutes = ( @@ -20,7 +23,7 @@ export const registerSyncWsRoutes = ( socket.close(1008, 'Invalid subscription query'); return; } - if (parsed.data.version !== SERVER_SYNC_PROTOCOL_VERSION) { + if (!isSupportedSyncVersion(parsed.data.version)) { socket.close(1008, 'Unsupported sync protocol version'); return; } diff --git a/apps/server/test/protocol-version.test.ts b/apps/server/test/protocol-version.test.ts new file mode 100644 index 000000000..62aed8cad --- /dev/null +++ b/apps/server/test/protocol-version.test.ts @@ -0,0 +1,85 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { buildApp } from '../src/app.js'; +import { + isSupportedSyncVersion, + SERVER_SYNC_PROTOCOL_VERSION, + SERVER_SYNC_VERSION_HEADER, +} from '../src/sync/protocol.js'; +import { ApiErrorCode } from '../src/types/api.js'; + +describe('sync protocol version negotiation', () => { + const tempDirs: string[] = []; + + afterEach(async () => { + for (const dir of tempDirs) { + await fs.rm(dir, { recursive: true, force: true }); + } + tempDirs.length = 0; + }); + + it('accepts same major versions', () => { + expect(isSupportedSyncVersion(SERVER_SYNC_PROTOCOL_VERSION)).toBe(true); + expect(isSupportedSyncVersion('1.0')).toBe(true); + expect(isSupportedSyncVersion('1.99')).toBe(true); + expect(isSupportedSyncVersion('2')).toBe(false); + expect(isSupportedSyncVersion('abc')).toBe(false); + expect(isSupportedSyncVersion(undefined)).toBe(false); + }); + + it('requires version header for /v1 routes', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'colanode-server-test-')); + tempDirs.push(root); + + const app = await buildApp({ + port: 0, + dbPath: path.join(root, 'server.sqlite'), + storage: { + type: 'local', + rootDir: path.join(root, 'storage'), + }, + }); + + try { + const withoutHeader = await app.inject({ + method: 'GET', + url: '/v1/sync/workspaces', + }); + + expect(withoutHeader.statusCode).toBe(400); + const payload = withoutHeader.json() as { + success: false; + error: { code: string; message: string }; + }; + expect(payload.success).toBe(false); + expect(payload.error.code).toBe(ApiErrorCode.SyncVersionMismatch); + + const withCompatibleVersion = await app.inject({ + method: 'GET', + url: '/v1/sync/workspaces', + headers: { + [SERVER_SYNC_VERSION_HEADER]: '1.2', + }, + }); + + expect(withCompatibleVersion.statusCode).toBe(200); + expect(withCompatibleVersion.headers[SERVER_SYNC_VERSION_HEADER]).toBe( + SERVER_SYNC_PROTOCOL_VERSION + ); + + const healthz = await app.inject({ + method: 'GET', + url: '/healthz', + }); + expect(healthz.statusCode).toBe(200); + expect(healthz.headers[SERVER_SYNC_VERSION_HEADER]).toBe( + SERVER_SYNC_PROTOCOL_VERSION + ); + } finally { + await app.close(); + } + }); +}); From 07d79b020a7d1678fb430605e095f684f18dfd85 Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 9 Jul 2026 17:18:04 +0800 Subject: [PATCH 12/20] Refactor WebSocket subscription handling and improve error management in sync API client. This commit introduces a dedicated function for parsing WebSocket subscription queries, enhancing validation and error handling. It also centralizes error message generation in the sync API client, improving clarity and consistency when handling sync protocol errors. These changes aim to streamline the synchronization process and enhance user experience. --- apps/server/src/sync/ws-routes.ts | 31 ++++++++++--- apps/server/test/ws-version.test.ts | 44 +++++++++++++++++++ .../workspaces/server-sync-api-client.ts | 24 +++++++--- 3 files changed, 85 insertions(+), 14 deletions(-) create mode 100644 apps/server/test/ws-version.test.ts diff --git a/apps/server/src/sync/ws-routes.ts b/apps/server/src/sync/ws-routes.ts index a76cfb0a5..7901f3125 100644 --- a/apps/server/src/sync/ws-routes.ts +++ b/apps/server/src/sync/ws-routes.ts @@ -13,18 +13,35 @@ const subscribeQuerySchema = z.object({ version: z.string().default(SERVER_SYNC_PROTOCOL_VERSION), }); +export const parseSyncWsSubscription = ( + query: unknown +): + | { ok: true; data: z.infer } + | { ok: false; reason: 'invalid_query' | 'unsupported_version' } => { + const parsed = subscribeQuerySchema.safeParse(query); + if (!parsed.success) { + return { ok: false, reason: 'invalid_query' }; + } + if (!isSupportedSyncVersion(parsed.data.version)) { + return { ok: false, reason: 'unsupported_version' }; + } + + return { ok: true, data: parsed.data }; +}; + export const registerSyncWsRoutes = ( app: FastifyInstance, hub: SyncRealtimeHub ): void => { app.get('/v1/sync/ws', { websocket: true }, (socket, request) => { - const parsed = subscribeQuerySchema.safeParse(request.query); - if (!parsed.success) { - socket.close(1008, 'Invalid subscription query'); - return; - } - if (!isSupportedSyncVersion(parsed.data.version)) { - socket.close(1008, 'Unsupported sync protocol version'); + const parsed = parseSyncWsSubscription(request.query); + if (!parsed.ok) { + socket.close( + 1008, + parsed.reason === 'invalid_query' + ? 'Invalid subscription query' + : 'Unsupported sync protocol version' + ); return; } diff --git a/apps/server/test/ws-version.test.ts b/apps/server/test/ws-version.test.ts new file mode 100644 index 000000000..a077a2d37 --- /dev/null +++ b/apps/server/test/ws-version.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { parseSyncWsSubscription } from '../src/sync/ws-routes.js'; + +describe('ws sync version negotiation', () => { + it('accepts compatible major versions', () => { + const parsed = parseSyncWsSubscription({ + workspaceId: 'ws_1', + deviceId: 'dev_1', + version: '1.5', + }); + + expect(parsed.ok).toBe(true); + if (parsed.ok) { + expect(parsed.data.version).toBe('1.5'); + } + }); + + it('rejects incompatible major versions', () => { + const parsed = parseSyncWsSubscription({ + workspaceId: 'ws_1', + deviceId: 'dev_1', + version: '2.0', + }); + + expect(parsed).toEqual({ + ok: false, + reason: 'unsupported_version', + }); + }); + + it('rejects invalid subscription payload', () => { + const parsed = parseSyncWsSubscription({ + workspaceId: '', + deviceId: 'dev_1', + version: '1', + }); + + expect(parsed).toEqual({ + ok: false, + reason: 'invalid_query', + }); + }); +}); diff --git a/packages/client/src/services/workspaces/server-sync-api-client.ts b/packages/client/src/services/workspaces/server-sync-api-client.ts index 6fcd52a49..64a71c472 100644 --- a/packages/client/src/services/workspaces/server-sync-api-client.ts +++ b/packages/client/src/services/workspaces/server-sync-api-client.ts @@ -31,6 +31,16 @@ type ApiEnvelope = | { success: true; requestId: string; data: T } | { success: false; requestId: string; error: { code: string; message: string } }; +const toSyncApiError = (error: { code: string; message: string }): Error => { + if (error.code === 'SYNC_VERSION_MISMATCH') { + return new Error( + 'Sync protocol version mismatch. Please refresh or upgrade the app and try again.' + ); + } + + return new Error(error.message); +}; + export type ClientServerSyncPullRecord = Omit & { record: OpLogSyncRecord; }; @@ -64,7 +74,7 @@ export class ServerSyncApiClient { .json>(); if (!response.success) { - throw new Error(response.error.message); + throw toSyncApiError(response.error); } return response.data; @@ -88,7 +98,7 @@ export class ServerSyncApiClient { .json>(); if (!response.success) { - throw new Error(response.error.message); + throw toSyncApiError(response.error); } return response.data; @@ -116,7 +126,7 @@ export class ServerSyncApiClient { .json>(); if (!response.success) { - throw new Error(response.error.message); + throw toSyncApiError(response.error); } } @@ -137,7 +147,7 @@ export class ServerSyncApiClient { .json>(); if (!response.success) { - throw new Error(response.error.message); + throw toSyncApiError(response.error); } return base64ToBytes(response.data.bodyBase64); @@ -152,7 +162,7 @@ export class ServerSyncApiClient { .json>(); if (!response.success) { - throw new Error(response.error.message); + throw toSyncApiError(response.error); } return response.data.workspaces; @@ -168,7 +178,7 @@ export class ServerSyncApiClient { .json>(); if (!response.success) { - throw new Error(response.error.message); + throw toSyncApiError(response.error); } } @@ -181,7 +191,7 @@ export class ServerSyncApiClient { .json>(); if (!response.success) { - throw new Error(response.error.message); + throw toSyncApiError(response.error); } return response.data; From 47305aedba41889c87154428fd1537659f96fbcc Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 9 Jul 2026 18:00:06 +0800 Subject: [PATCH 13/20] Document server sync release boundaries and rollout steps. Clarify Traefik SSO trust boundary, realtime/WS requirements, protocol version expectations, and add an operator-focused release checklist with rollout order. Co-authored-by: Cursor --- docs/server-sync-release-notes.md | 64 +++++++++++++++++++++++++++++++ docs/server-sync.md | 52 +++++++++++++++++++++++-- 2 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 docs/server-sync-release-notes.md diff --git a/docs/server-sync-release-notes.md b/docs/server-sync-release-notes.md new file mode 100644 index 000000000..639167232 --- /dev/null +++ b/docs/server-sync-release-notes.md @@ -0,0 +1,64 @@ +# Server Sync Release Notes + +Release scope: Web server sync MVP completion (local-first + multi-device + realtime). + +## Highlights + +- Added self-hosted sync server (`apps/server`) with: + - op-log push/pull APIs + - file upload/download APIs + - workspace manifest/list/delete APIs + - SQLite metadata persistence + pluggable storage (local FS / S3) +- Added web `ServerSyncService` integration: + - push local changes and import remote changes + - workspace join flow for new devices/incognito + - server workspace list with metadata and duplicate-name disambiguation +- Added realtime synchronization: + - WebSocket endpoint `/v1/sync/ws` + - server fan-out notifications on successful push + - client immediate pull on notify + - 60s polling fallback for resilience +- Added protocol versioning: + - request header `x-colanode-sync-version` + - major-version compatibility (`1.x`) + - clear mismatch error (`SYNC_VERSION_MISMATCH`) + - HTTP + WS compatibility tests + +## Deploy assumptions + +- Sync server is protected by Traefik SSO / forward-auth. +- Sync server is not directly exposed as a public unauthenticated endpoint. +- WS upgrade path `/v1/sync/ws` is proxied correctly. + +## Release checklist + +- [ ] Build passes: + - `npm run build -w @colanode/client` + - `npm run build -w @colanode/server` +- [ ] Server tests pass: + - `npm run test -w @colanode/server` +- [ ] Web + server smoke test: + - edit on device A appears on device B quickly (<1s normal path) + - WS disconnected still syncs via fallback polling +- [ ] New device flow: + - `/create` -> Join existing workspace from server +- [ ] Proxy checks: + - `/v1/*` HTTP routes reachable through proxy + - `/v1/sync/ws` WebSocket upgrade works +- [ ] Data persistence checks: + - restart server keeps workspace sync history and file blobs + +## Recommended rollout order + +1. Deploy server + proxy config first (Traefik SSO + WS route). +2. Verify health and protocol compatibility: + - `GET /healthz` returns 200 + - `x-colanode-sync-version` response header is present on `/v1/*` +3. Deploy web client. +4. Validate realtime cross-device edits and Join flow. + +## Known limitations + +- No built-in app auth/ACL yet (expected to be handled at proxy layer). +- Server-side compaction/retention policy is not implemented yet. +- Workspace delete is hard delete of server-side sync data. diff --git a/docs/server-sync.md b/docs/server-sync.md index 21339367a..b06a6b554 100644 --- a/docs/server-sync.md +++ b/docs/server-sync.md @@ -14,9 +14,10 @@ Browser (Web) Sync Server ``` - **Local-first**: reads/writes go to browser SQLite first. -- **Background sync**: `ServerSyncService` pulls then pushes every ~10s. +- **Realtime sync**: WebSocket notify + immediate pull; local changes debounce-push (~500ms). +- **Fallback sync**: 60s polling remains as a safety net. - **Multi-device**: new browser/device must **Join** a workspace from the server list (Settings → Local Sync). -- **No auth yet**: run server on a trusted network or behind VPN. +- **Auth boundary**: this server assumes trusted upstream and should be protected by Traefik SSO (or equivalent). ## Quick start (development) @@ -52,6 +53,7 @@ Open http://localhost:8080 - Web image is built with `VITE_SERVER_SYNC_URL=/` (same-origin). - Nginx proxies `/v1/*` and `/healthz` to the server container. +- Nginx proxies WebSocket `/v1/sync/ws` to the server container. - Server data persists in Docker volume `colanode-server-data`. Stop: @@ -74,10 +76,55 @@ docker compose down | GET | `/v1/files/download` | Download file blob | | WS | `/v1/sync/ws` | Realtime sync notifications | +All `/v1/*` requests require header `x-colanode-sync-version` from the client. +Server currently speaks protocol `1` and accepts `1.x` clients (major-version compatibility). + ## Realtime sync When `VITE_SERVER_SYNC_URL` is configured, the client opens a WebSocket to `/v1/sync/ws` and pulls immediately when another device pushes changes. Local edits export within ~500ms. A 60s poll remains as a fallback if the socket disconnects. +## Security boundary (Traefik SSO) + +This sync server does not perform app-level login/session checks. Recommended deployment: + +- Put server behind Traefik forward-auth / SSO middleware. +- Do not expose the sync server directly to the public internet. +- Keep web and server on same trusted domain/path (or private network), including WS upgrade path `/v1/sync/ws`. +- Restrict direct container port access where possible. + +### Minimal Traefik example + +```yaml +http: + routers: + colanode: + rule: Host(`colanode.example.com`) + service: colanode-web + entryPoints: [websecure] + middlewares: [sso-auth] + tls: {} + + services: + colanode-web: + loadBalancer: + servers: + - url: http://colanode-web:80 + + middlewares: + sso-auth: + forwardAuth: + address: http://your-sso-forward-auth/verify + trustForwardHeader: true + authResponseHeaders: + - X-Forwarded-User + - X-Forwarded-Email +``` + +Notes: +- Ensure WS upgrades are allowed on the same router (`/v1/sync/ws`). +- Keep `VITE_SERVER_SYNC_URL=/` so browser traffic stays same-origin behind Traefik. +- If you separate web/server domains, verify CORS, credentials, and WS origin policy explicitly. + ## Multi-device workflow 1. **Device A**: configure `VITE_SERVER_SYNC_URL`, use workspace normally. Data auto-pushes to server. @@ -117,5 +164,4 @@ See [apps/server/README.md](../apps/server/README.md). - Authentication (OIDC / JWT) and workspace ACLs - Server-side workspace delete / compaction -- Realtime push (WebSocket) instead of polling - Postgres option for metadata at scale From 676a6548d414fff920516a0d00b93741ae8fe34f Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 9 Jul 2026 18:13:42 +0800 Subject: [PATCH 14/20] Prepare server sync web MVP release notes. Add a release-ready summary with suggested tag, included commits, operator guidance, and verification checklist. Co-authored-by: Cursor --- docs/releases/server-sync-web-mvp.md | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/releases/server-sync-web-mvp.md diff --git a/docs/releases/server-sync-web-mvp.md b/docs/releases/server-sync-web-mvp.md new file mode 100644 index 000000000..ebf3837a8 --- /dev/null +++ b/docs/releases/server-sync-web-mvp.md @@ -0,0 +1,41 @@ +# Server Sync Web MVP Release + +## Suggested tag + +`v1.0.0-server-sync-web-mvp` + +## Title + +Server Sync Web MVP: realtime multi-device sync + +## Summary + +- Add self-hosted sync server (`apps/server`) for web op-log and file synchronization. +- Add workspace discovery/join flow for fresh browsers/incognito sessions. +- Add realtime sync using WebSocket notifications with polling fallback. +- Add protocol version negotiation and compatibility checks (HTTP + WS). +- Add deployment docs and release runbook for Traefik SSO protected environments. + +## Included commits + +- `7a58b818` Implement server synchronization features and enhance workspace management. +- `e4c08735` Enhance server synchronization by implementing version checks and updating headers. +- `a6ccde3d` Refactor sync version handling and improve WebSocket validation. +- `07d79b02` Refactor WebSocket subscription handling and improve error management in sync API client. +- `47305aed` Document server sync release boundaries and rollout steps. + +## Operator notes + +- Keep sync server behind Traefik SSO / forward-auth. +- Ensure `/v1/*` and `/v1/sync/ws` are correctly proxied. +- Client sends `x-colanode-sync-version`; server currently supports `1.x`. +- Workspace deletion is hard-delete of server sync metadata/blobs. + +## Verification checklist + +- `npm run build -w @colanode/client` +- `npm run build -w @colanode/server` +- `npm run test -w @colanode/server` +- Device A/B realtime edit propagation works (<1s typical). +- New browser can Join existing workspace from `/create`. +- Restarting server preserves synced data. From 44df6bc1c0d0e1d8e84d2001082f708871d726c5 Mon Sep 17 00:00:00 2001 From: barry Date: Fri, 10 Jul 2026 09:44:21 +0800 Subject: [PATCH 15/20] Speed up web Docker CI and harden release workflows. Enable buildx GHA cache, run latest tagging after image push succeeds, and skip Cloudflare deploy when required secrets are missing. Co-authored-by: Cursor --- .github/workflows/web-cf-build-and-deploy.yml | 7 +++++++ .github/workflows/web-docker-build-and-push.yml | 5 ++++- .github/workflows/web-docker-mark-latest.yml | 14 ++++++++++---- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/workflows/web-cf-build-and-deploy.yml b/.github/workflows/web-cf-build-and-deploy.yml index f9ac3f383..3ed39f70a 100644 --- a/.github/workflows/web-cf-build-and-deploy.yml +++ b/.github/workflows/web-cf-build-and-deploy.yml @@ -57,6 +57,7 @@ jobs: run: npm run build - name: Create wrangler.jsonc + if: ${{ secrets.CLOUDFLARE_API_TOKEN != '' && secrets.CLOUDFLARE_ACCOUNT_ID != '' && secrets.CLOUDFLARE_WORKER_NAME != '' }} working-directory: apps/web run: | cat > wrangler.jsonc << EOF @@ -71,6 +72,7 @@ jobs: EOF - name: Deploy to Cloudflare + if: ${{ secrets.CLOUDFLARE_API_TOKEN != '' && secrets.CLOUDFLARE_ACCOUNT_ID != '' && secrets.CLOUDFLARE_WORKER_NAME != '' }} uses: cloudflare/wrangler-action@v3 with: workingDirectory: ./apps/web @@ -78,3 +80,8 @@ jobs: accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} wranglerVersion: 4.19.2 command: deploy + + - name: Skip deploy when Cloudflare secrets are missing + if: ${{ secrets.CLOUDFLARE_API_TOKEN == '' || secrets.CLOUDFLARE_ACCOUNT_ID == '' || secrets.CLOUDFLARE_WORKER_NAME == '' }} + run: | + echo "Cloudflare deploy skipped: CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_WORKER_NAME is not fully configured." diff --git a/.github/workflows/web-docker-build-and-push.yml b/.github/workflows/web-docker-build-and-push.yml index aeef62f1e..78c276212 100644 --- a/.github/workflows/web-docker-build-and-push.yml +++ b/.github/workflows/web-docker-build-and-push.yml @@ -7,6 +7,7 @@ on: env: REGISTRY: ghcr.io + DOCKER_PLATFORMS: linux/amd64,linux/arm64 jobs: build: @@ -51,7 +52,9 @@ jobs: context: . file: ./apps/web/Dockerfile push: true - platforms: linux/amd64,linux/arm64 + platforms: ${{ env.DOCKER_PLATFORMS }} + cache-from: type=gha,scope=web-docker + cache-to: type=gha,mode=max,scope=web-docker tags: | ${{ env.REGISTRY }}/${{ vars.WEB_IMAGE_NAME }}:${{ env.VERSION }} labels: | diff --git a/.github/workflows/web-docker-mark-latest.yml b/.github/workflows/web-docker-mark-latest.yml index 7059af51f..8d6d2cb00 100644 --- a/.github/workflows/web-docker-mark-latest.yml +++ b/.github/workflows/web-docker-mark-latest.yml @@ -1,14 +1,16 @@ name: Web - Mark docker image as latest on: - release: - types: [published] + workflow_run: + workflows: ['Web - Build and push docker image'] + types: [completed] env: REGISTRY: ghcr.io jobs: tag-latest: + if: ${{ github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest steps: @@ -23,8 +25,12 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Extract version from tag - run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV + run: | + REF="${{ github.event.workflow_run.head_branch }}" + echo "VERSION=${REF#v}" >> $GITHUB_ENV - name: Tag as latest the version image run: | - docker buildx imagetools create --tag ${{ env.REGISTRY }}/${{ vars.WEB_IMAGE_NAME }}:latest ${{ env.REGISTRY }}/${{ vars.WEB_IMAGE_NAME }}:${{ env.VERSION }} + docker buildx imagetools create \ + --tag ${{ env.REGISTRY }}/${{ vars.WEB_IMAGE_NAME }}:latest \ + ${{ env.REGISTRY }}/${{ vars.WEB_IMAGE_NAME }}:${{ env.VERSION }} From e4764605cb3dec410b01c5730a4d63b519a7e582 Mon Sep 17 00:00:00 2001 From: barry Date: Fri, 10 Jul 2026 09:46:02 +0800 Subject: [PATCH 16/20] Fix Cloudflare deploy workflow secret checks. GitHub Actions cannot reference secrets in step if expressions; detect configuration in a prior step instead. Co-authored-by: Cursor --- .github/workflows/web-cf-build-and-deploy.yml | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/.github/workflows/web-cf-build-and-deploy.yml b/.github/workflows/web-cf-build-and-deploy.yml index 3ed39f70a..93184db81 100644 --- a/.github/workflows/web-cf-build-and-deploy.yml +++ b/.github/workflows/web-cf-build-and-deploy.yml @@ -56,13 +56,29 @@ jobs: working-directory: apps/web run: npm run build + - name: Check Cloudflare configuration + id: cloudflare + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_WORKER_NAME: ${{ secrets.CLOUDFLARE_WORKER_NAME }} + run: | + if [ -n "$CLOUDFLARE_API_TOKEN" ] && [ -n "$CLOUDFLARE_ACCOUNT_ID" ] && [ -n "$CLOUDFLARE_WORKER_NAME" ]; then + echo "configured=true" >> "$GITHUB_OUTPUT" + else + echo "configured=false" >> "$GITHUB_OUTPUT" + echo "Cloudflare deploy skipped: CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_WORKER_NAME is not fully configured." + fi + - name: Create wrangler.jsonc - if: ${{ secrets.CLOUDFLARE_API_TOKEN != '' && secrets.CLOUDFLARE_ACCOUNT_ID != '' && secrets.CLOUDFLARE_WORKER_NAME != '' }} + if: steps.cloudflare.outputs.configured == 'true' working-directory: apps/web + env: + CLOUDFLARE_WORKER_NAME: ${{ secrets.CLOUDFLARE_WORKER_NAME }} run: | cat > wrangler.jsonc << EOF { - "name": "${{ secrets.CLOUDFLARE_WORKER_NAME }}", + "name": "${CLOUDFLARE_WORKER_NAME}", "compatibility_date": "2025-05-28", "assets": { "directory": "./dist", @@ -72,7 +88,7 @@ jobs: EOF - name: Deploy to Cloudflare - if: ${{ secrets.CLOUDFLARE_API_TOKEN != '' && secrets.CLOUDFLARE_ACCOUNT_ID != '' && secrets.CLOUDFLARE_WORKER_NAME != '' }} + if: steps.cloudflare.outputs.configured == 'true' uses: cloudflare/wrangler-action@v3 with: workingDirectory: ./apps/web @@ -80,8 +96,3 @@ jobs: accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} wranglerVersion: 4.19.2 command: deploy - - - name: Skip deploy when Cloudflare secrets are missing - if: ${{ secrets.CLOUDFLARE_API_TOKEN == '' || secrets.CLOUDFLARE_ACCOUNT_ID == '' || secrets.CLOUDFLARE_WORKER_NAME == '' }} - run: | - echo "Cloudflare deploy skipped: CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_WORKER_NAME is not fully configured." From 7f0202e06315bc0229c4dc7f2d226cfac5cf6bb2 Mon Sep 17 00:00:00 2001 From: barry Date: Fri, 10 Jul 2026 09:52:53 +0800 Subject: [PATCH 17/20] Skip Cloudflare beta deploy when secrets are missing. Mirror the production release workflow guard so tag pushes do not fail without Cloudflare credentials. Co-authored-by: Cursor --- .../web-cf-build-and-deploy-beta.yml | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/web-cf-build-and-deploy-beta.yml b/.github/workflows/web-cf-build-and-deploy-beta.yml index ebef05d5f..fbb9e48a9 100644 --- a/.github/workflows/web-cf-build-and-deploy-beta.yml +++ b/.github/workflows/web-cf-build-and-deploy-beta.yml @@ -57,12 +57,29 @@ jobs: working-directory: apps/web run: npm run build + - name: Check Cloudflare configuration + id: cloudflare + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_WORKER_NAME_BETA: ${{ secrets.CLOUDFLARE_WORKER_NAME_BETA }} + run: | + if [ -n "$CLOUDFLARE_API_TOKEN" ] && [ -n "$CLOUDFLARE_ACCOUNT_ID" ] && [ -n "$CLOUDFLARE_WORKER_NAME_BETA" ]; then + echo "configured=true" >> "$GITHUB_OUTPUT" + else + echo "configured=false" >> "$GITHUB_OUTPUT" + echo "Cloudflare beta deploy skipped: CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_WORKER_NAME_BETA is not fully configured." + fi + - name: Create wrangler.jsonc + if: steps.cloudflare.outputs.configured == 'true' working-directory: apps/web + env: + CLOUDFLARE_WORKER_NAME_BETA: ${{ secrets.CLOUDFLARE_WORKER_NAME_BETA }} run: | cat > wrangler.jsonc << EOF { - "name": "${{ secrets.CLOUDFLARE_WORKER_NAME_BETA }}", + "name": "${CLOUDFLARE_WORKER_NAME_BETA}", "compatibility_date": "2025-05-28", "assets": { "directory": "./dist", @@ -72,6 +89,7 @@ jobs: EOF - name: Deploy to Cloudflare + if: steps.cloudflare.outputs.configured == 'true' uses: cloudflare/wrangler-action@v3 with: workingDirectory: ./apps/web From 4c6d1c0f496a42ea7c32cc1de2dc6baea227b41b Mon Sep 17 00:00:00 2001 From: barry Date: Fri, 10 Jul 2026 10:08:59 +0800 Subject: [PATCH 18/20] Make docker latest tagging work before merging to main. Use workflow_run when available, keep tag push trigger with retry, and wait for the version image before retagging latest. Co-authored-by: Cursor --- .github/workflows/web-docker-mark-latest.yml | 29 ++++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/.github/workflows/web-docker-mark-latest.yml b/.github/workflows/web-docker-mark-latest.yml index 8d6d2cb00..b90d809c8 100644 --- a/.github/workflows/web-docker-mark-latest.yml +++ b/.github/workflows/web-docker-mark-latest.yml @@ -4,13 +4,16 @@ on: workflow_run: workflows: ['Web - Build and push docker image'] types: [completed] + push: + tags: + - 'v*' env: REGISTRY: ghcr.io jobs: tag-latest: - if: ${{ github.event.workflow_run.conclusion == 'success' }} + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest steps: @@ -26,11 +29,25 @@ jobs: - name: Extract version from tag run: | - REF="${{ github.event.workflow_run.head_branch }}" - echo "VERSION=${REF#v}" >> $GITHUB_ENV + if [ "${{ github.event_name }}" = "workflow_run" ]; then + REF="${{ github.event.workflow_run.head_branch }}" + echo "VERSION=${REF#v}" >> $GITHUB_ENV + else + echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV + fi - name: Tag as latest the version image run: | - docker buildx imagetools create \ - --tag ${{ env.REGISTRY }}/${{ vars.WEB_IMAGE_NAME }}:latest \ - ${{ env.REGISTRY }}/${{ vars.WEB_IMAGE_NAME }}:${{ env.VERSION }} + IMAGE="${{ env.REGISTRY }}/${{ vars.WEB_IMAGE_NAME }}:${{ env.VERSION }}" + for attempt in $(seq 1 12); do + if docker buildx imagetools inspect "$IMAGE" >/dev/null 2>&1; then + docker buildx imagetools create \ + --tag ${{ env.REGISTRY }}/${{ vars.WEB_IMAGE_NAME }}:latest \ + "$IMAGE" + exit 0 + fi + echo "Waiting for $IMAGE (attempt $attempt/12)..." + sleep 10 + done + echo "Timed out waiting for $IMAGE" + exit 1 From 93f3fc9066636057796511183ec78bae7ca72c7f Mon Sep 17 00:00:00 2001 From: barry Date: Fri, 10 Jul 2026 10:10:21 +0800 Subject: [PATCH 19/20] Add manual dispatch for docker latest tagging. Allow marking latest after image push without waiting for merge to main. Co-authored-by: Cursor --- .github/workflows/web-docker-mark-latest.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/web-docker-mark-latest.yml b/.github/workflows/web-docker-mark-latest.yml index b90d809c8..90570bb5d 100644 --- a/.github/workflows/web-docker-mark-latest.yml +++ b/.github/workflows/web-docker-mark-latest.yml @@ -7,6 +7,12 @@ on: push: tags: - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Image version tag (without v prefix)' + required: true + type: string env: REGISTRY: ghcr.io @@ -29,7 +35,9 @@ jobs: - name: Extract version from tag run: | - if [ "${{ github.event_name }}" = "workflow_run" ]; then + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "VERSION=${{ inputs.version }}" >> $GITHUB_ENV + elif [ "${{ github.event_name }}" = "workflow_run" ]; then REF="${{ github.event.workflow_run.head_branch }}" echo "VERSION=${REF#v}" >> $GITHUB_ENV else From 3b05d1f4b9d5c44db619ca07886f2129d3fc8884 Mon Sep 17 00:00:00 2001 From: barry Date: Fri, 10 Jul 2026 10:37:35 +0800 Subject: [PATCH 20/20] Prepare feat/airtable-phase-a for merge: hygiene, lint, and meta type dedup fix. Ignore server runtime data, fix client lint, and prefer in-use synced Page meta types when consolidating duplicates after cloud sync. Co-authored-by: Cursor --- .gitignore | 1 + apps/server/.data/server.sqlite | Bin 4096 -> 0 bytes apps/server/.data/server.sqlite-shm | Bin 32768 -> 0 bytes apps/server/.data/server.sqlite-wal | Bin 2084752 -> 0 bytes packages/client/src/handlers/queries/index.ts | 2 +- packages/client/src/lib/op-log-export.ts | 2 +- packages/client/src/lib/page-meta-types.ts | 62 ++++++++++++------ packages/client/src/lib/records.ts | 1 - .../client/src/lib/rollup-field-display.ts | 8 +-- .../client/src/lib/scheduled-task-checks.ts | 2 +- .../src/lib/scheduled-task-templates.ts | 2 +- packages/client/src/lib/todo-list-system.ts | 4 +- packages/client/src/services/app-service.ts | 6 +- .../src/services/scheduled-task-service.ts | 6 +- .../src/services/workspaces/file-service.ts | 2 +- .../workspaces/server-sync-api-client.ts | 3 +- .../workspaces/server-sync-realtime.ts | 3 +- .../workspaces/server-sync-service.ts | 14 ++-- .../src/services/workspaces/user-service.ts | 2 +- .../services/workspaces/workspace-service.ts | 2 +- .../test/list-remote-workspaces.test.ts | 1 + .../client/test/rollup-field-display.test.ts | 2 +- .../client/test/sync-log-compaction.test.ts | 1 + packages/client/test/todo-list-system.test.ts | 2 +- .../client/test/workspace-members.test.ts | 2 +- 25 files changed, 78 insertions(+), 52 deletions(-) delete mode 100644 apps/server/.data/server.sqlite delete mode 100644 apps/server/.data/server.sqlite-shm delete mode 100644 apps/server/.data/server.sqlite-wal diff --git a/.gitignore b/.gitignore index 9afd455cd..4bb9ef33b 100644 --- a/.gitignore +++ b/.gitignore @@ -187,6 +187,7 @@ apps/mobile/assets/fonts/satoshi-variable-italic.woff2 apps/mobile/assets/fonts/antonio.ttf apps/server/data/ +apps/server/.data/ .expo web-build/ diff --git a/apps/server/.data/server.sqlite b/apps/server/.data/server.sqlite deleted file mode 100644 index 4a3492ca9ee32d110ce3484c72386f9ff8651a2c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYC*>iWj6^fNV2HHI9bB nXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDP#6LLGPMVA diff --git a/apps/server/.data/server.sqlite-shm b/apps/server/.data/server.sqlite-shm deleted file mode 100644 index 5f0cd3dde4041ec25a2c6b3e1d689728a9c202ea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 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 -): PageMetaTypeRow => { - return [...pageMetaTypes].sort((left, right) => { - const leftFieldCount = Object.keys(left.attributes.fields ?? {}).length; - const rightFieldCount = Object.keys(right.attributes.fields ?? {}).length; - if (leftFieldCount !== rightFieldCount) { - return rightFieldCount - leftFieldCount; +): number => { + const leftFieldCount = Object.keys(left.attributes.fields ?? {}).length; + const rightFieldCount = Object.keys(right.attributes.fields ?? {}).length; + const leftPageCount = pageCounts.get(left.id) ?? 0; + const rightPageCount = pageCounts.get(right.id) ?? 0; + + if (leftPageCount > 0 && rightPageCount === 0) { + if (rightFieldCount > leftFieldCount && leftFieldCount === 0) { + return 1; } - - const leftPageCount = pageCounts.get(left.id) ?? 0; - const rightPageCount = pageCounts.get(right.id) ?? 0; - if (leftPageCount !== rightPageCount) { - return rightPageCount - leftPageCount; + return -1; + } + if (rightPageCount > 0 && leftPageCount === 0) { + if (leftFieldCount > rightFieldCount && rightFieldCount === 0) { + return -1; } + return 1; + } - const createdAtCompare = left.createdAt.localeCompare(right.createdAt); - if (createdAtCompare !== 0) { - return createdAtCompare; - } + if (leftFieldCount !== rightFieldCount) { + return leftFieldCount > rightFieldCount ? -1 : 1; + } + + if (leftPageCount !== rightPageCount) { + return leftPageCount > rightPageCount ? -1 : 1; + } - return left.id.localeCompare(right.id); - })[0]!; + const createdAtCompare = left.createdAt.localeCompare(right.createdAt); + if (createdAtCompare !== 0) { + return createdAtCompare; + } + + return left.id.localeCompare(right.id); +}; + +const pickCanonicalPageMetaType = ( + pageMetaTypes: PageMetaTypeRow[], + pageCounts: Map +): PageMetaTypeRow => { + return pageMetaTypes.reduce((best, candidate) => + comparePageMetaTypePreference(candidate, best, pageCounts) < 0 + ? candidate + : best + ); }; const removeDuplicatePageMetaType = async ( diff --git a/packages/client/src/lib/records.ts b/packages/client/src/lib/records.ts index 14928294e..94b0df422 100644 --- a/packages/client/src/lib/records.ts +++ b/packages/client/src/lib/records.ts @@ -5,7 +5,6 @@ import { EmailFieldAttributes, FieldAttributes, isStringArray, - NumberFieldAttributes, PhoneFieldAttributes, SelectFieldAttributes, TextFieldAttributes, diff --git a/packages/client/src/lib/rollup-field-display.ts b/packages/client/src/lib/rollup-field-display.ts index 2e3aeaecc..5fa5e11e3 100644 --- a/packages/client/src/lib/rollup-field-display.ts +++ b/packages/client/src/lib/rollup-field-display.ts @@ -1,3 +1,7 @@ +import { + formatLookupFieldValue, + getLookupValueFromNode, +} from '@colanode/client/lib/lookup-field-display'; import { FieldAttributes, FieldValue, @@ -5,10 +9,6 @@ import { RollupFieldAttributes, } from '@colanode/core'; -import { - formatLookupFieldValue, - getLookupValueFromNode, -} from '@colanode/client/lib/lookup-field-display'; export type RollupSourceNode = { id: string; diff --git a/packages/client/src/lib/scheduled-task-checks.ts b/packages/client/src/lib/scheduled-task-checks.ts index 839356719..b693f0210 100644 --- a/packages/client/src/lib/scheduled-task-checks.ts +++ b/packages/client/src/lib/scheduled-task-checks.ts @@ -163,7 +163,7 @@ async function runScheduledTaskGroupCheck( workspace: WorkspaceService, check: Extract, scope?: ScheduledTaskScope | null, - runContext?: ScheduledTaskRunContext | null + _runContext?: ScheduledTaskRunContext | null ): Promise { if (check.checks.length === 0) { return { diff --git a/packages/client/src/lib/scheduled-task-templates.ts b/packages/client/src/lib/scheduled-task-templates.ts index 9641b2229..f1cb0bbfe 100644 --- a/packages/client/src/lib/scheduled-task-templates.ts +++ b/packages/client/src/lib/scheduled-task-templates.ts @@ -1,3 +1,4 @@ +import { buildRecordQueryDslFromViewFilters } from '@colanode/client/lib/record-query-view-dsl'; import { buildDefaultDatabaseQueryCheck, buildDefaultFileFieldChangedCheck, @@ -8,7 +9,6 @@ import { buildDefaultRecordsCreatedCheck, buildScheduledTaskCheck, } from '@colanode/client/lib/scheduled-task-check-utils'; -import { buildRecordQueryDslFromViewFilters } from '@colanode/client/lib/record-query-view-dsl'; import { ScheduledTaskInput } from '@colanode/client/types'; import { DatabaseViewFilterAttributes, diff --git a/packages/client/src/lib/todo-list-system.ts b/packages/client/src/lib/todo-list-system.ts index c0113229e..5e27e6868 100644 --- a/packages/client/src/lib/todo-list-system.ts +++ b/packages/client/src/lib/todo-list-system.ts @@ -1,3 +1,5 @@ +import { findOrCreateMetaTypesSpace } from '@colanode/client/lib/page-meta-types'; +import { ensureScheduledTaskMetaType } from '@colanode/client/lib/scheduled-task-meta-type'; import { applyTodoListListViewFields, buildMissingTodoListViews, @@ -9,8 +11,6 @@ import { getTodoListViewKey, resolveTodoListStatusFieldId, } from '@colanode/client/lib/todo-list'; -import { findOrCreateMetaTypesSpace } from '@colanode/client/lib/page-meta-types'; -import { ensureScheduledTaskMetaType } from '@colanode/client/lib/scheduled-task-meta-type'; import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service'; import { DatabaseAttributes, diff --git a/packages/client/src/services/app-service.ts b/packages/client/src/services/app-service.ts index d900e25e1..4fc28dc1f 100644 --- a/packages/client/src/services/app-service.ts +++ b/packages/client/src/services/app-service.ts @@ -6,14 +6,14 @@ import { SelectWorkspace, appDatabaseMigrations, } from '@colanode/client/databases/app'; -import { listServerWorkspaces } from '@colanode/client/lib/list-server-workspaces'; -import { setSyncState } from '@colanode/client/lib/sync-state'; import { Mediator } from '@colanode/client/handlers'; import { eventBus } from '@colanode/client/lib/event-bus'; -import { mapAccount, mapWorkspace } from '@colanode/client/lib/mappers'; import { ensureFileMetaTypes } from '@colanode/client/lib/file-meta-types'; +import { listServerWorkspaces } from '@colanode/client/lib/list-server-workspaces'; +import { mapAccount, mapWorkspace } from '@colanode/client/lib/mappers'; import { ensurePageMetaTypes, upgradeDefaultPageMetaTypeSchema } from '@colanode/client/lib/page-meta-types'; import { ensureScheduledTaskMetaType } from '@colanode/client/lib/scheduled-task-meta-type'; +import { setSyncState } from '@colanode/client/lib/sync-state'; import { ensureTodoListSystemDatabase } from '@colanode/client/lib/todo-list-system'; import { AccountService } from '@colanode/client/services/accounts/account-service'; import { AppMeta, LocalSyncConfig, ServerSyncConfig } from '@colanode/client/services/app-meta'; diff --git a/packages/client/src/services/scheduled-task-service.ts b/packages/client/src/services/scheduled-task-service.ts index 50142f3a6..e83c2ef0e 100644 --- a/packages/client/src/services/scheduled-task-service.ts +++ b/packages/client/src/services/scheduled-task-service.ts @@ -1,10 +1,10 @@ import { eventBus } from '@colanode/client/lib/event-bus'; -import { isScheduledTaskEventCheck } from '@colanode/client/lib/scheduled-task-check-utils'; -import { runScheduledTaskCheck } from '@colanode/client/lib/scheduled-task-checks'; import { buildScheduledTaskRunLog, runScheduledTaskActions, } from '@colanode/client/lib/scheduled-task-actions'; +import { isScheduledTaskEventCheck } from '@colanode/client/lib/scheduled-task-check-utils'; +import { runScheduledTaskCheck } from '@colanode/client/lib/scheduled-task-checks'; import { createScheduledTaskRecord, ensureScheduledTaskMetaType, @@ -20,11 +20,11 @@ import { getScheduledTaskJobScheduleId, } from '@colanode/client/lib/scheduled-task-schedule'; import { AppService } from '@colanode/client/services/app-service'; +import { Event } from '@colanode/client/types/events'; import { ScheduledTask, ScheduledTaskInput, } from '@colanode/client/types/scheduled-tasks'; -import { Event } from '@colanode/client/types/events'; import { generateId, IdType, SCHEDULED_TASK_FIELD_IDS } from '@colanode/core'; const formatReminderNotificationBody = (runAt: Date): string => { diff --git a/packages/client/src/services/workspaces/file-service.ts b/packages/client/src/services/workspaces/file-service.ts index f61f975f5..75d5e74f0 100644 --- a/packages/client/src/services/workspaces/file-service.ts +++ b/packages/client/src/services/workspaces/file-service.ts @@ -9,12 +9,12 @@ import { findDefaultFileMetaTypeId, isAssetLibraryRecordParent, } from '@colanode/client/lib/file-meta-types'; -import { resolveInitialMetaTypeFields } from '@colanode/client/lib/meta-type-field-values'; import { mapDownload, mapLocalFile, mapNode, } from '@colanode/client/lib/mappers'; +import { resolveInitialMetaTypeFields } from '@colanode/client/lib/meta-type-field-values'; import { fetchNode } from '@colanode/client/lib/utils'; import { MutationError, MutationErrorCode } from '@colanode/client/mutations'; import { AppService } from '@colanode/client/services/app-service'; diff --git a/packages/client/src/services/workspaces/server-sync-api-client.ts b/packages/client/src/services/workspaces/server-sync-api-client.ts index 64a71c472..bf272faa7 100644 --- a/packages/client/src/services/workspaces/server-sync-api-client.ts +++ b/packages/client/src/services/workspaces/server-sync-api-client.ts @@ -1,4 +1,6 @@ import ky from 'ky'; + +import { OpLogSyncRecord } from '@colanode/client/lib/op-log-export'; import { SERVER_SYNC_PROTOCOL_VERSION, SERVER_SYNC_VERSION_HEADER, @@ -9,7 +11,6 @@ import { ServerSyncWorkspaceSummary, } from '@colanode/core'; -import { OpLogSyncRecord } from '@colanode/client/lib/op-log-export'; const bytesToBase64 = (bytes: Uint8Array): string => { let binary = ''; diff --git a/packages/client/src/services/workspaces/server-sync-realtime.ts b/packages/client/src/services/workspaces/server-sync-realtime.ts index 797fe5779..14f777955 100644 --- a/packages/client/src/services/workspaces/server-sync-realtime.ts +++ b/packages/client/src/services/workspaces/server-sync-realtime.ts @@ -1,5 +1,4 @@ -import { createDebugger } from '@colanode/core'; -import { SERVER_SYNC_PROTOCOL_VERSION } from '@colanode/core'; +import { createDebugger , SERVER_SYNC_PROTOCOL_VERSION } from '@colanode/core'; const debug = createDebugger('client:service:server-sync-realtime'); diff --git a/packages/client/src/services/workspaces/server-sync-service.ts b/packages/client/src/services/workspaces/server-sync-service.ts index 2214293e2..6c1943291 100644 --- a/packages/client/src/services/workspaces/server-sync-service.ts +++ b/packages/client/src/services/workspaces/server-sync-service.ts @@ -1,3 +1,5 @@ +import { sha256 } from 'js-sha256'; + import { eventBus } from '@colanode/client/lib/event-bus'; import { collectOpLogExportRecords } from '@colanode/client/lib/op-log-export'; import { applyOpLogRecords } from '@colanode/client/lib/op-log-import'; @@ -6,14 +8,13 @@ import { ServerSyncConfig } from '@colanode/client/services/app-meta'; import { ServerSyncApiClient } from '@colanode/client/services/workspaces/server-sync-api-client'; import { ServerSyncRealtimeClient } from '@colanode/client/services/workspaces/server-sync-realtime'; import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service'; +import { Event } from '@colanode/client/types/events'; +import { DownloadStatus } from '@colanode/client/types/files'; import { LocalSyncExportSummary, LocalSyncStatus, } from '@colanode/client/types/workspaces'; -import { DownloadStatus } from '@colanode/client/types/files'; -import { Event } from '@colanode/client/types/events'; import { createDebugger, generateId, IdType } from '@colanode/core'; -import { sha256 } from 'js-sha256'; const debug = createDebugger('client:service:server-sync'); const FALLBACK_SYNC_INTERVAL_MS = 60_000; @@ -309,7 +310,8 @@ export class ServerSyncService { }; const semanticWarnings: string[] = []; - while (true) { + let hasMore = true; + while (hasMore) { const page = await this.client.pull({ workspaceId: this.workspace.workspaceId, deviceId: this.deviceId, @@ -345,9 +347,7 @@ export class ServerSyncService { String(afterSeq) ); - if (!page.hasMore) { - break; - } + hasMore = page.hasMore; } this.updateStatus({ diff --git a/packages/client/src/services/workspaces/user-service.ts b/packages/client/src/services/workspaces/user-service.ts index 39032e5bc..2428a4ae0 100644 --- a/packages/client/src/services/workspaces/user-service.ts +++ b/packages/client/src/services/workspaces/user-service.ts @@ -1,6 +1,7 @@ import { eventBus } from '@colanode/client/lib/event-bus'; import { mapUser } from '@colanode/client/lib/mappers'; import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service'; +import { User } from '@colanode/client/types/users'; import { createDebugger, generateId, @@ -10,7 +11,6 @@ import { UserStatus, WorkspaceRole, } from '@colanode/core'; -import { User } from '@colanode/client/types/users'; const debug = createDebugger('desktop:service:user'); diff --git a/packages/client/src/services/workspaces/workspace-service.ts b/packages/client/src/services/workspaces/workspace-service.ts index 35eb2d01c..da48c4991 100644 --- a/packages/client/src/services/workspaces/workspace-service.ts +++ b/packages/client/src/services/workspaces/workspace-service.ts @@ -10,7 +10,6 @@ import { mapMetadata } from '@colanode/client/lib/mappers'; import { AccountService } from '@colanode/client/services/accounts/account-service'; import { LocalSyncConfig, ServerSyncConfig } from '@colanode/client/services/app-meta'; import { CloudSyncService } from '@colanode/client/services/workspaces/cloud-sync-service'; -import { ServerSyncService } from '@colanode/client/services/workspaces/server-sync-service'; import { CollaborationService } from '@colanode/client/services/workspaces/collaboration-service'; import { DocumentService } from '@colanode/client/services/workspaces/document-service'; import { FileService } from '@colanode/client/services/workspaces/file-service'; @@ -20,6 +19,7 @@ import { NodeInteractionService } from '@colanode/client/services/workspaces/nod import { NodeReactionService } from '@colanode/client/services/workspaces/node-reaction-service'; import { NodeService } from '@colanode/client/services/workspaces/node-service'; import { RadarService } from '@colanode/client/services/workspaces/radar-service'; +import { ServerSyncService } from '@colanode/client/services/workspaces/server-sync-service'; import { SyncService } from '@colanode/client/services/workspaces/sync-service'; import { UserService } from '@colanode/client/services/workspaces/user-service'; import { Workspace } from '@colanode/client/types/workspaces'; diff --git a/packages/client/test/list-remote-workspaces.test.ts b/packages/client/test/list-remote-workspaces.test.ts index 39ddc6429..c8e3eb155 100644 --- a/packages/client/test/list-remote-workspaces.test.ts +++ b/packages/client/test/list-remote-workspaces.test.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; + import { afterEach, describe, expect, it } from 'vitest'; import { listRemoteWorkspaces } from '@colanode/client/lib/list-remote-workspaces'; diff --git a/packages/client/test/rollup-field-display.test.ts b/packages/client/test/rollup-field-display.test.ts index 828101a16..10a8b4c0b 100644 --- a/packages/client/test/rollup-field-display.test.ts +++ b/packages/client/test/rollup-field-display.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest'; +import { computeRollupResult } from '@colanode/client/lib/rollup-field-display'; import { FieldAttributes, RollupFieldAttributes, } from '@colanode/core'; -import { computeRollupResult } from '@colanode/client/lib/rollup-field-display'; const rollupField = ( overrides: Partial = {} diff --git a/packages/client/test/sync-log-compaction.test.ts b/packages/client/test/sync-log-compaction.test.ts index 999fdd81d..4ae114dac 100644 --- a/packages/client/test/sync-log-compaction.test.ts +++ b/packages/client/test/sync-log-compaction.test.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; + import { afterEach, describe, expect, it } from 'vitest'; import { compactAppliedRemoteLogs } from '@colanode/client/lib/sync-log-compaction'; diff --git a/packages/client/test/todo-list-system.test.ts b/packages/client/test/todo-list-system.test.ts index b2d6b54a1..83d256200 100644 --- a/packages/client/test/todo-list-system.test.ts +++ b/packages/client/test/todo-list-system.test.ts @@ -1,3 +1,4 @@ +import { Kysely } from 'kysely'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WorkspaceDatabaseSchema } from '@colanode/client/databases'; @@ -13,7 +14,6 @@ import { ensureTodoListSystemDatabase } from '@colanode/client/lib/todo-list-sys import { NodeService } from '@colanode/client/services/workspaces/node-service'; import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service'; import { generateId, IdType, REMINDERS_RELATION_FIELD_ID, TODO_LIST_FIELD_IDS, UserStatus } from '@colanode/core'; -import { Kysely } from 'kysely'; import { createWorkspaceDatabase } from './helpers/sqlite'; diff --git a/packages/client/test/workspace-members.test.ts b/packages/client/test/workspace-members.test.ts index fa74c0476..e846465f5 100644 --- a/packages/client/test/workspace-members.test.ts +++ b/packages/client/test/workspace-members.test.ts @@ -1,3 +1,4 @@ +import { Kysely } from 'kysely'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { WorkspaceDatabaseSchema } from '@colanode/client/databases'; @@ -5,7 +6,6 @@ import { eventBus } from '@colanode/client/lib/event-bus'; import { UserService } from '@colanode/client/services/workspaces/user-service'; import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service'; import { generateId, IdType, UserStatus } from '@colanode/core'; -import { Kysely } from 'kysely'; import { createWorkspaceDatabase } from './helpers/sqlite';

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