Skip to content

Airtable Phase A–C + Server Sync Web MVP#13

Merged
kooksee merged 20 commits into
mainfrom
feat/airtable-phase-a
Jul 10, 2026
Merged

Airtable Phase A–C + Server Sync Web MVP#13
kooksee merged 20 commits into
mainfrom
feat/airtable-phase-a

Conversation

@kooksee

@kooksee kooksee commented Jul 10, 2026

Copy link
Copy Markdown

Summary

  • Database / Airtable alignment (Phase A–C MVP): CSV import/export, lookup/autonumber, bidirectional relations, bulk edit, form view, rollup enhancements, scheduled-task automations, and related UI/tests.
  • Server Sync Web MVP: self-hosted sync server (apps/server) with op-log push/pull, file blobs, workspace join/list/delete, WebSocket realtime + polling fallback, protocol versioning, Docker Compose deployment, and web client integration.
  • CI / release hardening: faster web Docker builds (buildx GHA cache), reliable latest image tagging (workflow_run + retry + manual dispatch), Cloudflare deploy skip when secrets are missing.
  • Merge prep: ignore/untrack apps/server/.data, client lint fixes, Page meta type dedup fix after cloud sync import.

Test plan

  • npm run test -w @colanode/server (9 tests)
  • npm run test -w @colanode/client (133 tests)
  • npm run lint -w @colanode/client
  • Manual: database Airtable test plan (docs/database-airtable-test-plan.md)
  • Manual: server sync smoke test — dual-device realtime, Join flow, docker compose up (docs/server-sync-release-notes.md)
  • After merge: verify GitHub Actions workflow_run mark-latest on main

Made with Cursor

kooksee and others added 20 commits July 3, 2026 18:56
…t/import task details. Add ViewExportSettings component to various database view settings for improved export functionality.
…features list to include `form-view.md`, improve loading indicators in the web app, and upgrade Vite to version 8.1.3. Introduce new scheduled task actions and logs, ensuring better handling of task execution results. Update Airtable alignment documentation to reflect progress and add new fields for scheduled tasks.
…ce loading experience in main application entry, and improve error handling in service worker registration. Update UI components to include loading indicators and streamline collection synchronization with error logging for better debugging.
…ervice workers in development mode, and ensure local-only account initialization in dedicated worker. Update UI routes to handle account loading errors and streamline workspace creation process.
…gs, Owner, Created, and Updated. Implement functionality to upgrade existing Page meta types with these default fields if missing. Update tests to verify the presence of new fields in the Page meta type.
… integrate it into AppService. This includes enhancements to ensure default fields are applied to existing Page meta types, improving overall data consistency.
Introduce workspace.member create/update/delete mutations, UserService CRUD, Members UI in settings, and docs/local-members.md for collaborator-style roster management without server accounts.

Co-authored-by: Cursor <cursoragent@cursor.com>
Fix apps/web Docker build paths for repo-root context, add required cross-origin isolation headers in nginx, and document GHCR-friendly deployment steps.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ement.

This commit introduces server sync capabilities, allowing users to join and delete remote workspaces via the Colanode sync API. It updates the UI to reflect server sync status and modifies the Docker configuration for improved deployment. Additionally, it enhances the workspace creation process and integrates server sync checks into the application logic.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ating headers.

This commit introduces support for sync protocol versioning in both server and client components. It adds version validation in WebSocket routes and request headers, ensuring compatibility between clients and the server. Additionally, it updates the API client to include the sync version in requests, improving overall synchronization reliability.
This commit enhances the synchronization protocol by introducing a version parsing function and updating the WebSocket route to validate the sync version against the server's expected version. It also ensures that the sync version is included in the request headers, improving compatibility checks between clients and the server.
… in sync API client.

This commit introduces a dedicated function for parsing WebSocket subscription queries, enhancing validation and error handling. It also centralizes error message generation in the sync API client, improving clarity and consistency when handling sync protocol errors. These changes aim to streamline the synchronization process and enhance user experience.
Clarify Traefik SSO trust boundary, realtime/WS requirements, protocol version expectations, and add an operator-focused release checklist with rollout order.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add a release-ready summary with suggested tag, included commits, operator guidance, and verification checklist.

Co-authored-by: Cursor <cursoragent@cursor.com>
Enable buildx GHA cache, run latest tagging after image push succeeds, and skip Cloudflare deploy when required secrets are missing.

Co-authored-by: Cursor <cursoragent@cursor.com>
GitHub Actions cannot reference secrets in step if expressions; detect configuration in a prior step instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
Mirror the production release workflow guard so tag pushes do not fail without Cloudflare credentials.

Co-authored-by: Cursor <cursoragent@cursor.com>
Use workflow_run when available, keep tag push trigger with retry, and wait for the version image before retagging latest.

Co-authored-by: Cursor <cursoragent@cursor.com>
Allow marking latest after image push without waiting for merge to main.

