or3-provider-sqlite 0.0.1

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.
Files changed (32) hide show
  1. package/README.md +109 -0
  2. package/dist/module.d.mts +5 -0
  3. package/dist/module.json +9 -0
  4. package/dist/module.mjs +11 -0
  5. package/dist/runtime/server/admin/adapters/sync-sqlite.d.ts +2 -0
  6. package/dist/runtime/server/admin/adapters/sync-sqlite.js +72 -0
  7. package/dist/runtime/server/admin/stores/sqlite-store.d.ts +4 -0
  8. package/dist/runtime/server/admin/stores/sqlite-store.js +336 -0
  9. package/dist/runtime/server/auth/sqlite-auth-workspace-store.d.ts +111 -0
  10. package/dist/runtime/server/auth/sqlite-auth-workspace-store.js +349 -0
  11. package/dist/runtime/server/db/kysely.d.ts +32 -0
  12. package/dist/runtime/server/db/kysely.js +62 -0
  13. package/dist/runtime/server/db/migrate.d.ts +10 -0
  14. package/dist/runtime/server/db/migrate.js +38 -0
  15. package/dist/runtime/server/db/migrations/001_init.d.ts +6 -0
  16. package/dist/runtime/server/db/migrations/001_init.js +31 -0
  17. package/dist/runtime/server/db/migrations/002_sync_tables.d.ts +6 -0
  18. package/dist/runtime/server/db/migrations/002_sync_tables.js +55 -0
  19. package/dist/runtime/server/db/migrations/003_sync_hardening.d.ts +9 -0
  20. package/dist/runtime/server/db/migrations/003_sync_hardening.js +67 -0
  21. package/dist/runtime/server/db/migrations/004_auth_invites.d.ts +3 -0
  22. package/dist/runtime/server/db/migrations/004_auth_invites.js +18 -0
  23. package/dist/runtime/server/db/migrations/005_admin_stores.d.ts +7 -0
  24. package/dist/runtime/server/db/migrations/005_admin_stores.js +12 -0
  25. package/dist/runtime/server/db/schema.d.ts +138 -0
  26. package/dist/runtime/server/db/schema.js +10 -0
  27. package/dist/runtime/server/plugins/register.d.ts +2 -0
  28. package/dist/runtime/server/plugins/register.js +48 -0
  29. package/dist/runtime/server/sync/sqlite-sync-gateway-adapter.d.ts +36 -0
  30. package/dist/runtime/server/sync/sqlite-sync-gateway-adapter.js +366 -0
  31. package/dist/types.d.mts +7 -0
  32. package/package.json +54 -0
