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 +6 -3
- package/dist/module.json +1 -1
- package/dist/runtime/server/admin/adapters/sync-sqlite.js +12 -34
- package/dist/runtime/server/admin/stores/sqlite-store.js +64 -9
- package/dist/runtime/server/auth/sqlite-auth-workspace-store.d.ts +16 -1
- package/dist/runtime/server/auth/sqlite-auth-workspace-store.js +114 -3
- package/dist/runtime/server/db/migrate.js +45 -1
- package/dist/runtime/server/db/migrations/006_sync_snapshots.d.ts +10 -0
- package/dist/runtime/server/db/migrations/006_sync_snapshots.js +106 -0
- package/dist/runtime/server/db/migrations/007_device_cursor_ownership.d.ts +7 -0
- package/dist/runtime/server/db/migrations/007_device_cursor_ownership.js +5 -0
- package/dist/runtime/server/db/migrations/008_upload_intents.d.ts +4 -0
- package/dist/runtime/server/db/migrations/008_upload_intents.js +26 -0
- package/dist/runtime/server/db/schema.d.ts +40 -0
- package/dist/runtime/server/plugins/register.js +7 -0
- package/dist/runtime/server/sync/history-gc-policy.d.ts +13 -0
- package/dist/runtime/server/sync/history-gc-policy.js +8 -0
- package/dist/runtime/server/sync/sqlite-sync-gateway-adapter.d.ts +14 -2
- package/dist/runtime/server/sync/sqlite-sync-gateway-adapter.js +901 -67
- package/dist/runtime/server/webhooks/sqlite-webhook-store.d.ts +9 -0
- package/dist/runtime/server/webhooks/sqlite-webhook-store.js +521 -0
- package/package.json +5 -1
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
|
|
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
|
-
|
|
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
|
-
- **
|
|
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,14 +1,6 @@
|
|
|
1
1
|
import { createError } from "h3";
|
|
2
|
-
import {
|
|
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(
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
}).
|
|
89
|
-
(oc) => oc.columns(["workspace_id", "user_id"]).doUpdateSet({ role: input.role })
|
|
90
|
-
).execute();
|
|
97
|
+
}).execute();
|
|
91
98
|
}
|
|
92
99
|
async setMemberRole(input) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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.
|
|
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 {
|
|
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,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
|
+
}
|