Co-authored-by: Cursor <cursoragent@cursor.com>
… dedup fix.

Ignore server runtime data, fix client lint, and prefer in-use synced Page meta types when consolidating duplicates after cloud sync.

Co-authored-by: Cursor <cursoragent@cursor.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a self-hosted sync server (apps/server) and integrates it with the web client to enable realtime, multi-device synchronization, alongside several database enhancements like autonumber fields, lookup fields, bidirectional relations, bulk operations, and a new Form view. The review feedback focuses on critical performance optimizations for the sync server, including avoiding recursive directory scans in memory, indexing the sync_records table for faster workspace listing, grouping WebSocket subscribers by workspace ID to optimize notifications, and deleting file objects in parallel to prevent request timeouts.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +103 to +118
public async listObjects(input: ListObjectsInput): Promise<ListObjectsOutput> {
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),
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The listObjects method reads the entire directory tree recursively into memory via listFilesRecursively before filtering by prefix and slicing for pagination. As the number of files and workspaces grows, this will cause severe performance degradation, high CPU usage, and potential Out-Of-Memory (OOM) crashes. Consider optimizing this by resolving the base directory from the prefix and starting the recursive scan from there if it exists.

  public async listObjects(input: ListObjectsInput): Promise<ListObjectsOutput> {
    const limit = input.limit ?? DEFAULT_LIST_LIMIT;
    let scanDir = this.rootDir;
    const prefixDir = input.prefix.includes('/')
      ? input.prefix.substring(0, input.prefix.lastIndexOf('/'))
      : '';

    if (prefixDir) {
      const absolutePrefixDir = toAbsoluteStoragePath(this.rootDir, prefixDir);
      try {
        await fs.access(absolutePrefixDir);
        scanDir = absolutePrefixDir;
      } catch {
        // Fallback to rootDir if prefix directory doesn't exist yet
      }
    }

    const allItems = await listFilesRecursively(this.rootDir, scanDir);
    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),
    };
  }

Comment on lines +31 to +35
`,
`
CREATE INDEX IF NOT EXISTS idx_sync_records_ws_seq
ON sync_records(workspace_id, server_seq);
`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The workspace listing query in listWorkspaces performs a full table scan and aggregation (COUNT(*), MAX(happened_at)) on the sync_records table grouped by workspace_id. As the op-log grows, this query will become extremely slow. Adding an index on (workspace_id, happened_at) will significantly speed up this aggregation.

  `,
  `
  CREATE INDEX IF NOT EXISTS idx_sync_records_ws_seq
    ON sync_records(workspace_id, server_seq);
  `,
  `
  CREATE INDEX IF NOT EXISTS idx_sync_records_ws_happened
    ON sync_records(workspace_id, happened_at);
  `,

Comment on lines +16 to +67
export class SyncRealtimeHub {
private readonly subscribers = new Set<Subscriber>();

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);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The notify method iterates over all active subscribers in the entire hub to find those matching the workspaceId. If there are many connected clients across different workspaces, this O(N) scan will become a performance bottleneck. Grouping subscribers by workspaceId using a Map<string, Set<Subscriber>> optimizes this lookup to O(M) where M is only the subscribers in the active workspace.

export class SyncRealtimeHub {
  private readonly subscribers = new Map<string, Set<Subscriber>>();

  public subscribe(
    socket: WebSocket,
    workspaceId: string,
    deviceId: string
  ): void {
    const subscriber: Subscriber = { socket, workspaceId, deviceId };
    if (!this.subscribers.has(workspaceId)) {
      this.subscribers.set(workspaceId, new Set());
    }
    this.subscribers.get(workspaceId)!.add(subscriber);

    const cleanup = () => {
      const set = this.subscribers.get(workspaceId);
      if (set) {
        set.delete(subscriber);
        if (set.size === 0) {
          this.subscribers.delete(workspaceId);
        }
      }
    };

    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);

    const workspaceSubscribers = this.subscribers.get(input.workspaceId);
    if (!workspaceSubscribers) {
      return;
    }

    for (const subscriber of workspaceSubscribers) {
      if (subscriber.deviceId === input.fromDeviceId) {
        continue;
      }
      if (subscriber.socket.readyState !== 1) {
        continue;
      }

      subscriber.socket.send(payload);
    }
  }
}

Comment on lines +131 to +140
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.
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Deleting file objects sequentially in a for loop using await storage.deleteObject(key) can be extremely slow if a workspace has many files, potentially causing the HTTP request to time out. Consider deleting them in parallel using Promise.allSettled to avoid blocking the request.

    const objectKeys = repository.listFileObjectKeys(params.data.workspaceId);
    if (storage && objectKeys.length > 0) {
      await Promise.allSettled(
        objectKeys.map((key) => storage.deleteObject(key))
      );
    }

@kooksee kooksee merged commit a5db6d2 into main Jul 10, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant