or3-provider-sqlite 0.0.1 → 0.0.3
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 +20 -4
- 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/connect/sqlite-connect-store.d.ts +2 -0
- package/dist/runtime/server/connect/sqlite-connect-store.js +885 -0
- package/dist/runtime/server/db/migrate.js +62 -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/migrations/009_or3_connect.d.ts +4 -0
- package/dist/runtime/server/db/migrations/009_or3_connect.js +91 -0
- package/dist/runtime/server/db/migrations/010_connect_user_code_hmac.d.ts +10 -0
- package/dist/runtime/server/db/migrations/010_connect_user_code_hmac.js +97 -0
- package/dist/runtime/server/db/migrations/011_connect_environment_scope_indexes.d.ts +4 -0
- package/dist/runtime/server/db/migrations/011_connect_environment_scope_indexes.js +26 -0
- package/dist/runtime/server/db/migrations/012_connect_credential_redelivery.d.ts +4 -0
- package/dist/runtime/server/db/migrations/012_connect_credential_redelivery.js +124 -0
- package/dist/runtime/server/db/migrations/013_connect_environment_lifecycle.d.ts +10 -0
- package/dist/runtime/server/db/migrations/013_connect_environment_lifecycle.js +261 -0
- package/dist/runtime/server/db/migrations/014_connect_retention_activation.d.ts +4 -0
- package/dist/runtime/server/db/migrations/014_connect_retention_activation.js +44 -0
- package/dist/runtime/server/db/migrations/015_rate_limits.d.ts +4 -0
- package/dist/runtime/server/db/migrations/015_rate_limits.js +18 -0
- package/dist/runtime/server/db/schema.d.ts +97 -0
- package/dist/runtime/server/plugins/register.js +27 -5
- package/dist/runtime/server/rate-limit/sqlite-provider.d.ts +7 -0
- package/dist/runtime/server/rate-limit/sqlite-provider.js +70 -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 +9 -1
|
@@ -1,20 +1,138 @@
|
|
|
1
|
+
import { canRunSyncHistoryGc } from "./history-gc-policy.js";
|
|
1
2
|
import { createError } from "h3";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { getSqliteDb, getRawDb } from "../db/kysely.js";
|
|
4
5
|
import { SYNCED_TABLE_MAP, ALLOWED_SYNC_TABLES } from "../db/schema.js";
|
|
6
|
+
import { emitWebhookSystemHook } from "~~/server/utils/webhooks/runtime";
|
|
5
7
|
const DEFAULT_PULL_LIMIT = 100;
|
|
6
8
|
const MAX_PULL_LIMIT = 1e3;
|
|
9
|
+
const MAX_SNAPSHOT_PAGE_SIZE = 1e3;
|
|
10
|
+
const DEFAULT_CANONICAL_STORAGE_PAGE_SIZE = 100;
|
|
11
|
+
const MAX_CANONICAL_STORAGE_PAGE_SIZE = 500;
|
|
12
|
+
const SNAPSHOT_TTL_SECONDS = 60 * 60;
|
|
7
13
|
const SESSION_CONTEXT_KEY_PREFIX = "__or3_session_context_";
|
|
14
|
+
const MIN_SYNC_RETENTION_SECONDS = 60 * 60;
|
|
15
|
+
const MAX_SYNC_RETENTION_SECONDS = 365 * 24 * 60 * 60;
|
|
8
16
|
function uid() {
|
|
9
17
|
return randomUUID();
|
|
10
18
|
}
|
|
11
19
|
function nowEpoch() {
|
|
12
20
|
return Math.floor(Date.now() / 1e3);
|
|
13
21
|
}
|
|
22
|
+
function stableJson(value) {
|
|
23
|
+
if (value === void 0) return "undefined";
|
|
24
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
25
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
26
|
+
const record = value;
|
|
27
|
+
return `{${Object.keys(record).sort().map(
|
|
28
|
+
(key) => `${JSON.stringify(key)}:${stableJson(record[key])}`
|
|
29
|
+
).join(",")}}`;
|
|
30
|
+
}
|
|
31
|
+
function operationFingerprint(op) {
|
|
32
|
+
return stableJson({
|
|
33
|
+
tableName: op.tableName,
|
|
34
|
+
operation: op.operation,
|
|
35
|
+
pk: op.pk,
|
|
36
|
+
payload: op.payload,
|
|
37
|
+
clock: op.stamp.clock,
|
|
38
|
+
hlc: op.stamp.hlc,
|
|
39
|
+
deviceId: op.stamp.deviceId
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function validatePushOperation(workspaceId, op) {
|
|
43
|
+
if (!ALLOWED_SYNC_TABLES.includes(op.tableName)) {
|
|
44
|
+
return `Invalid table: ${op.tableName}`;
|
|
45
|
+
}
|
|
46
|
+
if (op.payload !== void 0 && (typeof op.payload !== "object" || op.payload === null || Array.isArray(op.payload))) {
|
|
47
|
+
return `Invalid payload for ${op.tableName}: payload must be an object`;
|
|
48
|
+
}
|
|
49
|
+
const payload = op.payload;
|
|
50
|
+
if (!payload) return void 0;
|
|
51
|
+
try {
|
|
52
|
+
JSON.stringify(payload);
|
|
53
|
+
} catch {
|
|
54
|
+
return `Invalid payload for ${op.tableName}: payload is not serializable`;
|
|
55
|
+
}
|
|
56
|
+
if ("_id" in payload) return `Invalid payload for ${op.tableName}: '_id' is immutable`;
|
|
57
|
+
if ("workspace_id" in payload && payload.workspace_id !== workspaceId) {
|
|
58
|
+
return `Invalid payload for ${op.tableName}: 'workspace_id' is immutable`;
|
|
59
|
+
}
|
|
60
|
+
const pkField = op.tableName === "file_meta" ? "hash" : "id";
|
|
61
|
+
if (pkField in payload && payload[pkField] !== op.pk) {
|
|
62
|
+
return `Invalid payload for ${op.tableName}: '${pkField}' must match operation pk`;
|
|
63
|
+
}
|
|
64
|
+
return void 0;
|
|
65
|
+
}
|
|
66
|
+
function normalizeStorageHash(value) {
|
|
67
|
+
return value.replace(/^sha256:/i, "").replace(/^md5:/i, "").trim().toLowerCase();
|
|
68
|
+
}
|
|
69
|
+
function resolveCanonicalStorageLimit(limit) {
|
|
70
|
+
if (typeof limit !== "number" || !Number.isFinite(limit)) {
|
|
71
|
+
return DEFAULT_CANONICAL_STORAGE_PAGE_SIZE;
|
|
72
|
+
}
|
|
73
|
+
return Math.max(1, Math.min(Math.floor(limit), MAX_CANONICAL_STORAGE_PAGE_SIZE));
|
|
74
|
+
}
|
|
75
|
+
function encodeCanonicalStorageCursor(cursor) {
|
|
76
|
+
return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
|
|
77
|
+
}
|
|
78
|
+
function decodeCanonicalStorageCursor(input, kind, hash) {
|
|
79
|
+
if (!input) return [];
|
|
80
|
+
try {
|
|
81
|
+
if (input.length > 2048) throw new Error("oversized");
|
|
82
|
+
const parsed = JSON.parse(Buffer.from(input, "base64url").toString("utf8"));
|
|
83
|
+
if (parsed.version !== 1 || parsed.kind !== kind || parsed.hash !== hash || !Array.isArray(parsed.key) || !parsed.key.every((part) => typeof part === "string")) {
|
|
84
|
+
throw new Error("mismatch");
|
|
85
|
+
}
|
|
86
|
+
return parsed.key;
|
|
87
|
+
} catch {
|
|
88
|
+
throw createError({ statusCode: 400, statusMessage: "Invalid canonical storage cursor" });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
14
91
|
function resolvePullLimit(limit) {
|
|
15
92
|
if (typeof limit !== "number" || !Number.isFinite(limit)) return DEFAULT_PULL_LIMIT;
|
|
16
93
|
return Math.max(1, Math.min(Math.floor(limit), MAX_PULL_LIMIT));
|
|
17
94
|
}
|
|
95
|
+
function resolveSnapshotPageSize(pageSize) {
|
|
96
|
+
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > MAX_SNAPSHOT_PAGE_SIZE) {
|
|
97
|
+
throw createError({
|
|
98
|
+
statusCode: 400,
|
|
99
|
+
statusMessage: `pageSize must be an integer between 1 and ${MAX_SNAPSHOT_PAGE_SIZE}`
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return pageSize;
|
|
103
|
+
}
|
|
104
|
+
function normalizeSnapshotTables(tables) {
|
|
105
|
+
const requested = tables?.length ? [...new Set(tables)] : [...ALLOWED_SYNC_TABLES];
|
|
106
|
+
for (const tableName of requested) {
|
|
107
|
+
if (!ALLOWED_SYNC_TABLES.includes(tableName)) {
|
|
108
|
+
throw createError({
|
|
109
|
+
statusCode: 400,
|
|
110
|
+
statusMessage: `Invalid snapshot table: ${tableName}`
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return requested.sort();
|
|
115
|
+
}
|
|
116
|
+
function encodeSnapshotPageToken(token) {
|
|
117
|
+
return Buffer.from(JSON.stringify(token), "utf8").toString("base64url");
|
|
118
|
+
}
|
|
119
|
+
function decodeSnapshotPageToken(value) {
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(
|
|
122
|
+
Buffer.from(value, "base64url").toString("utf8")
|
|
123
|
+
);
|
|
124
|
+
const after = parsed.after;
|
|
125
|
+
if (parsed.version !== 1 || typeof parsed.snapshotId !== "string" || !parsed.snapshotId || !after || typeof after.tableName !== "string" || typeof after.pk !== "string" || after.kind !== "row" && after.kind !== "tombstone") {
|
|
126
|
+
throw new Error("Invalid token payload");
|
|
127
|
+
}
|
|
128
|
+
return parsed;
|
|
129
|
+
} catch {
|
|
130
|
+
throw createError({
|
|
131
|
+
statusCode: 400,
|
|
132
|
+
statusMessage: "Invalid snapshot page token"
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
18
136
|
async function assertWorkspaceScopeAuthorized(event, workspaceId) {
|
|
19
137
|
if (!workspaceId.trim()) {
|
|
20
138
|
throw createError({
|
|
@@ -41,6 +159,126 @@ async function assertWorkspaceScopeAuthorized(event, workspaceId) {
|
|
|
41
159
|
});
|
|
42
160
|
}
|
|
43
161
|
}
|
|
162
|
+
function resolveSessionUserId(event) {
|
|
163
|
+
const context = event.context;
|
|
164
|
+
if (!context || typeof context !== "object") {
|
|
165
|
+
return void 0;
|
|
166
|
+
}
|
|
167
|
+
for (const [key, value] of Object.entries(context)) {
|
|
168
|
+
if (!key.startsWith(SESSION_CONTEXT_KEY_PREFIX)) continue;
|
|
169
|
+
const userId = value.user?.id;
|
|
170
|
+
if (typeof userId === "string" && userId.trim().length > 0) {
|
|
171
|
+
return userId;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return void 0;
|
|
175
|
+
}
|
|
176
|
+
function toWebhookEntityPayload(input) {
|
|
177
|
+
const base = input.op.payload && typeof input.op.payload === "object" ? { ...input.op.payload } : {};
|
|
178
|
+
if (typeof base.id !== "string" || base.id.length === 0) {
|
|
179
|
+
base.id = input.op.pk;
|
|
180
|
+
}
|
|
181
|
+
if (typeof base.workspace_id !== "string" || base.workspace_id.length === 0) {
|
|
182
|
+
base.workspace_id = input.workspaceId;
|
|
183
|
+
}
|
|
184
|
+
if (input.userId && (typeof base.user_id !== "string" || base.user_id.length === 0)) {
|
|
185
|
+
base.user_id = input.userId;
|
|
186
|
+
}
|
|
187
|
+
if (input.deleted) {
|
|
188
|
+
base.deleted = true;
|
|
189
|
+
}
|
|
190
|
+
if (typeof base.updated_at !== "number") {
|
|
191
|
+
base.updated_at = input.now;
|
|
192
|
+
}
|
|
193
|
+
return base;
|
|
194
|
+
}
|
|
195
|
+
function resolveHookEmission(input) {
|
|
196
|
+
if (!input.applied) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
const { op } = input;
|
|
200
|
+
if (op.tableName === "threads") {
|
|
201
|
+
if (op.operation === "delete") {
|
|
202
|
+
return {
|
|
203
|
+
hookName: "db.threads.delete:action:soft:after",
|
|
204
|
+
payload: toWebhookEntityPayload({
|
|
205
|
+
op,
|
|
206
|
+
workspaceId: input.workspaceId,
|
|
207
|
+
now: input.now,
|
|
208
|
+
userId: input.userId,
|
|
209
|
+
deleted: true
|
|
210
|
+
})
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
hookName: input.wasExisting ? "db.threads.update:action:after" : "db.threads.create:action:after",
|
|
215
|
+
payload: toWebhookEntityPayload({
|
|
216
|
+
op,
|
|
217
|
+
workspaceId: input.workspaceId,
|
|
218
|
+
now: input.now,
|
|
219
|
+
userId: input.userId
|
|
220
|
+
})
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
if (op.tableName === "messages") {
|
|
224
|
+
if (op.operation === "delete") {
|
|
225
|
+
return {
|
|
226
|
+
hookName: "db.messages.delete:action:soft:after",
|
|
227
|
+
payload: toWebhookEntityPayload({
|
|
228
|
+
op,
|
|
229
|
+
workspaceId: input.workspaceId,
|
|
230
|
+
now: input.now,
|
|
231
|
+
userId: input.userId,
|
|
232
|
+
deleted: true
|
|
233
|
+
})
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
hookName: input.wasExisting ? "db.messages.update:action:after" : "db.messages.create:action:after",
|
|
238
|
+
payload: toWebhookEntityPayload({
|
|
239
|
+
op,
|
|
240
|
+
workspaceId: input.workspaceId,
|
|
241
|
+
now: input.now,
|
|
242
|
+
userId: input.userId
|
|
243
|
+
})
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
if (op.tableName === "documents" || op.tableName === "posts") {
|
|
247
|
+
if (op.operation === "delete") {
|
|
248
|
+
return {
|
|
249
|
+
hookName: "db.documents.delete:action:soft:after",
|
|
250
|
+
payload: toWebhookEntityPayload({
|
|
251
|
+
op,
|
|
252
|
+
workspaceId: input.workspaceId,
|
|
253
|
+
now: input.now,
|
|
254
|
+
userId: input.userId,
|
|
255
|
+
deleted: true
|
|
256
|
+
})
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
hookName: input.wasExisting ? "db.documents.update:action:after" : "db.documents.create:action:after",
|
|
261
|
+
payload: toWebhookEntityPayload({
|
|
262
|
+
op,
|
|
263
|
+
workspaceId: input.workspaceId,
|
|
264
|
+
now: input.now,
|
|
265
|
+
userId: input.userId
|
|
266
|
+
})
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
if (op.tableName === "notifications" && op.operation === "put") {
|
|
270
|
+
return {
|
|
271
|
+
hookName: "notify:action:push",
|
|
272
|
+
payload: toWebhookEntityPayload({
|
|
273
|
+
op,
|
|
274
|
+
workspaceId: input.workspaceId,
|
|
275
|
+
now: input.now,
|
|
276
|
+
userId: input.userId
|
|
277
|
+
})
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
44
282
|
function incomingWinsLww(inClock, inHlc, existingClock, existingHlc) {
|
|
45
283
|
if (inClock > existingClock) return true;
|
|
46
284
|
if (inClock === existingClock && inHlc > existingHlc) return true;
|
|
@@ -48,9 +286,270 @@ function incomingWinsLww(inClock, inHlc, existingClock, existingHlc) {
|
|
|
48
286
|
}
|
|
49
287
|
export class SqliteSyncGatewayAdapter {
|
|
50
288
|
id = "sqlite";
|
|
289
|
+
capabilities = {
|
|
290
|
+
snapshotBootstrap: "snapshot-v1",
|
|
291
|
+
historyRetention: "snapshot-v1"
|
|
292
|
+
};
|
|
51
293
|
get db() {
|
|
52
294
|
return getSqliteDb();
|
|
53
295
|
}
|
|
296
|
+
async reserveUploadIntent(event, input) {
|
|
297
|
+
await assertWorkspaceScopeAuthorized(event, input.workspaceId);
|
|
298
|
+
if (!input.intentId || !Number.isSafeInteger(input.sizeBytes) || input.sizeBytes < 1 || !Number.isSafeInteger(input.expiresAt) || input.expiresAt <= nowEpoch()) {
|
|
299
|
+
throw createError({ statusCode: 400, statusMessage: "Invalid upload intent" });
|
|
300
|
+
}
|
|
301
|
+
if (input.workspaceQuotaBytes !== void 0 && (!Number.isSafeInteger(input.workspaceQuotaBytes) || input.workspaceQuotaBytes < 1)) {
|
|
302
|
+
throw createError({ statusCode: 400, statusMessage: "Invalid workspace quota" });
|
|
303
|
+
}
|
|
304
|
+
const hash = normalizeStorageHash(input.hash);
|
|
305
|
+
const raw = getRawDb();
|
|
306
|
+
raw.transaction(() => {
|
|
307
|
+
const now = nowEpoch();
|
|
308
|
+
raw.prepare(`UPDATE upload_intents SET status = 'expired'
|
|
309
|
+
WHERE workspace_id = ? AND status = 'active' AND expires_at <= ?`).run(input.workspaceId, now);
|
|
310
|
+
const liveRows = raw.prepare(`SELECT id, data_json FROM s_file_meta
|
|
311
|
+
WHERE workspace_id = ? AND deleted = 0`).all(input.workspaceId);
|
|
312
|
+
let usedBytes = 0;
|
|
313
|
+
let alreadyStored = false;
|
|
314
|
+
for (const row of liveRows) {
|
|
315
|
+
const payload = JSON.parse(row.data_json);
|
|
316
|
+
const size = payload.size_bytes ?? payload.sizeBytes;
|
|
317
|
+
if (typeof size !== "number" || !Number.isSafeInteger(size) || size < 0) {
|
|
318
|
+
throw createError({ statusCode: 500, statusMessage: "Invalid canonical file size" });
|
|
319
|
+
}
|
|
320
|
+
usedBytes += size;
|
|
321
|
+
if (normalizeStorageHash(row.id) === hash) alreadyStored = true;
|
|
322
|
+
}
|
|
323
|
+
const totals = raw.prepare(`SELECT
|
|
324
|
+
COALESCE(SUM(reserved_bytes), 0) AS reserved_bytes,
|
|
325
|
+
MAX(CASE WHEN hash = ? AND reserved_bytes > 0 THEN 1 ELSE 0 END) AS same_hash
|
|
326
|
+
FROM upload_intents WHERE workspace_id = ? AND status = 'active' AND expires_at > ?`).get(hash, input.workspaceId, now);
|
|
327
|
+
const reservedBytes = alreadyStored || totals.same_hash === 1 ? 0 : input.sizeBytes;
|
|
328
|
+
if (input.workspaceQuotaBytes !== void 0 && usedBytes + totals.reserved_bytes + reservedBytes > input.workspaceQuotaBytes) {
|
|
329
|
+
throw createError({ statusCode: 413, statusMessage: "Workspace storage quota exceeded" });
|
|
330
|
+
}
|
|
331
|
+
raw.prepare(`INSERT INTO upload_intents (
|
|
332
|
+
id, workspace_id, hash, mime_type, size_bytes, reserved_bytes,
|
|
333
|
+
expires_at, status, created_at
|
|
334
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?)`).run(
|
|
335
|
+
input.intentId,
|
|
336
|
+
input.workspaceId,
|
|
337
|
+
hash,
|
|
338
|
+
input.mimeType,
|
|
339
|
+
input.sizeBytes,
|
|
340
|
+
reservedBytes,
|
|
341
|
+
input.expiresAt,
|
|
342
|
+
now
|
|
343
|
+
);
|
|
344
|
+
})();
|
|
345
|
+
}
|
|
346
|
+
async consumeUploadIntent(event, input) {
|
|
347
|
+
await assertWorkspaceScopeAuthorized(event, input.workspaceId);
|
|
348
|
+
const raw = getRawDb();
|
|
349
|
+
const result = raw.prepare(`UPDATE upload_intents
|
|
350
|
+
SET status = 'consumed', storage_id = ?, consumed_at = ?
|
|
351
|
+
WHERE id = ? AND workspace_id = ? AND status = 'active' AND expires_at > ?
|
|
352
|
+
AND hash = ? AND mime_type = ? AND size_bytes = ?`).run(
|
|
353
|
+
input.storageId,
|
|
354
|
+
nowEpoch(),
|
|
355
|
+
input.intentId,
|
|
356
|
+
input.workspaceId,
|
|
357
|
+
nowEpoch(),
|
|
358
|
+
normalizeStorageHash(input.hash),
|
|
359
|
+
input.mimeType,
|
|
360
|
+
input.sizeBytes
|
|
361
|
+
);
|
|
362
|
+
if (result.changes !== 1) {
|
|
363
|
+
throw createError({ statusCode: 409, statusMessage: "Upload intent expired, mismatched, or already consumed" });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
async cancelUploadIntent(event, input) {
|
|
367
|
+
await assertWorkspaceScopeAuthorized(event, input.workspaceId);
|
|
368
|
+
const result = getRawDb().prepare(`UPDATE upload_intents
|
|
369
|
+
SET status = 'cancelled', cancelled_at = ?
|
|
370
|
+
WHERE id = ? AND workspace_id = ? AND status = 'active'`).run(nowEpoch(), input.intentId, input.workspaceId);
|
|
371
|
+
if (result.changes !== 1) {
|
|
372
|
+
throw createError({ statusCode: 409, statusMessage: "Upload intent is not active" });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
async queryCanonicalStorage(event, input) {
|
|
376
|
+
const workspaceId = input.scope.workspaceId;
|
|
377
|
+
await assertWorkspaceScopeAuthorized(event, workspaceId);
|
|
378
|
+
const limit = resolveCanonicalStorageLimit(input.limit);
|
|
379
|
+
const hash = input.hash === void 0 ? void 0 : normalizeStorageHash(input.hash);
|
|
380
|
+
if (input.hash !== void 0 && !hash) {
|
|
381
|
+
throw createError({ statusCode: 400, statusMessage: "Invalid storage hash" });
|
|
382
|
+
}
|
|
383
|
+
const after = decodeCanonicalStorageCursor(input.cursor, input.kind, hash);
|
|
384
|
+
const raw = getRawDb();
|
|
385
|
+
if (input.kind === "active_reservations") {
|
|
386
|
+
if (after.length > 1) {
|
|
387
|
+
throw createError({ statusCode: 400, statusMessage: "Invalid canonical storage cursor" });
|
|
388
|
+
}
|
|
389
|
+
const now = input.now ?? nowEpoch();
|
|
390
|
+
const rows2 = raw.prepare(`SELECT id, hash, reserved_bytes, expires_at
|
|
391
|
+
FROM upload_intents
|
|
392
|
+
WHERE workspace_id = ? AND status = 'active' AND expires_at > ?
|
|
393
|
+
AND id > ? AND reserved_bytes > 0 AND (? IS NULL OR hash = ?)
|
|
394
|
+
ORDER BY id ASC LIMIT ?`).all(workspaceId, now, after[0] ?? "", hash ?? null, hash ?? null, limit + 1);
|
|
395
|
+
const hasMore2 = rows2.length > limit;
|
|
396
|
+
const page2 = hasMore2 ? rows2.slice(0, limit) : rows2;
|
|
397
|
+
const last2 = page2[page2.length - 1];
|
|
398
|
+
return {
|
|
399
|
+
items: page2.map((row) => ({
|
|
400
|
+
kind: "reservation",
|
|
401
|
+
reservationId: row.id,
|
|
402
|
+
hash: row.hash,
|
|
403
|
+
sizeBytes: row.reserved_bytes,
|
|
404
|
+
expiresAt: row.expires_at
|
|
405
|
+
})),
|
|
406
|
+
hasMore: hasMore2,
|
|
407
|
+
...hasMore2 && last2 ? { nextCursor: encodeCanonicalStorageCursor({
|
|
408
|
+
version: 1,
|
|
409
|
+
kind: input.kind,
|
|
410
|
+
hash,
|
|
411
|
+
key: [last2.id]
|
|
412
|
+
}) } : {}
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
if (input.kind === "live_metadata") {
|
|
416
|
+
if (after.length > 1) {
|
|
417
|
+
throw createError({ statusCode: 400, statusMessage: "Invalid canonical storage cursor" });
|
|
418
|
+
}
|
|
419
|
+
const rows2 = raw.prepare(`
|
|
420
|
+
SELECT id, data_json, updated_at
|
|
421
|
+
FROM s_file_meta
|
|
422
|
+
WHERE workspace_id = ?
|
|
423
|
+
AND deleted = 0
|
|
424
|
+
AND id > ?
|
|
425
|
+
AND (? IS NULL OR lower(
|
|
426
|
+
CASE
|
|
427
|
+
WHEN instr(trim(id), ':') > 0
|
|
428
|
+
THEN substr(trim(id), instr(trim(id), ':') + 1)
|
|
429
|
+
ELSE trim(id)
|
|
430
|
+
END
|
|
431
|
+
) = ?)
|
|
432
|
+
ORDER BY id ASC
|
|
433
|
+
LIMIT ?
|
|
434
|
+
`).all(workspaceId, after[0] ?? "", hash ?? null, hash ?? null, limit + 1);
|
|
435
|
+
const hasMore2 = rows2.length > limit;
|
|
436
|
+
const page2 = hasMore2 ? rows2.slice(0, limit) : rows2;
|
|
437
|
+
const items = page2.map((row) => {
|
|
438
|
+
let payload;
|
|
439
|
+
try {
|
|
440
|
+
payload = JSON.parse(row.data_json);
|
|
441
|
+
} catch {
|
|
442
|
+
throw createError({ statusCode: 500, statusMessage: "Invalid canonical file metadata" });
|
|
443
|
+
}
|
|
444
|
+
const size = payload.size_bytes ?? payload.sizeBytes;
|
|
445
|
+
if (typeof size !== "number" || !Number.isSafeInteger(size) || size < 0) {
|
|
446
|
+
throw createError({ statusCode: 500, statusMessage: "Invalid canonical file size" });
|
|
447
|
+
}
|
|
448
|
+
const storageId = payload.storage_id ?? payload.storageId;
|
|
449
|
+
return {
|
|
450
|
+
kind: "metadata",
|
|
451
|
+
hash: normalizeStorageHash(row.id),
|
|
452
|
+
sizeBytes: size,
|
|
453
|
+
...typeof storageId === "string" && storageId ? { storageId } : {},
|
|
454
|
+
updatedAt: row.updated_at
|
|
455
|
+
};
|
|
456
|
+
});
|
|
457
|
+
const last2 = page2[page2.length - 1];
|
|
458
|
+
return {
|
|
459
|
+
items,
|
|
460
|
+
hasMore: hasMore2,
|
|
461
|
+
...hasMore2 && last2 ? {
|
|
462
|
+
nextCursor: encodeCanonicalStorageCursor({
|
|
463
|
+
version: 1,
|
|
464
|
+
kind: input.kind,
|
|
465
|
+
hash,
|
|
466
|
+
key: [last2.id]
|
|
467
|
+
})
|
|
468
|
+
} : {}
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
if (after.length !== 0 && after.length !== 3) {
|
|
472
|
+
throw createError({ statusCode: 400, statusMessage: "Invalid canonical storage cursor" });
|
|
473
|
+
}
|
|
474
|
+
const [afterTable = "", afterId = "", afterHash = ""] = after;
|
|
475
|
+
const rows = raw.prepare(`
|
|
476
|
+
WITH reference_edges(source_table, source_id, raw_hash) AS (
|
|
477
|
+
SELECT 'messages', row.id, CAST(edge.value AS TEXT)
|
|
478
|
+
FROM s_messages AS row
|
|
479
|
+
JOIN json_each(
|
|
480
|
+
CASE
|
|
481
|
+
WHEN json_valid(json_extract(row.data_json, '$.file_hashes'))
|
|
482
|
+
THEN json_extract(row.data_json, '$.file_hashes')
|
|
483
|
+
ELSE '[]'
|
|
484
|
+
END
|
|
485
|
+
) AS edge
|
|
486
|
+
WHERE row.workspace_id = ? AND row.deleted = 0 AND edge.type = 'text'
|
|
487
|
+
UNION ALL
|
|
488
|
+
SELECT 'posts', row.id, CAST(edge.value AS TEXT)
|
|
489
|
+
FROM s_posts AS row
|
|
490
|
+
JOIN json_each(
|
|
491
|
+
CASE
|
|
492
|
+
WHEN json_valid(json_extract(row.data_json, '$.file_hashes'))
|
|
493
|
+
THEN json_extract(row.data_json, '$.file_hashes')
|
|
494
|
+
ELSE '[]'
|
|
495
|
+
END
|
|
496
|
+
) AS edge
|
|
497
|
+
WHERE row.workspace_id = ? AND row.deleted = 0 AND edge.type = 'text'
|
|
498
|
+
), normalized_edges AS (
|
|
499
|
+
SELECT source_table, source_id, raw_hash, lower(
|
|
500
|
+
CASE
|
|
501
|
+
WHEN instr(trim(raw_hash), ':') > 0
|
|
502
|
+
THEN substr(trim(raw_hash), instr(trim(raw_hash), ':') + 1)
|
|
503
|
+
ELSE trim(raw_hash)
|
|
504
|
+
END
|
|
505
|
+
) AS normalized_hash
|
|
506
|
+
FROM reference_edges
|
|
507
|
+
)
|
|
508
|
+
SELECT source_table, source_id, raw_hash, normalized_hash
|
|
509
|
+
FROM normalized_edges
|
|
510
|
+
WHERE normalized_hash <> ''
|
|
511
|
+
AND (? IS NULL OR normalized_hash = ?)
|
|
512
|
+
AND (
|
|
513
|
+
source_table > ? OR
|
|
514
|
+
(source_table = ? AND source_id > ?) OR
|
|
515
|
+
(source_table = ? AND source_id = ? AND raw_hash > ?)
|
|
516
|
+
)
|
|
517
|
+
ORDER BY source_table ASC, source_id ASC, raw_hash ASC
|
|
518
|
+
LIMIT ?
|
|
519
|
+
`).all(
|
|
520
|
+
workspaceId,
|
|
521
|
+
workspaceId,
|
|
522
|
+
hash ?? null,
|
|
523
|
+
hash ?? null,
|
|
524
|
+
afterTable,
|
|
525
|
+
afterTable,
|
|
526
|
+
afterId,
|
|
527
|
+
afterTable,
|
|
528
|
+
afterId,
|
|
529
|
+
afterHash,
|
|
530
|
+
limit + 1
|
|
531
|
+
);
|
|
532
|
+
const hasMore = rows.length > limit;
|
|
533
|
+
const page = hasMore ? rows.slice(0, limit) : rows;
|
|
534
|
+
const last = page[page.length - 1];
|
|
535
|
+
return {
|
|
536
|
+
items: page.map((row) => ({
|
|
537
|
+
kind: "reference",
|
|
538
|
+
hash: row.normalized_hash,
|
|
539
|
+
sourceTable: row.source_table,
|
|
540
|
+
sourceId: row.source_id
|
|
541
|
+
})),
|
|
542
|
+
hasMore,
|
|
543
|
+
...hasMore && last ? {
|
|
544
|
+
nextCursor: encodeCanonicalStorageCursor({
|
|
545
|
+
version: 1,
|
|
546
|
+
kind: input.kind,
|
|
547
|
+
hash,
|
|
548
|
+
key: [last.source_table, last.source_id, last.raw_hash]
|
|
549
|
+
})
|
|
550
|
+
} : {}
|
|
551
|
+
};
|
|
552
|
+
}
|
|
54
553
|
async push(event, input) {
|
|
55
554
|
const { scope, ops } = input;
|
|
56
555
|
const workspaceId = scope.workspaceId;
|
|
@@ -58,25 +557,49 @@ export class SqliteSyncGatewayAdapter {
|
|
|
58
557
|
if (!ops.length) {
|
|
59
558
|
return { results: [], serverVersion: 0 };
|
|
60
559
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
560
|
+
const resultSlots = new Array(ops.length);
|
|
561
|
+
const groups = /* @__PURE__ */ new Map();
|
|
562
|
+
ops.forEach((op, index) => {
|
|
563
|
+
const fingerprint = operationFingerprint(op);
|
|
564
|
+
const group = groups.get(op.stamp.opId);
|
|
565
|
+
if (!group) {
|
|
566
|
+
groups.set(op.stamp.opId, {
|
|
567
|
+
op,
|
|
568
|
+
fingerprint,
|
|
569
|
+
indices: [index],
|
|
570
|
+
conflicting: false
|
|
571
|
+
});
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
group.indices.push(index);
|
|
575
|
+
if (group.fingerprint !== fingerprint) group.conflicting = true;
|
|
576
|
+
});
|
|
577
|
+
const uniqueOps = [];
|
|
578
|
+
const indicesByOpId = /* @__PURE__ */ new Map();
|
|
579
|
+
for (const group of groups.values()) {
|
|
580
|
+
const error = group.conflicting ? `Conflicting operations reuse op_id ${group.op.stamp.opId}` : validatePushOperation(workspaceId, group.op);
|
|
581
|
+
if (error) {
|
|
582
|
+
for (const index of group.indices) {
|
|
583
|
+
resultSlots[index] = {
|
|
584
|
+
opId: group.op.stamp.opId,
|
|
66
585
|
success: false,
|
|
67
|
-
error
|
|
68
|
-
errorCode: "VALIDATION_ERROR"
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
586
|
+
error,
|
|
587
|
+
errorCode: group.conflicting ? "CONFLICT" : "VALIDATION_ERROR"
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
continue;
|
|
72
591
|
}
|
|
592
|
+
uniqueOps.push(group.op);
|
|
593
|
+
indicesByOpId.set(group.op.stamp.opId, group.indices);
|
|
73
594
|
}
|
|
74
595
|
const raw = getRawDb();
|
|
75
596
|
const now = nowEpoch();
|
|
76
|
-
const
|
|
597
|
+
const userId = resolveSessionUserId(event);
|
|
598
|
+
const uniqueResults = [];
|
|
77
599
|
let finalServerVersion = 0;
|
|
600
|
+
const hookEmissions = [];
|
|
78
601
|
const runTx = raw.transaction(() => {
|
|
79
|
-
const opIds =
|
|
602
|
+
const opIds = uniqueOps.map((o) => o.stamp.opId);
|
|
80
603
|
const existingOps = /* @__PURE__ */ new Map();
|
|
81
604
|
const chunkSize = 500;
|
|
82
605
|
for (let i = 0; i < opIds.length; i += chunkSize) {
|
|
@@ -90,7 +613,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
90
613
|
}
|
|
91
614
|
}
|
|
92
615
|
const uniqueNewOpIds = /* @__PURE__ */ new Set();
|
|
93
|
-
for (const op of
|
|
616
|
+
for (const op of uniqueOps) {
|
|
94
617
|
if (existingOps.has(op.stamp.opId)) continue;
|
|
95
618
|
uniqueNewOpIds.add(op.stamp.opId);
|
|
96
619
|
}
|
|
@@ -114,18 +637,18 @@ export class SqliteSyncGatewayAdapter {
|
|
|
114
637
|
`);
|
|
115
638
|
const assignedVersions = /* @__PURE__ */ new Map();
|
|
116
639
|
let versionOffset = 0;
|
|
117
|
-
for (const op of
|
|
640
|
+
for (const op of uniqueOps) {
|
|
118
641
|
if (existingOps.has(op.stamp.opId)) continue;
|
|
119
642
|
if (assignedVersions.has(op.stamp.opId)) continue;
|
|
120
643
|
versionOffset++;
|
|
121
644
|
assignedVersions.set(op.stamp.opId, baseVersion + versionOffset);
|
|
122
645
|
}
|
|
123
646
|
const processedNew = /* @__PURE__ */ new Set();
|
|
124
|
-
for (const op of
|
|
647
|
+
for (const op of uniqueOps) {
|
|
125
648
|
const opId = op.stamp.opId;
|
|
126
649
|
const existingSv = existingOps.get(opId);
|
|
127
650
|
if (existingSv !== void 0) {
|
|
128
|
-
|
|
651
|
+
uniqueResults.push({
|
|
129
652
|
opId,
|
|
130
653
|
success: true,
|
|
131
654
|
serverVersion: existingSv
|
|
@@ -137,7 +660,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
137
660
|
throw new Error(`Missing server version allocation for op_id ${opId}`);
|
|
138
661
|
}
|
|
139
662
|
if (processedNew.has(opId)) {
|
|
140
|
-
|
|
663
|
+
uniqueResults.push({
|
|
141
664
|
opId,
|
|
142
665
|
success: true,
|
|
143
666
|
serverVersion
|
|
@@ -147,7 +670,7 @@ export class SqliteSyncGatewayAdapter {
|
|
|
147
670
|
processedNew.add(opId);
|
|
148
671
|
const materializedTable = SYNCED_TABLE_MAP[op.tableName];
|
|
149
672
|
if (!materializedTable) {
|
|
150
|
-
|
|
673
|
+
uniqueResults.push({
|
|
151
674
|
opId,
|
|
152
675
|
success: false,
|
|
153
676
|
error: `Unknown table: ${op.tableName}`,
|
|
@@ -175,10 +698,11 @@ export class SqliteSyncGatewayAdapter {
|
|
|
175
698
|
const existing = raw.prepare(
|
|
176
699
|
`SELECT clock, hlc FROM ${materializedTable} WHERE id = ? AND workspace_id = ?`
|
|
177
700
|
).get(pkValue, workspaceId);
|
|
701
|
+
let applied = false;
|
|
178
702
|
if (!existing) {
|
|
179
703
|
raw.prepare(
|
|
180
|
-
`INSERT INTO ${materializedTable} (id, workspace_id, data_json, clock, hlc, device_id, deleted, created_at, updated_at)
|
|
181
|
-
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)`
|
|
704
|
+
`INSERT INTO ${materializedTable} (id, workspace_id, data_json, clock, hlc, device_id, op_id, deleted, created_at, updated_at)
|
|
705
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
|
|
182
706
|
).run(
|
|
183
707
|
pkValue,
|
|
184
708
|
workspaceId,
|
|
@@ -186,9 +710,11 @@ export class SqliteSyncGatewayAdapter {
|
|
|
186
710
|
op.stamp.clock,
|
|
187
711
|
op.stamp.hlc,
|
|
188
712
|
op.stamp.deviceId,
|
|
713
|
+
opId,
|
|
189
714
|
now,
|
|
190
715
|
now
|
|
191
716
|
);
|
|
717
|
+
applied = true;
|
|
192
718
|
} else if (incomingWinsLww(
|
|
193
719
|
op.stamp.clock,
|
|
194
720
|
op.stamp.hlc,
|
|
@@ -197,35 +723,51 @@ export class SqliteSyncGatewayAdapter {
|
|
|
197
723
|
)) {
|
|
198
724
|
raw.prepare(
|
|
199
725
|
`UPDATE ${materializedTable}
|
|
200
|
-
SET data_json = ?, clock = ?, hlc = ?, device_id = ?, deleted = 0, updated_at = ?
|
|
726
|
+
SET data_json = ?, clock = ?, hlc = ?, device_id = ?, op_id = ?, deleted = 0, updated_at = ?
|
|
201
727
|
WHERE id = ? AND workspace_id = ?`
|
|
202
728
|
).run(
|
|
203
729
|
payloadJson ?? "{}",
|
|
204
730
|
op.stamp.clock,
|
|
205
731
|
op.stamp.hlc,
|
|
206
732
|
op.stamp.deviceId,
|
|
733
|
+
opId,
|
|
207
734
|
now,
|
|
208
735
|
pkValue,
|
|
209
736
|
workspaceId
|
|
210
737
|
);
|
|
738
|
+
applied = true;
|
|
739
|
+
}
|
|
740
|
+
const emission = resolveHookEmission({
|
|
741
|
+
op,
|
|
742
|
+
workspaceId,
|
|
743
|
+
now,
|
|
744
|
+
userId,
|
|
745
|
+
wasExisting: Boolean(existing),
|
|
746
|
+
applied
|
|
747
|
+
});
|
|
748
|
+
if (emission) {
|
|
749
|
+
hookEmissions.push(emission);
|
|
211
750
|
}
|
|
212
751
|
} else if (op.operation === "delete") {
|
|
213
752
|
const existing = raw.prepare(
|
|
214
753
|
`SELECT clock, hlc FROM ${materializedTable} WHERE id = ? AND workspace_id = ?`
|
|
215
754
|
).get(pkValue, workspaceId);
|
|
755
|
+
let applied = false;
|
|
216
756
|
if (!existing) {
|
|
217
757
|
raw.prepare(
|
|
218
|
-
`INSERT INTO ${materializedTable} (id, workspace_id, data_json, clock, hlc, device_id, deleted, created_at, updated_at)
|
|
219
|
-
VALUES (?, ?, '{}', ?, ?, ?, 1, ?, ?)`
|
|
758
|
+
`INSERT INTO ${materializedTable} (id, workspace_id, data_json, clock, hlc, device_id, op_id, deleted, created_at, updated_at)
|
|
759
|
+
VALUES (?, ?, '{}', ?, ?, ?, ?, 1, ?, ?)`
|
|
220
760
|
).run(
|
|
221
761
|
pkValue,
|
|
222
762
|
workspaceId,
|
|
223
763
|
op.stamp.clock,
|
|
224
764
|
op.stamp.hlc,
|
|
225
765
|
op.stamp.deviceId,
|
|
766
|
+
opId,
|
|
226
767
|
now,
|
|
227
768
|
now
|
|
228
769
|
);
|
|
770
|
+
applied = true;
|
|
229
771
|
} else if (incomingWinsLww(
|
|
230
772
|
op.stamp.clock,
|
|
231
773
|
op.stamp.hlc,
|
|
@@ -234,23 +776,27 @@ export class SqliteSyncGatewayAdapter {
|
|
|
234
776
|
)) {
|
|
235
777
|
raw.prepare(
|
|
236
778
|
`UPDATE ${materializedTable}
|
|
237
|
-
SET deleted = 1, clock = ?, hlc = ?, device_id = ?, updated_at = ?
|
|
779
|
+
SET deleted = 1, clock = ?, hlc = ?, device_id = ?, op_id = ?, updated_at = ?
|
|
238
780
|
WHERE id = ? AND workspace_id = ?`
|
|
239
781
|
).run(
|
|
240
782
|
op.stamp.clock,
|
|
241
783
|
op.stamp.hlc,
|
|
242
784
|
op.stamp.deviceId,
|
|
785
|
+
opId,
|
|
243
786
|
now,
|
|
244
787
|
pkValue,
|
|
245
788
|
workspaceId
|
|
246
789
|
);
|
|
790
|
+
applied = true;
|
|
247
791
|
}
|
|
248
792
|
raw.prepare(
|
|
249
|
-
`INSERT INTO tombstones (id, workspace_id, table_name, pk, deleted_at, clock, server_version, created_at)
|
|
250
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
793
|
+
`INSERT INTO tombstones (id, workspace_id, table_name, pk, deleted_at, clock, hlc, op_id, server_version, created_at)
|
|
794
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
251
795
|
ON CONFLICT(workspace_id, table_name, pk) DO UPDATE SET
|
|
252
796
|
deleted_at = excluded.deleted_at,
|
|
253
797
|
clock = excluded.clock,
|
|
798
|
+
hlc = excluded.hlc,
|
|
799
|
+
op_id = excluded.op_id,
|
|
254
800
|
server_version = excluded.server_version
|
|
255
801
|
WHERE excluded.clock > tombstones.clock
|
|
256
802
|
OR (
|
|
@@ -264,11 +810,24 @@ export class SqliteSyncGatewayAdapter {
|
|
|
264
810
|
pkValue,
|
|
265
811
|
now,
|
|
266
812
|
op.stamp.clock,
|
|
813
|
+
op.stamp.hlc,
|
|
814
|
+
opId,
|
|
267
815
|
serverVersion,
|
|
268
816
|
now
|
|
269
817
|
);
|
|
818
|
+
const emission = resolveHookEmission({
|
|
819
|
+
op,
|
|
820
|
+
workspaceId,
|
|
821
|
+
now,
|
|
822
|
+
userId,
|
|
823
|
+
wasExisting: Boolean(existing),
|
|
824
|
+
applied
|
|
825
|
+
});
|
|
826
|
+
if (emission) {
|
|
827
|
+
hookEmissions.push(emission);
|
|
828
|
+
}
|
|
270
829
|
}
|
|
271
|
-
|
|
830
|
+
uniqueResults.push({
|
|
272
831
|
opId,
|
|
273
832
|
success: true,
|
|
274
833
|
serverVersion
|
|
@@ -276,7 +835,20 @@ export class SqliteSyncGatewayAdapter {
|
|
|
276
835
|
}
|
|
277
836
|
});
|
|
278
837
|
runTx.immediate();
|
|
279
|
-
|
|
838
|
+
for (const result of uniqueResults) {
|
|
839
|
+
for (const index of indicesByOpId.get(result.opId) ?? []) {
|
|
840
|
+
resultSlots[index] = result;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
for (const emission of hookEmissions) {
|
|
844
|
+
await emitWebhookSystemHook(emission.hookName, emission.payload);
|
|
845
|
+
}
|
|
846
|
+
return {
|
|
847
|
+
results: resultSlots.filter(
|
|
848
|
+
(result) => Boolean(result)
|
|
849
|
+
),
|
|
850
|
+
serverVersion: finalServerVersion
|
|
851
|
+
};
|
|
280
852
|
}
|
|
281
853
|
async pull(event, input) {
|
|
282
854
|
const db = this.db;
|
|
@@ -307,58 +879,320 @@ export class SqliteSyncGatewayAdapter {
|
|
|
307
879
|
const nextCursor = lastRow ? lastRow.server_version : cursor;
|
|
308
880
|
return { changes, nextCursor, hasMore };
|
|
309
881
|
}
|
|
882
|
+
async snapshot(event, input) {
|
|
883
|
+
const workspaceId = input.scope.workspaceId;
|
|
884
|
+
await assertWorkspaceScopeAuthorized(event, workspaceId);
|
|
885
|
+
const pageSize = resolveSnapshotPageSize(input.pageSize);
|
|
886
|
+
const raw = getRawDb();
|
|
887
|
+
const now = nowEpoch();
|
|
888
|
+
let header;
|
|
889
|
+
let after;
|
|
890
|
+
if (input.pageToken) {
|
|
891
|
+
if (input.pageToken.length > 4096) {
|
|
892
|
+
throw createError({
|
|
893
|
+
statusCode: 400,
|
|
894
|
+
statusMessage: "Invalid snapshot page token"
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
const token = decodeSnapshotPageToken(input.pageToken);
|
|
898
|
+
const existing = raw.prepare(`
|
|
899
|
+
SELECT id, workspace_id, high_watermark, tables_json, expires_at
|
|
900
|
+
FROM sync_snapshots
|
|
901
|
+
WHERE id = ?
|
|
902
|
+
`).get(token.snapshotId);
|
|
903
|
+
if (!existing || existing.expires_at <= now) {
|
|
904
|
+
if (existing) {
|
|
905
|
+
raw.prepare("DELETE FROM sync_snapshots WHERE id = ?").run(existing.id);
|
|
906
|
+
}
|
|
907
|
+
throw createError({
|
|
908
|
+
statusCode: 410,
|
|
909
|
+
statusMessage: "Snapshot expired or unavailable"
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
if (existing.workspace_id !== workspaceId) {
|
|
913
|
+
throw createError({
|
|
914
|
+
statusCode: 403,
|
|
915
|
+
statusMessage: "Forbidden"
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
if (input.tables && existing.tables_json !== JSON.stringify(normalizeSnapshotTables(input.tables))) {
|
|
919
|
+
throw createError({
|
|
920
|
+
statusCode: 400,
|
|
921
|
+
statusMessage: "Snapshot table filter cannot change between pages"
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
header = existing;
|
|
925
|
+
after = token.after;
|
|
926
|
+
} else {
|
|
927
|
+
const tables = normalizeSnapshotTables(input.tables);
|
|
928
|
+
const snapshotId = uid();
|
|
929
|
+
const expiresAt = now + SNAPSHOT_TTL_SECONDS;
|
|
930
|
+
const capture = raw.transaction(() => {
|
|
931
|
+
raw.prepare("DELETE FROM sync_snapshots WHERE expires_at <= ?").run(now);
|
|
932
|
+
const counter = raw.prepare(
|
|
933
|
+
"SELECT value FROM server_version_counter WHERE workspace_id = ?"
|
|
934
|
+
).get(workspaceId);
|
|
935
|
+
const highWatermark = counter?.value ?? 0;
|
|
936
|
+
const tablesJson = JSON.stringify(tables);
|
|
937
|
+
raw.prepare(`
|
|
938
|
+
INSERT INTO sync_snapshots (
|
|
939
|
+
id, workspace_id, high_watermark, tables_json, created_at, expires_at
|
|
940
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
941
|
+
`).run(
|
|
942
|
+
snapshotId,
|
|
943
|
+
workspaceId,
|
|
944
|
+
highWatermark,
|
|
945
|
+
tablesJson,
|
|
946
|
+
now,
|
|
947
|
+
expiresAt
|
|
948
|
+
);
|
|
949
|
+
for (const tableName of tables) {
|
|
950
|
+
const materializedTable = SYNCED_TABLE_MAP[tableName];
|
|
951
|
+
if (!materializedTable) {
|
|
952
|
+
throw new Error(`Missing materialized table for ${tableName}`);
|
|
953
|
+
}
|
|
954
|
+
raw.prepare(`
|
|
955
|
+
INSERT INTO sync_snapshot_items (
|
|
956
|
+
snapshot_id, table_name, pk, kind, payload_json,
|
|
957
|
+
clock, hlc, op_id, server_deleted_at
|
|
958
|
+
)
|
|
959
|
+
SELECT
|
|
960
|
+
?, ?, id, 'row', data_json,
|
|
961
|
+
clock,
|
|
962
|
+
CASE WHEN hlc <> '' THEN hlc ELSE 'legacy:0' END,
|
|
963
|
+
CASE
|
|
964
|
+
WHEN op_id <> '' THEN op_id
|
|
965
|
+
ELSE 'legacy:${tableName}:' || id || ':' || clock || ':' || hlc
|
|
966
|
+
END,
|
|
967
|
+
NULL
|
|
968
|
+
FROM "${materializedTable}"
|
|
969
|
+
WHERE workspace_id = ? AND deleted = 0
|
|
970
|
+
`).run(snapshotId, tableName, workspaceId);
|
|
971
|
+
raw.prepare(`
|
|
972
|
+
INSERT INTO sync_snapshot_items (
|
|
973
|
+
snapshot_id, table_name, pk, kind, payload_json,
|
|
974
|
+
clock, hlc, op_id, server_deleted_at
|
|
975
|
+
)
|
|
976
|
+
SELECT
|
|
977
|
+
?, ?, materialized.id, 'tombstone', NULL,
|
|
978
|
+
materialized.clock,
|
|
979
|
+
CASE
|
|
980
|
+
WHEN materialized.hlc <> '' THEN materialized.hlc
|
|
981
|
+
ELSE 'legacy:0'
|
|
982
|
+
END,
|
|
983
|
+
CASE
|
|
984
|
+
WHEN materialized.op_id <> '' THEN materialized.op_id
|
|
985
|
+
ELSE 'legacy:tombstone:${tableName}:' || materialized.id || ':' || materialized.clock
|
|
986
|
+
END,
|
|
987
|
+
MAX(0, COALESCE(
|
|
988
|
+
(
|
|
989
|
+
SELECT tombstone.deleted_at
|
|
990
|
+
FROM tombstones AS tombstone
|
|
991
|
+
WHERE tombstone.workspace_id = materialized.workspace_id
|
|
992
|
+
AND tombstone.table_name = ?
|
|
993
|
+
AND tombstone.pk = materialized.id
|
|
994
|
+
LIMIT 1
|
|
995
|
+
),
|
|
996
|
+
materialized.updated_at,
|
|
997
|
+
materialized.created_at,
|
|
998
|
+
0
|
|
999
|
+
))
|
|
1000
|
+
FROM "${materializedTable}" AS materialized
|
|
1001
|
+
WHERE materialized.workspace_id = ? AND materialized.deleted <> 0
|
|
1002
|
+
`).run(snapshotId, tableName, tableName, workspaceId);
|
|
1003
|
+
raw.prepare(`
|
|
1004
|
+
INSERT INTO sync_snapshot_items (
|
|
1005
|
+
snapshot_id, table_name, pk, kind, payload_json,
|
|
1006
|
+
clock, hlc, op_id, server_deleted_at
|
|
1007
|
+
)
|
|
1008
|
+
SELECT
|
|
1009
|
+
?, ?, tombstone.pk, 'tombstone', NULL,
|
|
1010
|
+
tombstone.clock,
|
|
1011
|
+
CASE WHEN tombstone.hlc <> '' THEN tombstone.hlc ELSE 'legacy:0' END,
|
|
1012
|
+
CASE
|
|
1013
|
+
WHEN tombstone.op_id <> '' THEN tombstone.op_id
|
|
1014
|
+
ELSE 'legacy:tombstone:${tableName}:' || tombstone.pk || ':' || tombstone.clock
|
|
1015
|
+
END,
|
|
1016
|
+
MAX(0, tombstone.deleted_at)
|
|
1017
|
+
FROM tombstones AS tombstone
|
|
1018
|
+
WHERE tombstone.workspace_id = ?
|
|
1019
|
+
AND tombstone.table_name = ?
|
|
1020
|
+
AND NOT EXISTS (
|
|
1021
|
+
SELECT 1
|
|
1022
|
+
FROM "${materializedTable}" AS materialized
|
|
1023
|
+
WHERE materialized.workspace_id = tombstone.workspace_id
|
|
1024
|
+
AND materialized.id = tombstone.pk
|
|
1025
|
+
)
|
|
1026
|
+
`).run(snapshotId, tableName, workspaceId, tableName);
|
|
1027
|
+
}
|
|
1028
|
+
return {
|
|
1029
|
+
id: snapshotId,
|
|
1030
|
+
workspace_id: workspaceId,
|
|
1031
|
+
high_watermark: highWatermark,
|
|
1032
|
+
tables_json: tablesJson,
|
|
1033
|
+
expires_at: expiresAt
|
|
1034
|
+
};
|
|
1035
|
+
});
|
|
1036
|
+
header = capture.immediate();
|
|
1037
|
+
}
|
|
1038
|
+
const rows = after ? raw.prepare(`
|
|
1039
|
+
SELECT
|
|
1040
|
+
table_name, pk, kind, payload_json, clock, hlc, op_id,
|
|
1041
|
+
server_deleted_at
|
|
1042
|
+
FROM sync_snapshot_items
|
|
1043
|
+
WHERE snapshot_id = ?
|
|
1044
|
+
AND (
|
|
1045
|
+
table_name > ?
|
|
1046
|
+
OR (table_name = ? AND pk > ?)
|
|
1047
|
+
OR (table_name = ? AND pk = ? AND kind > ?)
|
|
1048
|
+
)
|
|
1049
|
+
ORDER BY table_name ASC, pk ASC, kind ASC
|
|
1050
|
+
LIMIT ?
|
|
1051
|
+
`).all(
|
|
1052
|
+
header.id,
|
|
1053
|
+
after.tableName,
|
|
1054
|
+
after.tableName,
|
|
1055
|
+
after.pk,
|
|
1056
|
+
after.tableName,
|
|
1057
|
+
after.pk,
|
|
1058
|
+
after.kind,
|
|
1059
|
+
pageSize + 1
|
|
1060
|
+
) : raw.prepare(`
|
|
1061
|
+
SELECT
|
|
1062
|
+
table_name, pk, kind, payload_json, clock, hlc, op_id,
|
|
1063
|
+
server_deleted_at
|
|
1064
|
+
FROM sync_snapshot_items
|
|
1065
|
+
WHERE snapshot_id = ?
|
|
1066
|
+
ORDER BY table_name ASC, pk ASC, kind ASC
|
|
1067
|
+
LIMIT ?
|
|
1068
|
+
`).all(header.id, pageSize + 1);
|
|
1069
|
+
const hasMore = rows.length > pageSize;
|
|
1070
|
+
const pageRows = hasMore ? rows.slice(0, pageSize) : rows;
|
|
1071
|
+
const items = pageRows.map((row) => {
|
|
1072
|
+
const revision = {
|
|
1073
|
+
clock: row.clock,
|
|
1074
|
+
hlc: row.hlc,
|
|
1075
|
+
opId: row.op_id
|
|
1076
|
+
};
|
|
1077
|
+
if (row.kind === "row") {
|
|
1078
|
+
return {
|
|
1079
|
+
kind: "row",
|
|
1080
|
+
tableName: row.table_name,
|
|
1081
|
+
pk: row.pk,
|
|
1082
|
+
payload: JSON.parse(row.payload_json ?? "null"),
|
|
1083
|
+
revision
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
return {
|
|
1087
|
+
kind: "tombstone",
|
|
1088
|
+
tableName: row.table_name,
|
|
1089
|
+
pk: row.pk,
|
|
1090
|
+
revision,
|
|
1091
|
+
serverDeletedAt: row.server_deleted_at ?? 0
|
|
1092
|
+
};
|
|
1093
|
+
});
|
|
1094
|
+
const lastRow = pageRows.at(-1);
|
|
1095
|
+
const nextPageToken = hasMore && lastRow ? encodeSnapshotPageToken({
|
|
1096
|
+
version: 1,
|
|
1097
|
+
snapshotId: header.id,
|
|
1098
|
+
after: {
|
|
1099
|
+
tableName: lastRow.table_name,
|
|
1100
|
+
pk: lastRow.pk,
|
|
1101
|
+
kind: lastRow.kind
|
|
1102
|
+
}
|
|
1103
|
+
}) : null;
|
|
1104
|
+
return {
|
|
1105
|
+
workspaceId: header.workspace_id,
|
|
1106
|
+
snapshotId: header.id,
|
|
1107
|
+
highWatermark: header.high_watermark,
|
|
1108
|
+
items,
|
|
1109
|
+
nextPageToken
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
310
1112
|
async updateCursor(event, input) {
|
|
311
1113
|
await assertWorkspaceScopeAuthorized(event, input.scope.workspaceId);
|
|
312
1114
|
const raw = getRawDb();
|
|
313
1115
|
const now = nowEpoch();
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
1116
|
+
const deviceId = input.deviceId.trim();
|
|
1117
|
+
if (!deviceId || deviceId.length > 256) {
|
|
1118
|
+
throw createError({ statusCode: 400, statusMessage: "Invalid device cursor" });
|
|
1119
|
+
}
|
|
1120
|
+
if (!Number.isSafeInteger(input.version) || input.version < 0) {
|
|
1121
|
+
throw createError({ statusCode: 400, statusMessage: "Invalid device cursor" });
|
|
1122
|
+
}
|
|
1123
|
+
const ownerUserId = resolveSessionUserId(event);
|
|
1124
|
+
if (!ownerUserId) {
|
|
1125
|
+
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
|
1126
|
+
}
|
|
1127
|
+
const update = raw.transaction(() => {
|
|
1128
|
+
const counter = raw.prepare(
|
|
1129
|
+
"SELECT value FROM server_version_counter WHERE workspace_id = ?"
|
|
1130
|
+
).get(input.scope.workspaceId);
|
|
1131
|
+
const maximum = counter?.value ?? 0;
|
|
1132
|
+
if (input.version > maximum) {
|
|
1133
|
+
throw createError({ statusCode: 400, statusMessage: "Cursor exceeds workspace version" });
|
|
1134
|
+
}
|
|
1135
|
+
const existing = raw.prepare(
|
|
1136
|
+
`SELECT owner_user_id, last_seen_version
|
|
1137
|
+
FROM device_cursors WHERE workspace_id = ? AND device_id = ?`
|
|
1138
|
+
).get(input.scope.workspaceId, deviceId);
|
|
1139
|
+
if (existing?.owner_user_id && existing.owner_user_id !== ownerUserId) {
|
|
1140
|
+
throw createError({ statusCode: 403, statusMessage: "Device cursor belongs to another user" });
|
|
1141
|
+
}
|
|
1142
|
+
if (existing && input.version < existing.last_seen_version) {
|
|
1143
|
+
throw createError({ statusCode: 409, statusMessage: "Device cursor cannot regress" });
|
|
1144
|
+
}
|
|
1145
|
+
raw.prepare(
|
|
1146
|
+
`INSERT INTO device_cursors (
|
|
1147
|
+
id, workspace_id, device_id, owner_user_id, last_seen_version, updated_at
|
|
1148
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
1149
|
+
ON CONFLICT(workspace_id, device_id) DO UPDATE SET
|
|
1150
|
+
owner_user_id = COALESCE(device_cursors.owner_user_id, excluded.owner_user_id),
|
|
1151
|
+
last_seen_version = excluded.last_seen_version,
|
|
1152
|
+
updated_at = excluded.updated_at`
|
|
1153
|
+
).run(uid(), input.scope.workspaceId, deviceId, ownerUserId, input.version, now);
|
|
1154
|
+
});
|
|
1155
|
+
update();
|
|
321
1156
|
}
|
|
322
1157
|
async gcTombstones(event, input) {
|
|
323
1158
|
await assertWorkspaceScopeAuthorized(event, input.scope.workspaceId);
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
).get(workspaceId);
|
|
329
|
-
const minCursor = minCursorRow?.min_version ?? 0;
|
|
1159
|
+
if (!Number.isSafeInteger(input.retentionSeconds) || input.retentionSeconds < MIN_SYNC_RETENTION_SECONDS || input.retentionSeconds > MAX_SYNC_RETENTION_SECONDS) {
|
|
1160
|
+
throw createError({ statusCode: 400, statusMessage: "Invalid sync retention window" });
|
|
1161
|
+
}
|
|
1162
|
+
if (!canRunSyncHistoryGc()) return;
|
|
330
1163
|
const cutoff = nowEpoch() - input.retentionSeconds;
|
|
331
|
-
raw
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
1164
|
+
const raw = getRawDb();
|
|
1165
|
+
raw.transaction(() => {
|
|
1166
|
+
const cursor = raw.prepare(
|
|
1167
|
+
`SELECT MIN(last_seen_version) AS min_version
|
|
1168
|
+
FROM device_cursors WHERE workspace_id = ?`
|
|
1169
|
+
).get(input.scope.workspaceId);
|
|
1170
|
+
if (cursor.min_version === null) return;
|
|
1171
|
+
raw.prepare(
|
|
1172
|
+
`DELETE FROM tombstones
|
|
1173
|
+
WHERE workspace_id = ? AND created_at < ? AND server_version <= ?`
|
|
1174
|
+
).run(input.scope.workspaceId, cutoff, cursor.min_version);
|
|
1175
|
+
})();
|
|
337
1176
|
}
|
|
338
1177
|
async gcChangeLog(event, input) {
|
|
339
1178
|
await assertWorkspaceScopeAuthorized(event, input.scope.workspaceId);
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
).get(workspaceId);
|
|
345
|
-
const minCursor = minCursorRow?.min_version ?? 0;
|
|
1179
|
+
if (!Number.isSafeInteger(input.retentionSeconds) || input.retentionSeconds < MIN_SYNC_RETENTION_SECONDS || input.retentionSeconds > MAX_SYNC_RETENTION_SECONDS) {
|
|
1180
|
+
throw createError({ statusCode: 400, statusMessage: "Invalid sync retention window" });
|
|
1181
|
+
}
|
|
1182
|
+
if (!canRunSyncHistoryGc()) return;
|
|
346
1183
|
const cutoff = nowEpoch() - input.retentionSeconds;
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
1184
|
+
const raw = getRawDb();
|
|
1185
|
+
raw.transaction(() => {
|
|
1186
|
+
const cursor = raw.prepare(
|
|
1187
|
+
`SELECT MIN(last_seen_version) AS min_version
|
|
1188
|
+
FROM device_cursors WHERE workspace_id = ?`
|
|
1189
|
+
).get(input.scope.workspaceId);
|
|
1190
|
+
if (cursor.min_version === null) return;
|
|
1191
|
+
raw.prepare(
|
|
351
1192
|
`DELETE FROM change_log
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
AND server_version < ?
|
|
356
|
-
AND created_at < ?
|
|
357
|
-
LIMIT ?
|
|
358
|
-
)`
|
|
359
|
-
).run(workspaceId, minCursor, cutoff, BATCH_SIZE);
|
|
360
|
-
deleted = result.changes;
|
|
361
|
-
} while (deleted >= BATCH_SIZE);
|
|
1193
|
+
WHERE workspace_id = ? AND created_at < ? AND server_version <= ?`
|
|
1194
|
+
).run(input.scope.workspaceId, cutoff, cursor.min_version);
|
|
1195
|
+
})();
|
|
362
1196
|
}
|
|
363
1197
|
}
|
|
364
1198
|
export function createSqliteSyncGatewayAdapter() {
|