or3-provider-sqlite 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@ SQLite sync and workspace store provider for OR3 Chat. Provides a lightweight, s
5
5
  ## What it provides
6
6
 
7
7
  - **AuthWorkspaceStore** (`sqlite`) — user identity mapping, workspace CRUD, role resolution
8
- - **SyncGatewayAdapter** (`sqlite`) — push/pull sync with LWW conflict resolution, cursor tracking, GC
8
+ - **SyncGatewayAdapter** (`sqlite`) — push/pull sync, consistent materialized snapshot pages, LWW conflict resolution, and cursor tracking
9
9
 
10
10
  ## Install
11
11
 
@@ -54,10 +54,12 @@ Registration is skipped when `auth.enabled` is `false` (local-only mode).
54
54
 
55
55
  ### Schema
56
56
 
57
- Two migrations create all tables:
57
+ Ordered migrations create and evolve all tables:
58
58
 
59
59
  - **001_init**: `users`, `auth_accounts`, `workspaces`, `workspace_members`
60
60
  - **002_sync_tables**: `server_version_counter`, `change_log`, `device_cursors`, `tombstones`, plus materialized entity tables (`s_threads`, `s_messages`, etc.)
61
+ - **003–005**: workspace-scoped sync keys, invitations, and admin stores
62
+ - **006_sync_snapshots**: winning operation IDs plus immutable snapshot headers/items
61
63
 
62
64
  All tables use snake_case aligned with the sync wire format.
63
65
 
@@ -65,8 +67,9 @@ All tables use snake_case aligned with the sync wire format.
65
67
 
66
68
  - **Push**: validates ops → checks `op_id` idempotency → allocates contiguous `server_version` block → applies LWW to materialized tables → writes change_log → upserts tombstones for deletes
67
69
  - **Pull**: returns ordered changes for `server_version > cursor` with limit/pagination and optional table filtering
70
+ - **Snapshot**: captures canonical live rows and current tombstones with one `highWatermark` under `BEGIN IMMEDIATE`, then serves immutable, keyset-paginated pages ordered by `(tableName, pk, kind)`
68
71
  - **Cursor**: forward-only per-device cursor tracking
69
- - **GC**: tombstone and change_log cleanup respects min device cursor + retention window
72
+ - **Retention safety**: tombstone and `change_log` GC is enabled only under the explicit `snapshot-v1` capability and deletes old revisions acknowledged by every registered device
70
73
 
71
74
  LWW conflict resolution: incoming wins when `clock` is higher, or when clocks are equal and `hlc` is lexicographically greater.
72
75
 
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "or3-provider-sqlite",
3
3
  "configKey": "or3-provider-sqlite",
4
- "version": "0.0.1",
4
+ "version": "0.0.2",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -1,14 +1,6 @@
1
1
  import { createError } from "h3";
2
- import { createSqliteSyncGatewayAdapter } from "../../sync/sqlite-sync-gateway-adapter.js";
2
+ import { SYNC_HISTORY_GC_POLICY } from "../../sync/history-gc-policy.js";
3
3
  const SQLITE_PROVIDER_ID = "sqlite";
4
- const DEFAULT_RETENTION_SECONDS = 30 * 24 * 3600;
5
- function resolveRetentionSeconds(payload) {
6
- const days = typeof payload?.retentionDays === "number" ? payload.retentionDays : null;
7
- const seconds = typeof payload?.retentionSeconds === "number" ? payload.retentionSeconds : null;
8
- if (seconds && Number.isFinite(seconds) && seconds > 0) return Math.floor(seconds);
9
- if (days && Number.isFinite(days) && days > 0) return Math.floor(days * 24 * 3600);
10
- return DEFAULT_RETENTION_SECONDS;
11
- }
12
4
  export const sqliteSyncAdminAdapter = {
13
5
  id: SQLITE_PROVIDER_ID,
14
6
  kind: "sync",
@@ -26,6 +18,10 @@ export const sqliteSyncAdminAdapter = {
26
18
  message: "SQLite is configured with :memory:. Data will not persist across restarts."
27
19
  });