@@ -0,0 +1,366 @@
1
+ import { createError } from "h3";
2
+ import { randomUUID } from "node:crypto";
3
+ import { getSqliteDb, getRawDb } from "../db/kysely.js";
4
+ import { SYNCED_TABLE_MAP, ALLOWED_SYNC_TABLES } from "../db/schema.js";
5
+ const DEFAULT_PULL_LIMIT = 100;
6
+ const MAX_PULL_LIMIT = 1e3;
7
+ const SESSION_CONTEXT_KEY_PREFIX = "__or3_session_context_";
8
+ function uid() {
9
+ return randomUUID();
10
+ }
11
+ function nowEpoch() {
12
+ return Math.floor(Date.now() / 1e3);
13
+ }
14
+ function resolvePullLimit(limit) {
15
+ if (typeof limit !== "number" || !Number.isFinite(limit)) return DEFAULT_PULL_LIMIT;
16
+ return Math.max(1, Math.min(Math.floor(limit), MAX_PULL_LIMIT));
17
+ }
18
+ async function assertWorkspaceScopeAuthorized(event, workspaceId) {
19
+ if (!workspaceId.trim()) {
20
+ throw createError({
21
+ statusCode: 400,
22
+ statusMessage: "workspaceId is required"
23
+ });
24
+ }
25
+ const context = event.context;
26
+ let activeWorkspaceId = null;
27
+ if (context && typeof context === "object") {
28
+ for (const [key, value] of Object.entries(context)) {
29
+ if (!key.startsWith(SESSION_CONTEXT_KEY_PREFIX)) continue;
30
+ const workspaceIdCandidate = value.workspace?.id;
31
+ if (typeof workspaceIdCandidate === "string" && workspaceIdCandidate.trim().length > 0) {
32
+ activeWorkspaceId = workspaceIdCandidate;
33
+ break;
34
+ }
35
+ }
36
+ }
37
+ if (activeWorkspaceId && activeWorkspaceId !== workspaceId) {
38
+ throw createError({
39
+ statusCode: 403,
40
+ statusMessage: "Forbidden"
41
+ });
42
+ }
43
+ }
44
+ function incomingWinsLww(inClock, inHlc, existingClock, existingHlc) {
45
+ if (inClock > existingClock) return true;
46
+ if (inClock === existingClock && inHlc > existingHlc) return true;
47
+ return false;
48
+ }
49
+ export class SqliteSyncGatewayAdapter {
50
+ id = "sqlite";
51
+ get db() {
52
+ return getSqliteDb();
53
+ }
54
+ async push(event, input) {
55
+ const { scope, ops } = input;
56
+ const workspaceId = scope.workspaceId;
57
+ await assertWorkspaceScopeAuthorized(event, workspaceId);
58
+ if (!ops.length) {
59
+ return { results: [], serverVersion: 0 };
60
+ }
61
+ for (const op of ops) {
62
+ if (!ALLOWED_SYNC_TABLES.includes(op.tableName)) {
63
+ return {
64
+ results: ops.map((o) => ({
65
+ opId: o.stamp.opId,
66
+ success: false,
67
+ error: `Invalid table: ${o.tableName}`,
68
+ errorCode: "VALIDATION_ERROR"
69
+ })),
70
+ serverVersion: 0
71
+ };
72
+ }
73
+ }
74
+ const raw = getRawDb();
75
+ const now = nowEpoch();
76
+ const results = [];
77
+ let finalServerVersion = 0;
78
+ const runTx = raw.transaction(() => {
79
+ const opIds = ops.map((o) => o.stamp.opId);
80
+ const existingOps = /* @__PURE__ */ new Map();
81
+ const chunkSize = 500;
82
+ for (let i = 0; i < opIds.length; i += chunkSize) {
83
+ const chunk = opIds.slice(i, i + chunkSize);
84
+ const placeholders = chunk.map(() => "?").join(",");
85
+ const rows = raw.prepare(
86
+ `SELECT op_id, server_version FROM change_log WHERE op_id IN (${placeholders})`
87
+ ).all(...chunk);
88
+ for (const row of rows) {
89
+ existingOps.set(row.op_id, row.server_version);
90
+ }
91
+ }
92
+ const uniqueNewOpIds = /* @__PURE__ */ new Set();
93
+ for (const op of ops) {
94
+ if (existingOps.has(op.stamp.opId)) continue;
95
+ uniqueNewOpIds.add(op.stamp.opId);
96
+ }
97
+ let baseVersion;
98
+ const counterRow = raw.prepare("SELECT value FROM server_version_counter WHERE workspace_id = ?").get(workspaceId);
99
+ if (counterRow) {
100
+ baseVersion = counterRow.value;
101
+ raw.prepare(
102
+ "UPDATE server_version_counter SET value = ? WHERE workspace_id = ?"
103
+ ).run(baseVersion + uniqueNewOpIds.size, workspaceId);
104
+ } else {
105
+ baseVersion = 0;
106
+ raw.prepare(
107
+ "INSERT INTO server_version_counter (workspace_id, value) VALUES (?, ?)"
108
+ ).run(workspaceId, uniqueNewOpIds.size);
109
+ }
110
+ finalServerVersion = baseVersion + uniqueNewOpIds.size;
111
+ const insertChangeLog = raw.prepare(`
112
+ INSERT INTO change_log (id, workspace_id, server_version, table_name, pk, op, payload_json, clock, hlc, device_id, op_id, created_at)
113
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
114
+ `);
115
+ const assignedVersions = /* @__PURE__ */ new Map();
116
+ let versionOffset = 0;
117
+ for (const op of ops) {
118
+ if (existingOps.has(op.stamp.opId)) continue;
119
+ if (assignedVersions.has(op.stamp.opId)) continue;
120
+ versionOffset++;
121
+ assignedVersions.set(op.stamp.opId, baseVersion + versionOffset);
122
+ }
123
+ const processedNew = /* @__PURE__ */ new Set();
124
+ for (const op of ops) {
125
+ const opId = op.stamp.opId;
126
+ const existingSv = existingOps.get(opId);
127
+ if (existingSv !== void 0) {
128
+ results.push({
129
+ opId,
130
+ success: true,
131
+ serverVersion: existingSv
132
+ });
133
+ continue;
134
+ }
135
+ const serverVersion = assignedVersions.get(opId);
136
+ if (serverVersion === void 0) {
137
+ throw new Error(`Missing server version allocation for op_id ${opId}`);
138
+ }
139
+ if (processedNew.has(opId)) {
140
+ results.push({
141
+ opId,
142
+ success: true,
143
+ serverVersion
144
+ });
145
+ continue;
146
+ }
147
+ processedNew.add(opId);
148
+ const materializedTable = SYNCED_TABLE_MAP[op.tableName];
149
+ if (!materializedTable) {
150
+ results.push({
151
+ opId,
152
+ success: false,
153
+ error: `Unknown table: ${op.tableName}`,
154
+ errorCode: "VALIDATION_ERROR"
155
+ });
156
+ continue;
157
+ }
158
+ const pkValue = op.pk;
159
+ const payloadJson = op.payload != null ? JSON.stringify(op.payload) : null;
160
+ insertChangeLog.run(
161
+ uid(),
162
+ workspaceId,
163
+ serverVersion,
164
+ op.tableName,
165
+ pkValue,
166
+ op.operation,
167
+ payloadJson,
168
+ op.stamp.clock,
169
+ op.stamp.hlc,
170
+ op.stamp.deviceId,
171
+ opId,
172
+ now
173
+ );
174
+ if (op.operation === "put") {
175
+ const existing = raw.prepare(
176
+ `SELECT clock, hlc FROM ${materializedTable} WHERE id = ? AND workspace_id = ?`
177
+ ).get(pkValue, workspaceId);
178
+ if (!existing) {
179
+ raw.prepare(
180
+ `INSERT INTO ${materializedTable} (id, workspace_id, data_json, clock, hlc, device_id, deleted, created_at, updated_at)
181
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)`
182
+ ).run(
183
+ pkValue,
184
+ workspaceId,
185
+ payloadJson ?? "{}",
186
+ op.stamp.clock,
187
+ op.stamp.hlc,
188
+ op.stamp.deviceId,
189
+ now,
190
+ now
191
+ );
192
+ } else if (incomingWinsLww(
193
+ op.stamp.clock,
194
+ op.stamp.hlc,
195
+ existing.clock,
196
+ existing.hlc
197
+ )) {
198
+ raw.prepare(
199
+ `UPDATE ${materializedTable}
200
+ SET data_json = ?, clock = ?, hlc = ?, device_id = ?, deleted = 0, updated_at = ?
201
+ WHERE id = ? AND workspace_id = ?`
202
+ ).run(
203
+ payloadJson ?? "{}",
204
+ op.stamp.clock,
205
+ op.stamp.hlc,
206
+ op.stamp.deviceId,
207
+ now,
208
+ pkValue,
209
+ workspaceId
210
+ );
211
+ }
212
+ } else if (op.operation === "delete") {
213
+ const existing = raw.prepare(
214
+ `SELECT clock, hlc FROM ${materializedTable} WHERE id = ? AND workspace_id = ?`
215
+ ).get(pkValue, workspaceId);
216
+ if (!existing) {
217
+ raw.prepare(
218
+ `INSERT INTO ${materializedTable} (id, workspace_id, data_json, clock, hlc, device_id, deleted, created_at, updated_at)
219
+ VALUES (?, ?, '{}', ?, ?, ?, 1, ?, ?)`
220
+ ).run(
221
+ pkValue,
222
+ workspaceId,
223
+ op.stamp.clock,
224
+ op.stamp.hlc,
225
+ op.stamp.deviceId,
226
+ now,
227
+ now
228
+ );
229
+ } else if (incomingWinsLww(
230
+ op.stamp.clock,
231
+ op.stamp.hlc,
232
+ existing.clock,
233
+ existing.hlc
234
+ )) {
235
+ raw.prepare(
236
+ `UPDATE ${materializedTable}
237
+ SET deleted = 1, clock = ?, hlc = ?, device_id = ?, updated_at = ?
238
+ WHERE id = ? AND workspace_id = ?`
239
+ ).run(
240
+ op.stamp.clock,
241
+ op.stamp.hlc,
242
+ op.stamp.deviceId,
243
+ now,
244
+ pkValue,
245
+ workspaceId
246
+ );
247
+ }
248
+ raw.prepare(
249
+ `INSERT INTO tombstones (id, workspace_id, table_name, pk, deleted_at, clock, server_version, created_at)
250
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
251
+ ON CONFLICT(workspace_id, table_name, pk) DO UPDATE SET
252
+ deleted_at = excluded.deleted_at,
253
+ clock = excluded.clock,
254
+ server_version = excluded.server_version
255
+ WHERE excluded.clock > tombstones.clock
256
+ OR (
257
+ excluded.clock = tombstones.clock
258
+ AND excluded.server_version > tombstones.server_version
259
+ )`
260
+ ).run(
261
+ uid(),
262
+ workspaceId,
263
+ op.tableName,
264
+ pkValue,
265
+ now,
266
+ op.stamp.clock,
267
+ serverVersion,
268
+ now
269
+ );
270
+ }
271
+ results.push({
272
+ opId,
273
+ success: true,
274
+ serverVersion
275
+ });
276
+ }
277
+ });
278
+ runTx.immediate();
279
+ return { results, serverVersion: finalServerVersion };
280
+ }
281
+ async pull(event, input) {
282
+ const db = this.db;
283
+ const { scope, cursor, limit, tables } = input;
284
+ await assertWorkspaceScopeAuthorized(event, scope.workspaceId);
285
+ const fetchLimit = resolvePullLimit(limit);
286
+ let query = db.selectFrom("change_log").selectAll().where("workspace_id", "=", scope.workspaceId).where("server_version", ">", cursor).orderBy("server_version", "asc").limit(fetchLimit + 1);
287
+ if (tables?.length) {
288
+ query = query.where("table_name", "in", tables);
289
+ }
290
+ const rows = await query.execute();
291
+ const hasMore = rows.length > fetchLimit;
292
+ const resultRows = hasMore ? rows.slice(0, fetchLimit) : rows;
293
+ const changes = resultRows.map((row) => ({
294
+ serverVersion: row.server_version,
295
+ tableName: row.table_name,
296
+ pk: row.pk,
297
+ op: row.op,
298
+ payload: row.payload_json ? JSON.parse(row.payload_json) : void 0,
299
+ stamp: {
300
+ clock: row.clock,
301
+ hlc: row.hlc,
302
+ deviceId: row.device_id,
303
+ opId: row.op_id
304
+ }
305
+ }));
306
+ const lastRow = resultRows[resultRows.length - 1];
307
+ const nextCursor = lastRow ? lastRow.server_version : cursor;
308
+ return { changes, nextCursor, hasMore };
309
+ }
310
+ async updateCursor(event, input) {
311
+ await assertWorkspaceScopeAuthorized(event, input.scope.workspaceId);
312
+ const raw = getRawDb();
313
+ const now = nowEpoch();
314
+ raw.prepare(
315
+ `INSERT INTO device_cursors (id, workspace_id, device_id, last_seen_version, updated_at)
316
+ VALUES (?, ?, ?, ?, ?)
317
+ ON CONFLICT(workspace_id, device_id) DO UPDATE SET
318
+ last_seen_version = MAX(device_cursors.last_seen_version, excluded.last_seen_version),
319
+ updated_at = excluded.updated_at`
320
+ ).run(uid(), input.scope.workspaceId, input.deviceId, input.version, now);
321
+ }
322
+ async gcTombstones(event, input) {
323
+ await assertWorkspaceScopeAuthorized(event, input.scope.workspaceId);
324
+ const raw = getRawDb();
325
+ const workspaceId = input.scope.workspaceId;
326
+ const minCursorRow = raw.prepare(
327
+ "SELECT MIN(last_seen_version) as min_version FROM device_cursors WHERE workspace_id = ?"
328
+ ).get(workspaceId);
329
+ const minCursor = minCursorRow?.min_version ?? 0;
330
+ const cutoff = nowEpoch() - input.retentionSeconds;
331
+ raw.prepare(
332
+ `DELETE FROM tombstones
333
+ WHERE workspace_id = ?
334
+ AND server_version < ?
335
+ AND created_at < ?`
336
+ ).run(workspaceId, minCursor, cutoff);
337
+ }
338
+ async gcChangeLog(event, input) {
339
+ await assertWorkspaceScopeAuthorized(event, input.scope.workspaceId);
340
+ const raw = getRawDb();
341
+ const workspaceId = input.scope.workspaceId;
342
+ const minCursorRow = raw.prepare(
343
+ "SELECT MIN(last_seen_version) as min_version FROM device_cursors WHERE workspace_id = ?"
344
+ ).get(workspaceId);
345
+ const minCursor = minCursorRow?.min_version ?? 0;
346
+ const cutoff = nowEpoch() - input.retentionSeconds;
347
+ const BATCH_SIZE = 1e3;
348
+ let deleted;
349
+ do {
350
+ const result = raw.prepare(
351
+ `DELETE FROM change_log
352
+ WHERE rowid IN (
353
+ SELECT rowid FROM change_log
354
+ WHERE workspace_id = ?
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);
362
+ }
363
+ }
364
+ export function createSqliteSyncGatewayAdapter() {
365
+ return new SqliteSyncGatewayAdapter();
366
+ }
@@ -0,0 +1,7 @@
1
+ import type { NuxtModule } from '@nuxt/schema'
2
+
3
+ import type { default as Module } from './module.mjs'
4
+
5
+ export type ModuleOptions = typeof Module extends NuxtModule<infer O> ? Partial<O> : Record<string, any>
6
+
7
+ export { default } from './module.mjs'
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "or3-provider-sqlite",
3
+ "version": "0.0.1",
4
+ "description": "SQLite sync and workspace store provider for OR3 Chat — lightweight self-hosted backend via Kysely.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/types.d.mts",
11
+ "import": "./dist/module.mjs"
12
+ },
13
+ "./nuxt": {
14
+ "types": "./dist/types.d.mts",
15
+ "import": "./dist/module.mjs"
16
+ }
17
+ },
18
+ "main": "./dist/module.mjs",
19
+ "types": "./dist/types.d.mts",
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "scripts": {
24
+ "prepack": "bun run build",
25
+ "build": "nuxt-module-build build",
26
+ "lint": "eslint src --max-warnings 0",
27
+ "type-check": "tsc --noEmit",
28
+ "test": "vitest run"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "dependencies": {
34
+ "better-sqlite3": "^12.6.2",
35
+ "kysely": "^0.28.11"
36
+ },
37
+ "peerDependencies": {
38
+ "@nuxt/kit": "^3.0.0 || ^4.0.0",
39
+ "nuxt": "^3.0.0 || ^4.0.0"
40
+ },
41
+ "devDependencies": {
42
+ "@eslint/js": "^9.34.0",
43
+ "@nuxt/module-builder": "^1.0.2",
44
+ "@types/better-sqlite3": "^7.6.13",
45
+ "@types/node": "^25.2.2",
46
+ "@typescript-eslint/eslint-plugin": "^8.41.0",
47
+ "@typescript-eslint/parser": "^8.41.0",
48
+ "eslint": "^9.34.0",
49
+ "h3": "^1.15.4",
50
+ "pathe": "^2.0.3",
51
+ "typescript": "^5.9.3",
52
+ "vitest": "^4.0.18"
53
+ }
54
+ }