diff --git a/.cursor/skills/colanode/SKILL.md b/.cursor/skills/colanode/SKILL.md index fbdcf0866..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/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/.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/.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/.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 diff --git a/.github/workflows/web-cf-build-and-deploy.yml b/.github/workflows/web-cf-build-and-deploy.yml index f9ac3f383..93184db81 100644 --- a/.github/workflows/web-cf-build-and-deploy.yml +++ b/.github/workflows/web-cf-build-and-deploy.yml @@ -56,12 +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: 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", @@ -71,6 +88,7 @@ jobs: EOF - name: Deploy to Cloudflare + if: steps.cloudflare.outputs.configured == 'true' uses: cloudflare/wrangler-action@v3 with: workingDirectory: ./apps/web 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..90570bb5d 100644 --- a/.github/workflows/web-docker-mark-latest.yml +++ b/.github/workflows/web-docker-mark-latest.yml @@ -1,14 +1,25 @@ name: Web - Mark docker image as latest on: - release: - types: [published] + workflow_run: + workflows: ['Web - Build and push docker image'] + types: [completed] + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Image version tag (without v prefix)' + required: true + type: string env: REGISTRY: ghcr.io jobs: tag-latest: + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest steps: @@ -23,8 +34,28 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Extract version from tag - run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV + run: | + 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 + 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 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/AGENTS.md b/AGENTS.md index c24d044a7..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`, 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/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/Dockerfile b/apps/server/Dockerfile new file mode 100644 index 000000000..5cfdb3edc --- /dev/null +++ b/apps/server/Dockerfile @@ -0,0 +1,36 @@ +FROM node:22-bookworm-slim AS build + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +COPY package.json package-lock.json ./ +COPY apps/server/package.json apps/server/package.json + +RUN npm ci -w @colanode/server --include-workspace-root + +COPY apps/server apps/server +COPY tsconfig.base.json ./ + +RUN npm run build -w @colanode/server && npm prune --production + +FROM node:22-bookworm-slim + +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=4400 +ENV SYNC_DB_PATH=/data/server.sqlite +ENV SYNC_STORAGE_LOCAL_ROOT=/data/storage + +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/apps/server/package.json ./package.json +COPY --from=build /app/apps/server/dist ./dist + +RUN mkdir -p /data/storage + +EXPOSE 4400 + +CMD ["node", "dist/index.js"] diff --git a/apps/server/README.md b/apps/server/README.md new file mode 100644 index 000000000..9e81c6d90 --- /dev/null +++ b/apps/server/README.md @@ -0,0 +1,71 @@ +# Colanode Server (sync API) + +Self-hosted sync API for unified storage. Uses local SQLite for metadata and a pluggable object store for file blobs. + +## Quick start + +```bash +cd apps/server +npm run dev +``` + +Defaults: + +- API: `http://localhost:4400` +- SQLite DB: `./.data/server.sqlite` +- Object storage: `./.data/storage` (local filesystem) + +## Environment + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `4400` | HTTP port | +| `SYNC_DB_PATH` | `./.data/server.sqlite` | SQLite database path | +| `SYNC_STORAGE_TYPE` | `local` | `local` or `s3` | +| `SYNC_STORAGE_LOCAL_ROOT` | `./.data/storage` | Local object storage root | + +## API + +- `GET /healthz` +- `GET /v1/sync/workspaces` +- `PUT /v1/sync/workspaces/:workspaceId/manifest` +- `POST /v1/sync/push` +- `GET /v1/sync/pull` +- `POST /v1/files/upload` +- `GET /v1/files/download` + +## Docker + +```bash +# From repository root +docker compose up --build -d +``` + +See [docs/server-sync.md](../../docs/server-sync.md) for full self-hosted setup. + +## Data layout + +- **SQLite**: sync batches, sync record index, file metadata +- **Object storage**: file blobs (`workspaces//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..aa7a7c1ae --- /dev/null +++ b/apps/server/src/app.ts @@ -0,0 +1,96 @@ +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 { + isSupportedSyncVersion, + 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'; +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(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( + '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 ( + typeof version !== 'string' || + !isSupportedSyncVersion(version) + ) { + throw new ApiError( + ApiErrorCode.SyncVersionMismatch, + `Unsupported sync protocol version. Server=${SERVER_SYNC_PROTOCOL_VERSION}, client=${String(version ?? 'none')}.`, + 400 + ); + } + } + }); + + 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/protocol.ts b/apps/server/src/sync/protocol.ts new file mode 100644 index 000000000..445e78ed4 --- /dev/null +++ b/apps/server/src/sync/protocol.ts @@ -0,0 +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/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..7901f3125 --- /dev/null +++ b/apps/server/src/sync/ws-routes.ts @@ -0,0 +1,50 @@ +import { FastifyInstance } from 'fastify'; +import { z } from 'zod'; + +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(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 = parseSyncWsSubscription(request.query); + if (!parsed.ok) { + socket.close( + 1008, + parsed.reason === 'invalid_query' + ? 'Invalid subscription query' + : '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 new file mode 100644 index 000000000..920bcf646 --- /dev/null +++ b/apps/server/src/types/api.ts @@ -0,0 +1,41 @@ +export enum ApiErrorCode { + SyncInvalidPayload = 'SYNC_INVALID_PAYLOAD', + 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', + 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/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(); + } + }); +}); 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/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/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 01ad4db01..099b5f68a 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -2,32 +2,36 @@ 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 ./ # 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 && \ @@ -43,12 +47,43 @@ 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 _; 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 /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/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/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 faa865fa5..88e60623c 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,17 +9,62 @@ 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 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) { - 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; } @@ -80,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); }, }; @@ -94,11 +144,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 41f89665c..28c0bd178 100644 --- a/apps/web/src/root.tsx +++ b/apps/web/src/root.tsx @@ -1,28 +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 1d5868816..43cfb16e2 100644 --- a/apps/web/src/workers/dedicated.ts +++ b/apps/web/src/workers/dedicated.ts @@ -1,13 +1,16 @@ 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, MutationResult, 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 { @@ -15,6 +18,7 @@ import { extractFileSubtype, generateId, IdType, + createDebugger, } from '@colanode/core'; import { BroadcastInitMessage, @@ -33,6 +37,34 @@ 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 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(); @@ -44,45 +76,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 +96,127 @@ 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); +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(); } - eventBus.subscribe((event) => { - broadcastMessage({ - type: 'event', - windowId, - event, + if (!accountRow) { + throw new Error('Failed to initialize local-only account record'); + } + + await app.initAccount(mapAccount(accountRow)); +}; + +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: !serverSyncUrl, + }; + + const bootstrap = await WebBootstrapService.create(path, fs); + if (bootstrap.needsFreshInstall) { + finishInit('reset'); + return; + } + + 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', !serverSync); + if (serverSync) { + await app.metadata.set('app', 'mode.serverSync', true); + await app.metadata.set('app', 'serverSync.baseUrl', serverSync.baseUrl); + } + + 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) { @@ -191,6 +281,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; @@ -247,11 +339,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); @@ -459,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/apps/web/vite.config.js b/apps/web/vite.config.js index c8532b13d..d5ccc7b89 100644 --- a/apps/web/vite.config.js +++ b/apps/web/vite.config.js @@ -5,7 +5,26 @@ import { defineConfig } from 'vite'; import { VitePWA } from 'vite-plugin-pwa'; // https://vitejs.dev/config/ -export default defineConfig({ +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, + hmr: { + protocol: 'ws', + clientPort: 4000, + }, + warmup: { + clientFiles: ['./index.html', './src/main.tsx', './src/root.tsx'], + }, + }, + preview: { + headers: crossOriginIsolationHeaders, + }, test: { globals: true, environment: 'jsdom', @@ -34,8 +53,7 @@ export default defineConfig({ base: '/', includeAssets: ['favicon.ico'], devOptions: { - enabled: true, - type: 'module', + enabled: false, }, srcDir: 'src/workers', filename: 'service.ts', @@ -85,4 +103,4 @@ export default defineConfig({ }, }, }, -}); +})); 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/database-airtable-alignment.md b/docs/database-airtable-alignment.md index 6eee38cbe..49091ec35 100644 --- a/docs/database-airtable-alignment.md +++ b/docs/database-airtable-alignment.md @@ -1,6 +1,7 @@ # Database — Airtable Alignment Plan -> Status: **planned** (not started) +> 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 | @@ -124,14 +125,14 @@ 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 | -| 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 | +| 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 | ✓ | **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,7 +258,8 @@ 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 | +| 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/` | | Form / chart / timeline views | `packages/ui/src/components/databases/`, `database-view.ts` layout enum | 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/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/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/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. diff --git a/docs/roadmap.md b/docs/roadmap.md index ec707b5b6..adb826256 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -13,6 +13,8 @@ | 多维表格 → 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) | +| 自托管 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) | @@ -46,6 +48,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/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 new file mode 100644 index 000000000..b06a6b554 --- /dev/null +++ b/docs/server-sync.md @@ -0,0 +1,167 @@ +# 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. +- **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). +- **Auth boundary**: this server assumes trusted upstream and should be protected by Traefik SSO (or equivalent). + +## 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. +- Nginx proxies WebSocket `/v1/sync/ws` 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 | + +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. +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 +- Postgres option for metadata at scale 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. diff --git a/package-lock.json b/package-lock.json index 7829011c7..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", @@ -85,11 +103,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", @@ -175,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", @@ -1879,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 @@ -3750,21 +4175,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 +4198,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, @@ -4364,33 +4789,213 @@ "npm": ">=10" } }, - "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "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": { - "@floating-ui/utils": "^0.2.11" + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" } }, - "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "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": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" + "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/@floating-ui/react": { - "version": "0.27.19", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz", - "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==", + "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": { - "@floating-ui/react-dom": "^2.1.8", - "@floating-ui/utils": "^0.2.11", + "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", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.19", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz", + "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", "tabbable": "^6.0.0" }, "peerDependencies": { @@ -5022,16 +5627,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 +6056,22 @@ "@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/@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", @@ -6660,159 +7287,416 @@ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz", "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==", "cpu": [ - "arm64" + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", + "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", + "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", + "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", + "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", + "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", + "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "cpu": [ + "ia32" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", + "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "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" ], - "license": "MPL-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": ">= 10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@resvg/resvg-js-darwin-x64": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", - "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "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": [ - "x64" + "arm64" ], - "license": "MPL-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": ">= 10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", - "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "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": [ - "arm" + "arm64" ], - "license": "MPL-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@resvg/resvg-js-linux-arm64-gnu": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", - "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "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": [ - "arm64" + "ppc64" ], - "license": "MPL-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@resvg/resvg-js-linux-arm64-musl": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", - "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "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": [ - "arm64" + "s390x" ], - "license": "MPL-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@resvg/resvg-js-linux-x64-gnu": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", - "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "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" ], - "license": "MPL-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@resvg/resvg-js-linux-x64-musl": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", - "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "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" ], - "license": "MPL-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@resvg/resvg-js-win32-arm64-msvc": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", - "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "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" ], - "license": "MPL-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "win32" + "openharmony" ], "engines": { - "node": ">= 10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@resvg/resvg-js-win32-ia32-msvc": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", - "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "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": [ - "ia32" + "wasm32" ], - "license": "MPL-2.0", + "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": ">= 10" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@resvg/resvg-js-win32-x64-msvc": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", - "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "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" ], - "license": "MPL-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">= 10" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/pluginutils": { @@ -7341,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", @@ -8557,9 +9522,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, @@ -8964,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", @@ -9550,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", @@ -10044,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", @@ -10070,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", @@ -10317,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", @@ -11838,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", @@ -13610,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", @@ -13638,28 +14672,91 @@ "merge2": "^1.3.0", "micromatch": "^4.0.8" }, - "engines": { - "node": ">=8.6.0" + "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" + }, + "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": { @@ -13669,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", @@ -13685,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" @@ -13868,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", @@ -15773,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", @@ -15945,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", @@ -17684,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", @@ -18164,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", @@ -18209,9 +19492,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": [ { @@ -18400,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", @@ -18758,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", @@ -19190,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", @@ -19496,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", @@ -19511,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": { @@ -19550,6 +20871,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", @@ -19716,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", @@ -19816,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", @@ -19959,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", @@ -20253,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", @@ -20334,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", @@ -20419,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", @@ -21297,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", @@ -21323,14 +22771,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" @@ -21445,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/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/handlers/queries/index.ts b/packages/client/src/handlers/queries/index.ts index c7a039f7b..ed195a7ba 100644 --- a/packages/client/src/handlers/queries/index.ts +++ b/packages/client/src/handlers/queries/index.ts @@ -16,8 +16,8 @@ import { EmojiGetBySkinIdQueryHandler } from './emojis/emoji-get-by-skin-id'; import { EmojiListQueryHandler } from './emojis/emoji-list'; import { EmojiSearchQueryHandler } from './emojis/emoji-search'; import { EmojiSvgGetQueryHandler } from './emojis/emoji-svg-get'; -import { FileByMetaTypeQueryHandler } from './files/file-by-meta-type'; import { DownloadListQueryHandler } from './files/download-list'; +import { FileByMetaTypeQueryHandler } from './files/file-by-meta-type'; import { FileDownloadRequestGetQueryHandler } from './files/file-download-request-get'; import { LocalFileGetQueryHandler } from './files/local-file-get'; import { TempFileListQueryHandler } from './files/temp-file-list'; 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 new file mode 100644 index 000000000..320bf143b --- /dev/null +++ b/packages/client/src/lib/database-csv.ts @@ -0,0 +1,366 @@ +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 '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') { + 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': + case 'lookup': + 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([ + 'autonumber', + 'button', + 'created_at', + 'created_by', + 'updated_at', + 'updated_by', + 'file', + 'formula', + 'resource', + 'lookup', + 'lookup', + 'rollup', +]); + +export const isCsvImportableField = (field: FieldAttributes): boolean => { + return !IMPORT_SKIP_FIELD_TYPES.has(field.type); +}; 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 af667dbe8..ac9166fe0 100644 --- a/packages/client/src/lib/index.ts +++ b/packages/client/src/lib/index.ts @@ -8,9 +8,17 @@ 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'; export * from './utils'; 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/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/op-log-export.ts b/packages/client/src/lib/op-log-export.ts new file mode 100644 index 000000000..b04619f3c --- /dev/null +++ b/packages/client/src/lib/op-log-export.ts @@ -0,0 +1,263 @@ +import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service'; +import { + SyncDocumentUpdateData, + SyncNodeReactionData, + SyncNodeTombstoneData, + SyncNodeUpdateData, +} from '@colanode/core'; +import { encodeState } from '@colanode/crdt'; + +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/page-meta-types.ts b/packages/client/src/lib/page-meta-types.ts index 05659f826..21314be41 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,93 @@ 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}`); + 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 ( workspace: WorkspaceService ): Promise => { @@ -369,30 +546,54 @@ const readPageMetaTypeId = ( : null; }; -const pickCanonicalPageMetaType = ( - pageMetaTypes: PageMetaTypeRow[], +const comparePageMetaTypePreference = ( + left: PageMetaTypeRow, + right: PageMetaTypeRow, pageCounts: Map -): 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 ( @@ -553,7 +754,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 +842,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/src/lib/records.ts b/packages/client/src/lib/records.ts index bd4420673..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, @@ -101,6 +100,7 @@ const buildFilterQuery = ( return null; case 'multi_select': return buildMultiSelectFilterQuery(filter, field); + case 'autonumber': case 'number': return buildNumberFilterQuery(filter, field); case 'phone': @@ -222,7 +222,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..5fa5e11e3 --- /dev/null +++ b/packages/client/src/lib/rollup-field-display.ts @@ -0,0 +1,136 @@ +import { + formatLookupFieldValue, + getLookupValueFromNode, +} from '@colanode/client/lib/lookup-field-display'; +import { + FieldAttributes, + FieldValue, + RollupAggregation, + RollupFieldAttributes, +} from '@colanode/core'; + + +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-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-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/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/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/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/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/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 7ed925be7..4fc28dc1f 100644 --- a/packages/client/src/services/app-service.ts +++ b/packages/client/src/services/app-service.ts @@ -8,13 +8,15 @@ import { } from '@colanode/client/databases/app'; 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 { 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 } 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(); @@ -285,6 +324,7 @@ export class AppService { await ensureFileMetaTypes(existing); await ensureScheduledTaskMetaType(existing); await ensureTodoListSystemDatabase(existing); + await upgradeDefaultPageMetaTypeSchema(existing); return existing; } @@ -302,6 +342,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; diff --git a/packages/client/src/services/scheduled-task-service.ts b/packages/client/src/services/scheduled-task-service.ts index 6762e633c..e83c2ef0e 100644 --- a/packages/client/src/services/scheduled-task-service.ts +++ b/packages/client/src/services/scheduled-task-service.ts @@ -1,4 +1,8 @@ import { eventBus } from '@colanode/client/lib/event-bus'; +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 { @@ -16,12 +20,12 @@ 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 } 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/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 new file mode 100644 index 000000000..bf272faa7 --- /dev/null +++ b/packages/client/src/services/workspaces/server-sync-api-client.ts @@ -0,0 +1,200 @@ +import ky from 'ky'; + +import { OpLogSyncRecord } from '@colanode/client/lib/op-log-export'; +import { + SERVER_SYNC_PROTOCOL_VERSION, + SERVER_SYNC_VERSION_HEADER, + ServerSyncPullRecord, + ServerSyncPullResponse, + ServerSyncPushResponse, + ServerSyncWorkspaceManifest, + ServerSyncWorkspaceSummary, +} from '@colanode/core'; + + +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 } }; + +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; +}; +export type ClientServerSyncPullResponse = Omit & { + records: ClientServerSyncPullRecord[]; +}; +export type { + ServerSyncPushResponse, + ServerSyncWorkspaceManifest, + ServerSyncWorkspaceSummary, +}; + +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: { + workspaceId: string; + deviceId: string; + batchId: string; + records: OpLogSyncRecord[]; + clientCursor?: { lastPulledServerSeq: number }; + }): Promise { + const response = await this.http + .post(`${this.baseUrl}/v1/sync/push`, { json: input }) + .json>(); + + if (!response.success) { + throw toSyncApiError(response.error); + } + + return response.data; + } + + public async pull(input: { + workspaceId: string; + deviceId: string; + afterSeq: number; + limit?: number; + }): Promise { + const response = await this.http + .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 toSyncApiError(response.error); + } + + return response.data; + } + + public async uploadFile(input: { + workspaceId: string; + fileId: string; + extension: string; + contentType?: string; + sha256: string; + body: Uint8Array; + }): Promise { + const response = await this.http + .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 toSyncApiError(response.error); + } + } + + public async downloadFile(input: { + workspaceId: string; + fileId: string; + extension: string; + }): Promise { + try { + const response = await this.http + .get(`${this.baseUrl}/v1/files/download`, { + searchParams: { + workspaceId: input.workspaceId, + fileId: input.fileId, + extension: input.extension, + }, + }) + .json>(); + + if (!response.success) { + throw toSyncApiError(response.error); + } + + return base64ToBytes(response.data.bodyBase64); + } catch { + return null; + } + } + + public async listWorkspaces(): Promise { + const response = await this.http + .get(`${this.baseUrl}/v1/sync/workspaces`) + .json>(); + + if (!response.success) { + throw toSyncApiError(response.error); + } + + return response.data.workspaces; + } + + public async upsertWorkspaceManifest( + manifest: ServerSyncWorkspaceManifest + ): Promise { + const response = await this.http + .put(`${this.baseUrl}/v1/sync/workspaces/${manifest.workspaceId}/manifest`, { + json: manifest, + }) + .json>(); + + if (!response.success) { + throw toSyncApiError(response.error); + } + } + + public async deleteWorkspace(workspaceId: string): Promise<{ + deletedRecords: number; + deletedFiles: number; + }> { + const response = await this.http + .delete(`${this.baseUrl}/v1/sync/workspaces/${workspaceId}`) + .json>(); + + if (!response.success) { + throw toSyncApiError(response.error); + } + + 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..14f777955 --- /dev/null +++ b/packages/client/src/services/workspaces/server-sync-realtime.ts @@ -0,0 +1,135 @@ +import { createDebugger , SERVER_SYNC_PROTOCOL_VERSION } 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, + version: SERVER_SYNC_PROTOCOL_VERSION, + }); + + 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..6c1943291 --- /dev/null +++ b/packages/client/src/services/workspaces/server-sync-service.ts @@ -0,0 +1,523 @@ +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'; +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 { Event } from '@colanode/client/types/events'; +import { DownloadStatus } from '@colanode/client/types/files'; +import { + LocalSyncExportSummary, + LocalSyncStatus, +} from '@colanode/client/types/workspaces'; +import { createDebugger, generateId, IdType } from '@colanode/core'; + +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[] = []; + + let hasMore = true; + while (hasMore) { + 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) + ); + + hasMore = page.hasMore; + } + + 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/user-service.ts b/packages/client/src/services/workspaces/user-service.ts index ff28712ba..2428a4ae0 100644 --- a/packages/client/src/services/workspaces/user-service.ts +++ b/packages/client/src/services/workspaces/user-service.ts @@ -1,11 +1,15 @@ 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, + IdType, SyncUserData, UserOutput, UserStatus, + WorkspaceRole, } from '@colanode/core'; 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/src/services/workspaces/workspace-service.ts b/packages/client/src/services/workspaces/workspace-service.ts index 133603078..da48c4991 100644 --- a/packages/client/src/services/workspaces/workspace-service.ts +++ b/packages/client/src/services/workspaces/workspace-service.ts @@ -8,7 +8,7 @@ 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 { CollaborationService } from '@colanode/client/services/workspaces/collaboration-service'; import { DocumentService } from '@colanode/client/services/workspaces/document-service'; @@ -19,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'; @@ -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/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/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/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/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/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/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 () => { 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..10a8b4c0b --- /dev/null +++ b/packages/client/test/rollup-field-display.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; + +import { computeRollupResult } from '@colanode/client/lib/rollup-field-display'; +import { + FieldAttributes, + RollupFieldAttributes, +} from '@colanode/core'; + + +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/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 new file mode 100644 index 000000000..e846465f5 --- /dev/null +++ b/packages/client/test/workspace-members.test.ts @@ -0,0 +1,129 @@ +import { Kysely } from 'kysely'; +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 { 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/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/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/core/src/types/server-sync.ts b/packages/core/src/types/server-sync.ts new file mode 100644 index 000000000..2b5e0bf36 --- /dev/null +++ b/packages/core/src/types/server-sync.ts @@ -0,0 +1,79 @@ +export const SERVER_SYNC_VERSION_HEADER = 'x-colanode-sync-version'; +export const SERVER_SYNC_PROTOCOL_VERSION = '1'; + +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/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-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-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-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..619ee23ea 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,24 @@ 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); + if (output === 'success') { + setInitOutput('success'); + void collections.preload().catch((err) => { + console.error('Colanode | Error preloading collections', err); }); - } else { - setInitOutput(output); - } - }); + } else { + setInitOutput(output); + } + }) + .catch((err) => { + setInitOutput('error'); + console.error('Colanode | Init failed', err); + }); }, []); return ( @@ -46,6 +48,7 @@ export const AppProvider = ({ type }: AppProviderProps) => { {initOutput === null && } {initOutput === 'reset' && } + {initOutput === 'error' && } {initOutput === 'success' && ( 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/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-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/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-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-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-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/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/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/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/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/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-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-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/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 ( + + ); }; 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-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..3170c3bec --- /dev/null +++ b/packages/ui/src/lib/database-csv-import.ts @@ -0,0 +1,207 @@ +import { + FieldAttributes, + FieldValue, + generateId, + IdType, +} from '@colanode/core'; +import { LocalRecordNode } from '@colanode/client/types'; +import { + allocateAutonumbers, + 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; + let fieldsState = Object.fromEntries( + input.database.fields.map((field) => [field.id, field]) + ); + + 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 allocation = allocateAutonumbers(fieldsState); + fieldsState = allocation.fields; + + const record: LocalRecordNode = { + id: generateId(IdType.Record), + type: 'record', + parentId: input.database.id, + rootId: input.database.rootId, + databaseId: input.database.id, + name, + fields: { + ...fields, + ...allocation.values, + }, + createdAt: new Date().toISOString(), + createdBy: input.workspace.userId, + updatedAt: null, + updatedBy: null, + localRevision: '0', + serverRevision: '0', + }; + + input.workspace.collections.nodes.insert(record); + 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/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({ 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 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); + }); +});