28
20
  }
21
+ warnings.push({
22
+ level: "warning",
23
+ message: SYNC_HISTORY_GC_POLICY.reason
24
+ });
29
25
  return {
30
26
  details: {
31
27
  dbPath: dbPath ?? ":memory:",
@@ -33,39 +29,21 @@ export const sqliteSyncAdminAdapter = {
33
29
  synchronous: process.env.OR3_SQLITE_PRAGMA_SYNCHRONOUS ?? "NORMAL"
34
30
  },
35
31
  warnings,
36
- actions: [
37
- {
38
- id: "sync.gc-change-log",
39
- label: "Run Sync Change Log GC",
40
- description: "Purge old change_log entries after retention and device cursor checks.",
41
- danger: true
42
- },
43
- {
44
- id: "sync.gc-tombstones",
45
- label: "Run Sync Tombstone GC",
46
- description: "Purge old tombstones after retention and device cursor checks.",
47
- danger: true
48
- }
49
- ]
32
+ actions: []
50
33
  };
51
34
  },
52
- async runAction(event, actionId, payload, ctx) {
35
+ async runAction(_event, actionId, _payload, ctx) {
53
36
  if (!ctx.session.workspace?.id) {
54
37
  throw createError({
55
38
  statusCode: 400,
56
39
  statusMessage: "Workspace not resolved"
57
40
  });
58
41
  }
59
- const adapter = createSqliteSyncGatewayAdapter();
60
- const retentionSeconds = resolveRetentionSeconds(payload);
61
- const scope = { workspaceId: ctx.session.workspace.id };
62
- if (actionId === "sync.gc-change-log") {
63
- await adapter.gcChangeLog?.(event, { scope, retentionSeconds });
64
- return { ok: true, action: actionId, retentionSeconds };
65
- }
66
- if (actionId === "sync.gc-tombstones") {
67
- await adapter.gcTombstones?.(event, { scope, retentionSeconds });
68
- return { ok: true, action: actionId, retentionSeconds };
42
+ if (actionId === "sync.gc-change-log" || actionId === "sync.gc-tombstones") {
43
+ throw createError({
44
+ statusCode: 503,
45
+ statusMessage: SYNC_HISTORY_GC_POLICY.reason
46
+ });
69
47
  }
70
48
  throw createError({ statusCode: 400, statusMessage: "Unknown action" });
71
49
  }
@@ -79,25 +79,80 @@ class SqliteWorkspaceAccessStore {
79
79
  ).execute();
80
80
  }
81
81
  }
82
+ const existingMembership = await this.db.selectFrom("workspace_members").select("role").where("workspace_id", "=", input.workspaceId).where("user_id", "=", userId).executeTakeFirst();
83
+ if (existingMembership) {
84
+ await this.setMemberRole({
85
+ workspaceId: input.workspaceId,
86
+ userId,
87
+ role: input.role
88
+ });
89
+ return;
90
+ }
82
91
  await this.db.insertInto("workspace_members").values({
83
92
  id: randomUUID(),
84
93
  workspace_id: input.workspaceId,
85
94
  user_id: userId,
86
95
  role: input.role,
87
96
  created_at: now
88
- }).onConflict(
89
- (oc) => oc.columns(["workspace_id", "user_id"]).doUpdateSet({ role: input.role })
90
- ).execute();
97
+ }).execute();
91
98
  }
92
99
  async setMemberRole(input) {
93
- const result = await this.db.updateTable("workspace_members").set({ role: input.role }).where("workspace_id", "=", input.workspaceId).where("user_id", "=", input.userId).executeTakeFirst();
94
- const updated = Number(result.numUpdatedRows ?? 0);
95
- if (updated === 0) {
96
- throw new Error("Workspace member not found");
97
- }
100
+ await this.db.transaction().execute(async (tx) => {
101
+ const member = await tx.selectFrom("workspace_members").select("role").where("workspace_id", "=", input.workspaceId).where("user_id", "=", input.userId).executeTakeFirst();
102
+ if (!member) {
103
+ throw new Error("Workspace member not found");
104
+ }
105
+ if (member.role === "owner" && input.role !== "owner") {
106
+ const owners = await tx.selectFrom("workspace_members").select("user_id").where("workspace_id", "=", input.workspaceId).where("role", "=", "owner").execute();
107
+ if (owners.length <= 1) {
108
+ throw new Error(
109
+ "Cannot demote the last workspace owner. Transfer ownership first."
110
+ );
111
+ }
112
+ const workspace = await tx.selectFrom("workspaces").select("owner_user_id").where("id", "=", input.workspaceId).executeTakeFirst();
113
+ if (workspace?.owner_user_id === input.userId) {
114
+ const replacement = owners.find(
115
+ (owner) => owner.user_id !== input.userId
116
+ );
117
+ if (!replacement) {
118
+ throw new Error(
119
+ "Cannot demote the last workspace owner. Transfer ownership first."
120
+ );
121
+ }
122
+ await tx.updateTable("workspaces").set({ owner_user_id: replacement.user_id }).where("id", "=", input.workspaceId).execute();
123
+ }
124
+ }
125
+ await tx.updateTable("workspace_members").set({ role: input.role }).where("workspace_id", "=", input.workspaceId).where("user_id", "=", input.userId).execute();
126
+ });
98
127
  }
99
128
  async removeMember(input) {
100
- await this.db.deleteFrom("workspace_members").where("workspace_id", "=", input.workspaceId).where("user_id", "=", input.userId).execute();
129
+ await this.db.transaction().execute(async (tx) => {
130
+ const member = await tx.selectFrom("workspace_members").select("role").where("workspace_id", "=", input.workspaceId).where("user_id", "=", input.userId).executeTakeFirst();
131
+ if (!member) {
132
+ throw new Error("Workspace member not found");
133
+ }
134
+ if (member.role === "owner") {
135
+ const owners = await tx.selectFrom("workspace_members").select("user_id").where("workspace_id", "=", input.workspaceId).where("role", "=", "owner").execute();
136
+ if (owners.length <= 1) {
137
+ throw new Error(
138
+ "Cannot remove the last workspace owner. Transfer ownership first."
139
+ );
140
+ }
141
+ const workspace = await tx.selectFrom("workspaces").select("owner_user_id").where("id", "=", input.workspaceId).executeTakeFirst();
142
+ if (workspace?.owner_user_id === input.userId) {
143
+ const replacement = owners.find(
144
+ (owner) => owner.user_id !== input.userId
145
+ );
146
+ if (!replacement) {
147
+ throw new Error(
148
+ "Cannot remove the last workspace owner. Transfer ownership first."
149
+ );
150
+ }
151
+ await tx.updateTable("workspaces").set({ owner_user_id: replacement.user_id }).where("id", "=", input.workspaceId).execute();
152
+ }
153
+ }
154
+ await tx.deleteFrom("workspace_members").where("workspace_id", "=", input.workspaceId).where("user_id", "=", input.userId).execute();
155
+ });
101
156
  }
102
157
  async listWorkspaces(input) {
103
158
  const includeDeleted = input.includeDeleted === true;
@@ -4,10 +4,11 @@
4
4
  * Maps provider identity -> internal user and manages workspace CRUD.
5
5
  * Uses Kysely for all queries. No Convex or Clerk SDK imports.
6
6
  */
7
- import type { AuthWorkspaceStore } from '~~/server/auth/store/types';
7
+ import type { AuthWorkspaceStore, InviteProvisionResult, InviteValidationResult } from '~~/server/auth/store/types';
8
8
  import type { WorkspaceRole } from '~~/app/core/hooks/hook-types';
9
9
  export declare class SqliteAuthWorkspaceStore implements AuthWorkspaceStore {
10
10
  private get db();
11
+ protected onInviteProvisionStep(_step: 'invite_validated' | 'user_created' | 'membership_created' | 'invite_consumed'): void;
11
12
  getOrCreateUser(input: {
12
13
  provider: string;
13
14
  providerUserId: string;
@@ -27,6 +28,7 @@ export declare class SqliteAuthWorkspaceStore implements AuthWorkspaceStore {
27
28
  getOrCreateDefaultWorkspace(userId: string): Promise<{
28
29
  workspaceId: string;
29
30
  workspaceName: string;
31
+ created?: boolean;
30
32
  }>;
31
33
  getWorkspaceRole(input: {
32
34
  userId: string;
@@ -95,6 +97,19 @@ export declare class SqliteAuthWorkspaceStore implements AuthWorkspaceStore {
95
97
  inviteId: string;
96
98
  revokedByUserId: string;
97
99
  }): Promise<void>;
100
+ validateInvite(input: {
101
+ workspaceId: string;
102
+ email: string;
103
+ tokenHash: string;
104
+ }): Promise<InviteValidationResult>;
105
+ acceptInviteAndProvisionUser(input: {
106
+ provider: string;
107
+ providerUserId: string;
108
+ email: string;
109
+ displayName?: string;
110
+ workspaceId: string;
111
+ tokenHash: string;
112
+ }): Promise<InviteProvisionResult>;
98
113
  consumeInvite(input: {
99
114
  workspaceId: string;
100
115
  email: string;
@@ -13,6 +13,8 @@ export class SqliteAuthWorkspaceStore {
13
13
  get db() {
14
14
  return getSqliteDb();
15
15
  }
16
+ onInviteProvisionStep(_step) {
17
+ }
16
18
  async getOrCreateUser(input) {
17
19
  this.db;
18
20
  const raw = getRawDb();
@@ -94,7 +96,8 @@ export class SqliteAuthWorkspaceStore {
94
96
  if (activeMembership) {
95
97
  return {
96
98
  workspaceId: activeMembership.id,
97
- workspaceName: activeMembership.name
99
+ workspaceName: activeMembership.name,
100
+ created: false
98
101
  };
99
102
  }
100
103
  const membership = await db.selectFrom("workspace_members").innerJoin("workspaces", "workspaces.id", "workspace_members.workspace_id").select([
@@ -103,7 +106,11 @@ export class SqliteAuthWorkspaceStore {
103
106
  ]).where("workspace_members.user_id", "=", userId).where("workspaces.deleted", "=", 0).orderBy("workspace_members.created_at", "asc").executeTakeFirst();
104
107
  if (membership) {
105
108
  await db.updateTable("users").set({ active_workspace_id: membership.id }).where("id", "=", userId).execute();
106
- return { workspaceId: membership.id, workspaceName: membership.name };
109
+ return {
110
+ workspaceId: membership.id,
111
+ workspaceName: membership.name,
112
+ created: false
113
+ };
107
114
  }
108
115
  const workspaceId = uid();
109
116
  const memberId = uid();
@@ -128,7 +135,7 @@ export class SqliteAuthWorkspaceStore {
128
135
  }).execute();
129
136
  await tx.updateTable("users").set({ active_workspace_id: workspaceId }).where("id", "=", userId).execute();
130
137
  });
131
- return { workspaceId, workspaceName: name };
138
+ return { workspaceId, workspaceName: name, created: true };
132
139
  }
133
140
  async getWorkspaceRole(input) {
134
141
  const db = this.db;
@@ -277,6 +284,110 @@ export class SqliteAuthWorkspaceStore {
277
284
  }).where("workspace_id", "=", input.workspaceId).where("id", "=", input.inviteId).execute();
278
285
  void input.revokedByUserId;
279
286
  }
287
+ async validateInvite(input) {
288
+ this.db;
289
+ const raw = getRawDb();
290
+ const now = nowEpoch();
291
+ const invite = raw.prepare(
292
+ `SELECT role, status, email, expires_at
293
+ FROM auth_invites
294
+ WHERE workspace_id = ? AND token_hash = ?
295
+ LIMIT 1`
296
+ ).get(input.workspaceId, input.tokenHash);
297
+ if (!invite) return { ok: false, reason: "not_found" };
298
+ if (invite.email !== normalizeEmail(input.email)) {
299
+ return { ok: false, reason: "email_mismatch" };
300
+ }
301
+ if (invite.status === "accepted") return { ok: false, reason: "already_used" };
302
+ if (invite.status === "revoked") return { ok: false, reason: "revoked" };
303
+ if (invite.status === "expired" || invite.expires_at <= now) {
304
+ return { ok: false, reason: "expired" };
305
+ }
306
+ return { ok: true, role: invite.role };
307
+ }
308
+ async acceptInviteAndProvisionUser(input) {
309
+ this.db;
310
+ const raw = getRawDb();
311
+ const now = nowEpoch();
312
+ const normalizedEmail = normalizeEmail(input.email);
313
+ return raw.transaction(() => {
314
+ const invite = raw.prepare(
315
+ `SELECT id, role, status, email, expires_at
316
+ FROM auth_invites
317
+ WHERE workspace_id = ? AND token_hash = ?
318
+ LIMIT 1`
319
+ ).get(input.workspaceId, input.tokenHash);
320
+ if (!invite) return { ok: false, reason: "not_found" };
321
+ if (invite.email !== normalizedEmail) {
322
+ return { ok: false, reason: "email_mismatch" };
323
+ }
324
+ if (invite.status === "accepted") {
325
+ return { ok: false, reason: "already_used" };
326
+ }
327
+ if (invite.status === "revoked") {
328
+ return { ok: false, reason: "revoked" };
329
+ }
330
+ if (invite.status === "expired" || invite.expires_at <= now) {
331
+ return { ok: false, reason: "expired" };
332
+ }
333
+ this.onInviteProvisionStep("invite_validated");
334
+ const existingAccount = raw.prepare(
335
+ `SELECT user_id
336
+ FROM auth_accounts
337
+ WHERE provider = ? AND provider_user_id = ?
338
+ LIMIT 1`
339
+ ).get(input.provider, input.providerUserId);
340
+ const userId = existingAccount?.user_id ?? uid();
341
+ const createdUser = !existingAccount;
342
+ if (createdUser) {
343
+ raw.prepare(
344
+ `INSERT INTO users (id, email, display_name, active_workspace_id, created_at)
345
+ VALUES (?, ?, ?, NULL, ?)`
346
+ ).run(userId, normalizedEmail, input.displayName ?? null, now);
347
+ raw.prepare(
348
+ `INSERT INTO auth_accounts (id, user_id, provider, provider_user_id, created_at)
349
+ VALUES (?, ?, ?, ?, ?)`
350
+ ).run(uid(), userId, input.provider, input.providerUserId, now);
351
+ }
352
+ this.onInviteProvisionStep("user_created");
353
+ const existingMembership = raw.prepare(
354
+ `SELECT id FROM workspace_members
355
+ WHERE workspace_id = ? AND user_id = ?
356
+ LIMIT 1`
357
+ ).get(input.workspaceId, userId);
358
+ if (existingMembership) {
359
+ raw.prepare("UPDATE workspace_members SET role = ? WHERE id = ?").run(
360
+ invite.role,
361
+ existingMembership.id
362
+ );
363
+ } else {
364
+ raw.prepare(
365
+ `INSERT INTO workspace_members (id, workspace_id, user_id, role, created_at)
366
+ VALUES (?, ?, ?, ?, ?)`
367
+ ).run(uid(), input.workspaceId, userId, invite.role, now);
368
+ }
369
+ raw.prepare("UPDATE users SET active_workspace_id = ? WHERE id = ?").run(
370
+ input.workspaceId,
371
+ userId
372
+ );
373
+ this.onInviteProvisionStep("membership_created");
374
+ const consumed = raw.prepare(
375
+ `UPDATE auth_invites
376
+ SET status = 'accepted', accepted_at = ?, accepted_user_id = ?, updated_at = ?
377
+ WHERE id = ? AND status = 'pending'`
378
+ ).run(now, userId, now, invite.id);
379
+ if (consumed.changes !== 1) {
380
+ throw new Error("Invite state changed during atomic acceptance");
381
+ }
382
+ this.onInviteProvisionStep("invite_consumed");
383
+ return {
384
+ ok: true,
385
+ userId,
386
+ role: invite.role,
387
+ createdUser
388
+ };
389
+ }).immediate();
390
+ }
280
391
  async consumeInvite(input) {
281
392
  this.db;
282
393
  const raw = getRawDb();
@@ -4,18 +4,61 @@ import * as m002 from "./migrations/002_sync_tables.js";
4
4
  import * as m003 from "./migrations/003_sync_hardening.js";
5
5
  import * as m004 from "./migrations/004_auth_invites.js";
6
6
  import * as m005 from "./migrations/005_admin_stores.js";
7
+ import * as m006 from "./migrations/006_sync_snapshots.js";
8
+ import * as m007 from "./migrations/007_device_cursor_ownership.js";
9
+ import * as m008 from "./migrations/008_upload_intents.js";
10
+ const REQUIRED_SCHEMA_TABLES = [
11
+ "users",
12
+ "auth_accounts",
13
+ "workspaces",
14
+ "workspace_members",
15
+ "server_version_counter",
16
+ "change_log",
17
+ "device_cursors",
18
+ "tombstones",
19
+ "s_threads",
20
+ "s_messages",
21
+ "s_projects",
22
+ "s_posts",
23
+ "s_kv",
24
+ "s_file_meta",
25
+ "s_notifications",
26
+ "auth_invites",
27
+ "admin_users",
28
+ "admin_workspace_settings",
29
+ "sync_snapshots",
30
+ "sync_snapshot_items",
31
+ "upload_intents"
32
+ ];
7
33
  const migrations = {
8
34
  "001_init": m001,
9
35
  "002_sync_tables": m002,
10
36
  "003_sync_hardening": m003,
11
37
  "004_auth_invites": m004,
12
- "005_admin_stores": m005
38
+ "005_admin_stores": m005,
39
+ "006_sync_snapshots": m006,
40
+ "007_device_cursor_ownership": m007,
41
+ "008_upload_intents": m008
13
42
  };
14
43
  class StaticMigrationProvider {
15
44
  async getMigrations() {
16
45
  return migrations;
17
46
  }
18
47
  }
48
+ async function assertLatestSchemaIntegrity(db) {
49
+ const tables = await db.introspection.getTables({
50
+ withInternalKyselyTables: true
51
+ });
52
+ const tableNames = new Set(tables.map((table) => table.name));
53
+ const missing = REQUIRED_SCHEMA_TABLES.filter(
54
+ (tableName) => !tableNames.has(tableName)
55
+ );
56
+ if (missing.length > 0) {
57
+ throw new Error(
58
+ `SQLite schema integrity check failed; required tables are missing: ${missing.join(", ")}`
59
+ );
60
+ }
61
+ }
19
62
  export async function runMigrations(db) {
20
63
  const migrator = new Migrator({
21
64
  db,
@@ -35,4 +78,5 @@ export async function runMigrations(db) {
35
78
  console.error("[or3-sqlite] migration error:", error);
36
79
  throw error;
37
80
  }
81
+ await assertLatestSchemaIntegrity(db);
38
82
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Migration 006: Durable, consistent sync snapshot pages.
3
+ *
4
+ * - Stores the winning operation ID on materialized rows and tombstones.
5
+ * - Adds immutable snapshot headers/items for pagination across requests.
6
+ * - Backfills deterministic operation IDs for existing materialized state.
7
+ */
8
+ import type { Kysely } from 'kysely';
9
+ export declare function up(db: Kysely<unknown>): Promise<void>;
10
+ export declare function down(db: Kysely<unknown>): Promise<void>;
@@ -0,0 +1,106 @@
1
+ import { sql } from "kysely";
2
+ const SYNCED_TABLES = [
3
+ ["s_threads", "threads"],
4
+ ["s_messages", "messages"],
5
+ ["s_projects", "projects"],
6
+ ["s_posts", "posts"],
7
+ ["s_kv", "kv"],
8
+ ["s_file_meta", "file_meta"],
9
+ ["s_notifications", "notifications"]
10
+ ];
11
+ export async function up(db) {
12
+ for (const [materializedTable, tableName] of SYNCED_TABLES) {
13
+ await sql.raw(
14
+ `ALTER TABLE "${materializedTable}" ADD COLUMN op_id TEXT NOT NULL DEFAULT ''`
15
+ ).execute(db);
16
+ await sql.raw(`
17
+ UPDATE "${materializedTable}" AS materialized
18
+ SET op_id = COALESCE(
19
+ (
20
+ SELECT change.op_id
21
+ FROM change_log AS change
22
+ WHERE change.workspace_id = materialized.workspace_id
23
+ AND change.table_name = '${tableName}'
24
+ AND change.pk = materialized.id
25
+ AND change.op = CASE WHEN materialized.deleted <> 0 THEN 'delete' ELSE 'put' END
26
+ AND change.clock = materialized.clock
27
+ AND change.hlc = materialized.hlc
28
+ ORDER BY change.server_version DESC
29
+ LIMIT 1
30
+ ),
31
+ 'legacy:${tableName}:' || materialized.id || ':' || materialized.clock || ':' || materialized.hlc
32
+ )
33
+ WHERE materialized.op_id = ''
34
+ `).execute(db);
35
+ }
36
+ await sql.raw(
37
+ `ALTER TABLE tombstones ADD COLUMN hlc TEXT NOT NULL DEFAULT ''`
38
+ ).execute(db);
39
+ await sql.raw(
40
+ `ALTER TABLE tombstones ADD COLUMN op_id TEXT NOT NULL DEFAULT ''`
41
+ ).execute(db);
42
+ await sql.raw(`
43
+ UPDATE tombstones AS tombstone
44
+ SET
45
+ hlc = COALESCE(
46
+ (
47
+ SELECT change.hlc
48
+ FROM change_log AS change
49
+ WHERE change.workspace_id = tombstone.workspace_id
50
+ AND change.table_name = tombstone.table_name
51
+ AND change.pk = tombstone.pk
52
+ AND change.op = 'delete'
53
+ AND change.server_version = tombstone.server_version
54
+ LIMIT 1
55
+ ),
56
+ CASE WHEN tombstone.hlc <> '' THEN tombstone.hlc ELSE 'legacy:0' END
57
+ ),
58
+ op_id = COALESCE(
59
+ (
60
+ SELECT change.op_id
61
+ FROM change_log AS change
62
+ WHERE change.workspace_id = tombstone.workspace_id
63
+ AND change.table_name = tombstone.table_name
64
+ AND change.pk = tombstone.pk
65
+ AND change.op = 'delete'
66
+ AND change.server_version = tombstone.server_version
67
+ LIMIT 1
68
+ ),
69
+ 'legacy:tombstone:' || tombstone.table_name || ':' || tombstone.pk || ':' || tombstone.clock
70
+ )
71
+ WHERE tombstone.hlc = '' OR tombstone.op_id = ''
72
+ `).execute(db);
73
+ await sql.raw(`
74
+ CREATE TABLE sync_snapshots (
75
+ id TEXT PRIMARY KEY,
76
+ workspace_id TEXT NOT NULL,
77
+ high_watermark INTEGER NOT NULL,
78
+ tables_json TEXT NOT NULL,
79
+ created_at INTEGER NOT NULL,
80
+ expires_at INTEGER NOT NULL
81
+ )
82
+ `).execute(db);
83
+ await sql.raw(`
84
+ CREATE INDEX idx_sync_snapshots_expires
85
+ ON sync_snapshots(expires_at)
86
+ `).execute(db);
87
+ await sql.raw(`
88
+ CREATE TABLE sync_snapshot_items (
89
+ snapshot_id TEXT NOT NULL,
90
+ table_name TEXT NOT NULL,
91
+ pk TEXT NOT NULL,
92
+ kind TEXT NOT NULL CHECK(kind IN ('row', 'tombstone')),
93
+ payload_json TEXT,
94
+ clock INTEGER NOT NULL,
95
+ hlc TEXT NOT NULL,
96
+ op_id TEXT NOT NULL,
97
+ server_deleted_at INTEGER,
98
+ PRIMARY KEY (snapshot_id, table_name, pk, kind),
99
+ FOREIGN KEY (snapshot_id) REFERENCES sync_snapshots(id) ON DELETE CASCADE
100
+ ) WITHOUT ROWID
101
+ `).execute(db);
102
+ }
103
+ export async function down(db) {
104
+ await db.schema.dropTable("sync_snapshot_items").ifExists().execute();
105
+ await db.schema.dropTable("sync_snapshots").ifExists().execute();
106
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Migration 007: bind device cursors to the authenticated user that created
3
+ * them. Legacy rows remain unbound until their next authenticated update.
4
+ */
5
+ import type { Kysely } from 'kysely';
6
+ export declare function up(db: Kysely<unknown>): Promise<void>;
7
+ export declare function down(_db: Kysely<unknown>): Promise<void>;
@@ -0,0 +1,5 @@
1
+ export async function up(db) {
2
+ await db.schema.alterTable("device_cursors").addColumn("owner_user_id", "text").execute();
3
+ }
4
+ export async function down(_db) {
5
+ }
@@ -0,0 +1,4 @@
1
+ import { type Kysely } from 'kysely';
2
+ /** Persisted upload intents are both one-time authorizations and quota reservations. */
3
+ export declare function up(db: Kysely<unknown>): Promise<void>;
4
+ export declare function down(db: Kysely<unknown>): Promise<void>;
@@ -0,0 +1,26 @@
1
+ import { sql } from "kysely";
2
+ export async function up(db) {
3
+ await sql`
4
+ CREATE TABLE upload_intents (
5
+ id TEXT PRIMARY KEY,
6
+ workspace_id TEXT NOT NULL,
7
+ hash TEXT NOT NULL,
8
+ mime_type TEXT NOT NULL,
9
+ size_bytes INTEGER NOT NULL,
10
+ reserved_bytes INTEGER NOT NULL,
11
+ expires_at INTEGER NOT NULL,
12
+ status TEXT NOT NULL CHECK (status IN ('active', 'consumed', 'cancelled', 'expired')),
13
+ storage_id TEXT,
14
+ created_at INTEGER NOT NULL,
15
+ consumed_at INTEGER,
16
+ cancelled_at INTEGER
17
+ )
18
+ `.execute(db);
19
+ await sql`CREATE INDEX upload_intents_workspace_status_expiry
20
+ ON upload_intents(workspace_id, status, expires_at, id)`.execute(db);
21
+ await sql`CREATE INDEX upload_intents_workspace_hash_status
22
+ ON upload_intents(workspace_id, hash, status)`.execute(db);
23
+ }
24
+ export async function down(db) {
25
+ await db.schema.dropTable("upload_intents").ifExists().execute();
26
+ }