@haverstack/record-adapter-do-sqlite 0.1.0

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/dist/index.js ADDED
@@ -0,0 +1,1174 @@
1
+ import { StackVersionConflictError, StackNotFoundError, StackConflictError, applyMergePatch, StackQueryError } from '@haverstack/core';
2
+
3
+ // ../sqlite-shared/dist/schema.js
4
+ var RECORD_SCHEMA_SQL = `
5
+ CREATE TABLE IF NOT EXISTS records (
6
+ id TEXT PRIMARY KEY,
7
+ type_id TEXT NOT NULL,
8
+ created_at INTEGER NOT NULL,
9
+ updated_at INTEGER NOT NULL,
10
+ content TEXT NOT NULL CHECK (json_valid(content)),
11
+ version INTEGER NOT NULL DEFAULT 1,
12
+ parent_id TEXT,
13
+ entity_id TEXT,
14
+ app_id TEXT,
15
+ principal_id TEXT,
16
+ updated_by TEXT,
17
+ updated_via TEXT,
18
+ deleted_at INTEGER,
19
+ unlisted_at INTEGER,
20
+ permissions TEXT CHECK (permissions IS NULL OR json_valid(permissions))
21
+ ) STRICT;
22
+
23
+ -- A relationship's target is (related_scope, related_id) plus one
24
+ -- qualifier: related_stack for a record in another stack, related_ns for
25
+ -- a foreign namespace. All four are in the primary key, so two targets
26
+ -- differing only by namespace are two associations.
27
+ CREATE TABLE IF NOT EXISTS associations (
28
+ record_id TEXT NOT NULL REFERENCES records(id),
29
+ kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship')),
30
+ label TEXT NOT NULL,
31
+ file_id TEXT NOT NULL DEFAULT '',
32
+ related_scope TEXT NOT NULL DEFAULT '' CHECK (related_scope IN ('', 'record', 'entity', 'external')),
33
+ related_id TEXT NOT NULL DEFAULT '',
34
+ related_ns TEXT NOT NULL DEFAULT '',
35
+ related_stack TEXT NOT NULL DEFAULT '',
36
+ PRIMARY KEY (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack)
37
+ ) STRICT;
38
+
39
+ CREATE TABLE IF NOT EXISTS versions (
40
+ record_id TEXT NOT NULL REFERENCES records(id),
41
+ version INTEGER NOT NULL,
42
+ type_id TEXT NOT NULL,
43
+ content TEXT NOT NULL CHECK (json_valid(content)),
44
+ updated_at INTEGER NOT NULL,
45
+ entity_id TEXT,
46
+ updated_by TEXT,
47
+ updated_via TEXT,
48
+ associations TEXT CHECK (associations IS NULL OR json_valid(associations)),
49
+ permissions TEXT CHECK (permissions IS NULL OR json_valid(permissions)),
50
+ PRIMARY KEY (record_id, version)
51
+ ) STRICT;
52
+
53
+ CREATE TABLE IF NOT EXISTS types (
54
+ id TEXT PRIMARY KEY,
55
+ base_id TEXT NOT NULL,
56
+ version INTEGER NOT NULL,
57
+ name TEXT NOT NULL,
58
+ schema TEXT NOT NULL CHECK (json_valid(schema)),
59
+ schema_hash TEXT NOT NULL,
60
+ migrates_from TEXT,
61
+ created_at INTEGER NOT NULL
62
+ ) STRICT;
63
+
64
+ -- One row per top-level file-ref content field on a record, kept in sync
65
+ -- on every content/typeId write. Lets the attachmentFileId query filter
66
+ -- and deleteAttachment()'s reference check see content-held file
67
+ -- references, not just attachment associations.
68
+ CREATE TABLE IF NOT EXISTS file_refs (
69
+ record_id TEXT NOT NULL REFERENCES records(id),
70
+ field TEXT NOT NULL,
71
+ file_id TEXT NOT NULL,
72
+ PRIMARY KEY (record_id, field)
73
+ ) STRICT;
74
+
75
+ -- Indexes
76
+ CREATE INDEX IF NOT EXISTS idx_records_type_id ON records(type_id);
77
+ CREATE INDEX IF NOT EXISTS idx_records_parent_id ON records(parent_id);
78
+ CREATE INDEX IF NOT EXISTS idx_records_entity_id ON records(entity_id);
79
+ CREATE INDEX IF NOT EXISTS idx_records_app_id ON records(app_id);
80
+ CREATE INDEX IF NOT EXISTS idx_records_principal_id ON records(principal_id);
81
+ CREATE INDEX IF NOT EXISTS idx_records_deleted_at ON records(deleted_at);
82
+ CREATE INDEX IF NOT EXISTS idx_records_unlisted_at ON records(unlisted_at);
83
+ CREATE INDEX IF NOT EXISTS idx_records_created_at ON records(created_at);
84
+ CREATE INDEX IF NOT EXISTS idx_records_updated_at ON records(updated_at);
85
+ CREATE INDEX IF NOT EXISTS idx_assoc_record_id ON associations(record_id);
86
+ CREATE INDEX IF NOT EXISTS idx_assoc_kind_label ON associations(kind, label);
87
+ CREATE INDEX IF NOT EXISTS idx_assoc_kind_file_id ON associations(kind, file_id);
88
+ CREATE INDEX IF NOT EXISTS idx_assoc_related ON associations(related_scope, related_ns, related_id);
89
+ CREATE INDEX IF NOT EXISTS idx_types_base_id ON types(base_id);
90
+ CREATE INDEX IF NOT EXISTS idx_file_refs_file_id ON file_refs(file_id);
91
+ `;
92
+ var FTS5_SCHEMA_SQL = `
93
+ CREATE VIRTUAL TABLE IF NOT EXISTS records_fts USING fts5(
94
+ content,
95
+ content='records',
96
+ content_rowid='rowid'
97
+ );
98
+ `;
99
+ var PRAGMA_FOREIGN_KEYS_ON = `PRAGMA foreign_keys = ON;`;
100
+ var SORT_FIELDS = ["createdAt", "updatedAt", "version"];
101
+ var encodeCursor = (field, value, id) => btoa(`${field}|${value}|${id}`);
102
+ var decodeCursor = (cursor) => {
103
+ let decoded;
104
+ try {
105
+ decoded = atob(cursor);
106
+ } catch {
107
+ throw new StackQueryError(`Invalid cursor: malformed "${cursor}"`);
108
+ }
109
+ const parts = decoded.split("|");
110
+ if (parts.length !== 3) {
111
+ throw new StackQueryError(`Invalid cursor: malformed "${cursor}"`);
112
+ }
113
+ const [field, value, id] = parts;
114
+ if (!SORT_FIELDS.includes(field)) {
115
+ throw new StackQueryError(`Invalid cursor: unknown sort field "${field}"`);
116
+ }
117
+ const numericValue = Number(value);
118
+ if (!isFinite(numericValue)) {
119
+ throw new StackQueryError(`Invalid cursor: non-numeric sort value`);
120
+ }
121
+ return { field, value: numericValue, id };
122
+ };
123
+ var getSortField = (query) => query.sort?.field ?? "createdAt";
124
+ var getSortColumn = (field) => field === "createdAt" ? "created_at" : field === "updatedAt" ? "updated_at" : "version";
125
+ var makeCursor = (record, field) => {
126
+ const value = field === "updatedAt" ? record.updatedAt.getTime() : field === "version" ? record.version : record.createdAt.getTime();
127
+ return encodeCursor(field, value, record.id);
128
+ };
129
+
130
+ // ../sqlite-shared/dist/fts5.js
131
+ var sanitizeFts5Query = (query, maxDepth = 2) => {
132
+ if (!query)
133
+ return "";
134
+ let clean = query.replace(/\*/g, "");
135
+ clean = clean.replace(/\bNEAR\s*\(\s*([^,)]*)(?:,[^)]*)?\)/gi, "$1");
136
+ clean = clean.replace(/:/g, " ");
137
+ clean = clean.replace(/(?:^|\(\s*)NOT\s+/gi, (m) => m.replace(/NOT\s+/i, ""));
138
+ let currentDepth = 0;
139
+ let result = "";
140
+ for (const char of clean) {
141
+ if (char === "(") {
142
+ if (currentDepth < maxDepth) {
143
+ currentDepth++;
144
+ result += char;
145
+ } else
146
+ result += " ";
147
+ } else if (char === ")") {
148
+ if (currentDepth > 0) {
149
+ currentDepth--;
150
+ result += char;
151
+ } else
152
+ result += " ";
153
+ } else {
154
+ result += char;
155
+ }
156
+ }
157
+ if (currentDepth > 0)
158
+ result += ")".repeat(currentDepth);
159
+ let prev;
160
+ do {
161
+ prev = result;
162
+ result = result.replace(/\(\s*\)/g, " ");
163
+ } while (result !== prev);
164
+ return result.replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim();
165
+ };
166
+ var fts5Strategy = {
167
+ /** Index a record that has no FTS entry yet (used right after an INSERT). */
168
+ insert(exec, recordId, content) {
169
+ exec.run(`INSERT INTO records_fts(rowid, content) SELECT rowid, ? FROM records WHERE id = ?`, [
170
+ content,
171
+ recordId
172
+ ]);
173
+ },
174
+ /**
175
+ * Remove a record's FTS entry. MUST be called before the records row's
176
+ * content changes or the row is deleted — see the note above.
177
+ */
178
+ remove(exec, recordId) {
179
+ const row = exec.get("SELECT rowid, content FROM records WHERE id = ?", [recordId]);
180
+ if (!row)
181
+ return;
182
+ exec.run(`INSERT INTO records_fts(records_fts, rowid, content) VALUES('delete', ?, ?)`, [
183
+ row.rowid,
184
+ row.content
185
+ ]);
186
+ }
187
+ };
188
+
189
+ // ../sqlite-shared/dist/query.js
190
+ var sqlDirection = (query) => {
191
+ const dir = query.sort?.direction ?? "desc";
192
+ if (dir !== "asc" && dir !== "desc") {
193
+ throw new StackQueryError(`Invalid sort direction "${dir}": expected "asc" or "desc".`);
194
+ }
195
+ return dir === "asc" ? "ASC" : "DESC";
196
+ };
197
+ var buildWhereClause = (query) => {
198
+ const conditions = ["r.id != '_config'"];
199
+ const params = [];
200
+ const f = query.filter ?? {};
201
+ if (!f.includeDeleted) {
202
+ conditions.push("r.deleted_at IS NULL");
203
+ }
204
+ if (!f.includeUnlisted) {
205
+ conditions.push("r.unlisted_at IS NULL");
206
+ }
207
+ if (f.typeId !== void 0) {
208
+ const ids = Array.isArray(f.typeId) ? f.typeId : [f.typeId];
209
+ conditions.push(`r.type_id IN (${ids.map(() => "?").join(",")})`);
210
+ params.push(...ids);
211
+ }
212
+ if (f.parentId !== void 0) {
213
+ if (f.parentId === null) {
214
+ conditions.push("r.parent_id IS NULL");
215
+ } else {
216
+ conditions.push("r.parent_id = ?");
217
+ params.push(f.parentId);
218
+ }
219
+ }
220
+ if (f.appId !== void 0) {
221
+ const ids = Array.isArray(f.appId) ? f.appId : [f.appId];
222
+ conditions.push(`r.app_id IN (${ids.map(() => "?").join(",")})`);
223
+ params.push(...ids);
224
+ }
225
+ if (f.entityId !== void 0) {
226
+ const ids = Array.isArray(f.entityId) ? f.entityId : [f.entityId];
227
+ conditions.push(`r.entity_id IN (${ids.map(() => "?").join(",")})`);
228
+ params.push(...ids);
229
+ }
230
+ if (f.principalId !== void 0) {
231
+ const ids = Array.isArray(f.principalId) ? f.principalId : [f.principalId];
232
+ conditions.push(`r.principal_id IN (${ids.map(() => "?").join(",")})`);
233
+ params.push(...ids);
234
+ }
235
+ if (f.createdAt?.after) {
236
+ conditions.push("r.created_at > ?");
237
+ params.push(f.createdAt.after.getTime());
238
+ }
239
+ if (f.createdAt?.before) {
240
+ conditions.push("r.created_at < ?");
241
+ params.push(f.createdAt.before.getTime());
242
+ }
243
+ if (f.updatedAt?.after) {
244
+ conditions.push("r.updated_at > ?");
245
+ params.push(f.updatedAt.after.getTime());
246
+ }
247
+ if (f.updatedAt?.before) {
248
+ conditions.push("r.updated_at < ?");
249
+ params.push(f.updatedAt.before.getTime());
250
+ }
251
+ if (f.tags?.length) {
252
+ for (const tag of f.tags) {
253
+ conditions.push(`r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'tag' AND a.label = ?)`);
254
+ params.push(tag);
255
+ }
256
+ }
257
+ if (f.hasAttachment) {
258
+ conditions.push(`r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'attachment' AND a.label = ?)`);
259
+ params.push(f.hasAttachment);
260
+ }
261
+ if (f.attachmentFileId) {
262
+ conditions.push(`(r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'attachment' AND a.file_id = ?)
263
+ OR r.id IN (SELECT fr.record_id FROM file_refs fr WHERE fr.file_id = ?))`);
264
+ params.push(f.attachmentFileId, f.attachmentFileId);
265
+ }
266
+ if (f.relatedTo) {
267
+ const clauses = [];
268
+ const target = f.relatedTo.target;
269
+ if (f.relatedTo.label !== void 0) {
270
+ clauses.push("a.label = ?");
271
+ params.push(f.relatedTo.label);
272
+ }
273
+ if (target) {
274
+ clauses.push("a.related_scope = ?");
275
+ params.push(target.scope);
276
+ if (target.scope === "record") {
277
+ clauses.push("a.related_id = ?", "a.related_stack = ?");
278
+ params.push(target.recordId, target.stackUrl ?? "");
279
+ } else if (target.scope === "entity") {
280
+ clauses.push("a.related_id = ?");
281
+ params.push(target.entityId);
282
+ } else {
283
+ clauses.push("a.related_ns = ?");
284
+ params.push(target.ns);
285
+ if (target.id !== void 0) {
286
+ clauses.push("a.related_id = ?");
287
+ params.push(target.id);
288
+ }
289
+ }
290
+ }
291
+ conditions.push(`r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'relationship'` + clauses.map((c) => ` AND ${c}`).join("") + `)`);
292
+ }
293
+ if (f.content) {
294
+ for (const [key, value] of Object.entries(f.content)) {
295
+ if (value === null) {
296
+ conditions.push(`json_extract(r.content, ?) IS NULL`);
297
+ params.push(`$.${key}`);
298
+ } else {
299
+ conditions.push(`json_extract(r.content, ?) = ?`);
300
+ params.push(`$.${key}`, value);
301
+ }
302
+ }
303
+ }
304
+ if (f.search) {
305
+ const sanitized = sanitizeFts5Query(f.search);
306
+ if (sanitized) {
307
+ conditions.push(`r.rowid IN (SELECT rowid FROM records_fts WHERE records_fts MATCH ?)`);
308
+ params.push(sanitized);
309
+ } else {
310
+ conditions.push("0");
311
+ }
312
+ }
313
+ if (query.cursor) {
314
+ const { field: cursorField, value: numericValue, id: cursorId } = decodeCursor(query.cursor);
315
+ const sortField = getSortField(query);
316
+ if (cursorField !== sortField) {
317
+ throw new StackQueryError(`Cursor sort field "${cursorField}" does not match query sort field "${sortField}"`);
318
+ }
319
+ const col = getSortColumn(cursorField);
320
+ const op = sqlDirection(query) === "ASC" ? ">" : "<";
321
+ conditions.push(`(r.${col} ${op} ? OR (r.${col} = ? AND r.id ${op} ?))`);
322
+ params.push(numericValue, numericValue, cursorId);
323
+ }
324
+ return {
325
+ sql: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "",
326
+ params
327
+ };
328
+ };
329
+ var buildOrderClause = (query) => {
330
+ const field = getSortField(query);
331
+ const dir = sqlDirection(query);
332
+ return `ORDER BY r.${getSortColumn(field)} ${dir}, r.id ${dir}`;
333
+ };
334
+
335
+ // ../sqlite-shared/dist/mappers.js
336
+ var toMs = (d) => d.getTime();
337
+ var fromMs = (ms) => new Date(ms);
338
+ var rowToRecord = (row, associations) => {
339
+ const record = {
340
+ id: row.id,
341
+ typeId: row.type_id,
342
+ createdAt: fromMs(row.created_at),
343
+ updatedAt: fromMs(row.updated_at),
344
+ content: JSON.parse(row.content),
345
+ version: row.version
346
+ };
347
+ if (row.parent_id)
348
+ record.parentId = row.parent_id;
349
+ if (row.entity_id)
350
+ record.entityId = row.entity_id;
351
+ if (row.app_id)
352
+ record.appId = row.app_id;
353
+ if (row.principal_id)
354
+ record.principalId = row.principal_id;
355
+ if (row.updated_by)
356
+ record.updatedBy = row.updated_by;
357
+ if (row.updated_via)
358
+ record.updatedVia = row.updated_via;
359
+ if (row.deleted_at)
360
+ record.deletedAt = fromMs(row.deleted_at);
361
+ if (row.unlisted_at)
362
+ record.unlistedAt = fromMs(row.unlisted_at);
363
+ if (row.permissions)
364
+ record.permissions = JSON.parse(row.permissions);
365
+ if (associations.length)
366
+ record.associations = associations;
367
+ return record;
368
+ };
369
+ var rowToAssociation = (row) => {
370
+ if (row.kind === "tag") {
371
+ return { kind: "tag", label: row.label };
372
+ }
373
+ if (row.kind === "attachment") {
374
+ return {
375
+ kind: "attachment",
376
+ label: row.label,
377
+ fileId: row.file_id
378
+ };
379
+ }
380
+ return {
381
+ kind: "relationship",
382
+ label: row.label,
383
+ target: rowToTarget(row)
384
+ };
385
+ };
386
+ var associationKeyColumns = (a) => {
387
+ if (a.kind === "attachment")
388
+ return [a.fileId, "", "", "", ""];
389
+ if (a.kind !== "relationship")
390
+ return ["", "", "", "", ""];
391
+ const t = a.target;
392
+ if (t.scope === "entity")
393
+ return ["", "entity", t.entityId, "", ""];
394
+ if (t.scope === "external")
395
+ return ["", "external", t.id, t.ns, ""];
396
+ return ["", "record", t.recordId, "", t.stackUrl ?? ""];
397
+ };
398
+ var rowToTarget = (row) => {
399
+ const id = row.related_id;
400
+ if (row.related_scope === "entity")
401
+ return { scope: "entity", entityId: id };
402
+ if (row.related_scope === "external") {
403
+ return { scope: "external", ns: row.related_ns, id };
404
+ }
405
+ const stackUrl = row.related_stack;
406
+ return { scope: "record", recordId: id, ...stackUrl && { stackUrl } };
407
+ };
408
+ var rowToType = (row) => {
409
+ const t = {
410
+ id: row.id,
411
+ baseId: row.base_id,
412
+ version: row.version,
413
+ name: row.name,
414
+ schema: JSON.parse(row.schema),
415
+ schemaHash: row.schema_hash,
416
+ createdAt: fromMs(row.created_at)
417
+ };
418
+ if (row.migrates_from)
419
+ t.migratesFrom = row.migrates_from;
420
+ return t;
421
+ };
422
+ var rowToVersion = (row) => {
423
+ const v = {
424
+ version: row.version,
425
+ typeId: row.type_id,
426
+ content: JSON.parse(row.content),
427
+ updatedAt: fromMs(row.updated_at)
428
+ };
429
+ if (row.entity_id)
430
+ v.entityId = row.entity_id;
431
+ if (row.updated_by)
432
+ v.updatedBy = row.updated_by;
433
+ if (row.updated_via)
434
+ v.updatedVia = row.updated_via;
435
+ if (row.associations)
436
+ v.associations = JSON.parse(row.associations);
437
+ if (row.permissions)
438
+ v.permissions = JSON.parse(row.permissions);
439
+ return v;
440
+ };
441
+
442
+ // ../sqlite-shared/dist/executor.js
443
+ var isForeignKeyViolation = (err) => err instanceof Error && err.message.includes("FOREIGN KEY constraint failed");
444
+ var isUniqueConstraintViolation = (err) => err instanceof Error && err.message.includes("UNIQUE constraint failed");
445
+
446
+ // ../sqlite-shared/dist/config.js
447
+ var insertConfigRecord = (exec, entityId, timezone) => {
448
+ const now = Date.now();
449
+ exec.run(`INSERT INTO records (id, type_id, created_at, updated_at, content, version)
450
+ VALUES ('_config', '_config@1', ?, ?, ?, 1)`, [now, now, JSON.stringify({ entityId, timezone })]);
451
+ };
452
+ var readStackConfig = (exec) => {
453
+ const row = exec.get(`SELECT content FROM records WHERE id = '_config'`);
454
+ if (!row)
455
+ throw new Error("Stack database is missing its config record.");
456
+ const content = JSON.parse(row.content);
457
+ return { entityId: content.entityId, timezone: content.timezone };
458
+ };
459
+ var fileRefFieldNames = (schema) => Object.keys(schema).filter((field) => schema[field].kind === "file-ref");
460
+ var SharedSqlRecordLogic = class {
461
+ deps;
462
+ /**
463
+ * Per-typeId cache of file-ref field names, so syncFileRefs() doesn't
464
+ * hit the `types` table and re-parse schema JSON on every write.
465
+ * Populated eagerly in saveType() and lazily via getFileRefFields().
466
+ */
467
+ fileRefFieldsByType = /* @__PURE__ */ new Map();
468
+ constructor(deps) {
469
+ this.deps = deps;
470
+ }
471
+ get exec() {
472
+ return this.deps.exec;
473
+ }
474
+ /**
475
+ * SQL fragment (plus its bind params) that gates a records-table
476
+ * UPDATE/DELETE on the opt-in `expectedVersion` precondition. Empty when
477
+ * expectedVersion is omitted, keeping today's unconditional behavior.
478
+ */
479
+ versionGuard(expectedVersion) {
480
+ return expectedVersion === void 0 ? { clause: "", params: [] } : { clause: " AND version = ?", params: [expectedVersion] };
481
+ }
482
+ /**
483
+ * Precondition check for mutations that can't fold expectedVersion into
484
+ * their primary UPDATE's WHERE clause: patchContent/restoreVersion need
485
+ * fts.remove() to run *before* the records-table content changes, so
486
+ * the check happens first, standalone.
487
+ */
488
+ checkExpectedVersion(record, expectedVersion) {
489
+ if (expectedVersion === void 0 || record.version === expectedVersion)
490
+ return;
491
+ throw new StackVersionConflictError(`Record "${record.id}" is at version ${record.version}, expected ${expectedVersion}`, record.id, expectedVersion, record.version);
492
+ }
493
+ /**
494
+ * Called after a versionGuard()-gated statement affected zero rows.
495
+ * Distinguishes "record doesn't exist" (StackNotFoundError) from "it
496
+ * exists but isn't at expectedVersion" (StackVersionConflictError, with
497
+ * the actual current version for the caller to act on).
498
+ */
499
+ throwVersionConflict(id, expectedVersion) {
500
+ const row = this.exec.get("SELECT version FROM records WHERE id = ?", [
501
+ id
502
+ ]);
503
+ if (!row)
504
+ throw new StackNotFoundError(`Record not found: "${id}"`);
505
+ throw new StackVersionConflictError(`Record "${id}" is at version ${row.version}, expected ${expectedVersion}`, id, expectedVersion, row.version);
506
+ }
507
+ // -------------------------------------------------------
508
+ // Records
509
+ // -------------------------------------------------------
510
+ /**
511
+ * A duplicate id hits the `records` PK — mapped to StackConflictError
512
+ * instead of surfacing the raw engine exception, mirroring saveVersion's
513
+ * collision mapping below.
514
+ */
515
+ async createRecord(record) {
516
+ try {
517
+ this.exec.run(`INSERT INTO records
518
+ (id, type_id, created_at, updated_at, content, version,
519
+ parent_id, entity_id, app_id, principal_id, updated_by, updated_via,
520
+ deleted_at, unlisted_at, permissions)
521
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
522
+ record.id,
523
+ record.typeId,
524
+ toMs(record.createdAt),
525
+ toMs(record.updatedAt),
526
+ JSON.stringify(record.content),
527
+ record.version,
528
+ record.parentId ?? null,
529
+ record.entityId ?? null,
530
+ record.appId ?? null,
531
+ record.principalId ?? null,
532
+ record.updatedBy ?? null,
533
+ record.updatedVia ?? null,
534
+ record.deletedAt ? toMs(record.deletedAt) : null,
535
+ record.unlistedAt ? toMs(record.unlistedAt) : null,
536
+ record.permissions ? JSON.stringify(record.permissions) : null
537
+ ]);
538
+ } catch (err) {
539
+ if (isUniqueConstraintViolation(err)) {
540
+ throw new StackConflictError(`Record already exists: "${record.id}"`);
541
+ }
542
+ throw err;
543
+ }
544
+ if (record.associations?.length) {
545
+ this.insertAssociations(record.id, record.associations);
546
+ }
547
+ fts5Strategy.insert(this.exec, record.id, JSON.stringify(record.content));
548
+ this.syncFileRefs(record.id, record.typeId, record.content);
549
+ return record;
550
+ }
551
+ async getRecord(id) {
552
+ return this.readRecord(id);
553
+ }
554
+ /**
555
+ * The synchronous read behind getRecord(). Callers inside exec.transaction()
556
+ * use this one: an `await` inside that callback yields the microtask queue
557
+ * mid-transaction, and the next operation to run would try to open one of
558
+ * its own — transaction() requires a synchronous callback for exactly this
559
+ * reason (see SqlExecutor.transaction).
560
+ *
561
+ * The same synchrony is what lets the post-commit reads below report the
562
+ * version their own mutation produced: `getRecord()` runs its body before
563
+ * the `await` yields, so nothing interleaves between the commit and the
564
+ * read. A backend that made these reads genuinely asynchronous would open
565
+ * that window, and a concurrent mutation could be reported under this
566
+ * one's verb.
567
+ */
568
+ readRecord(id) {
569
+ const row = this.exec.get("SELECT * FROM records WHERE id = ?", [id]);
570
+ if (!row)
571
+ return null;
572
+ const associations = this.getAssociationsForRecord(id);
573
+ return rowToRecord(row, associations);
574
+ }
575
+ async patchContent(id, patch, opts = {}) {
576
+ const existing = await this.getRecord(id);
577
+ if (!existing)
578
+ throw new Error(`Record not found: "${id}"`);
579
+ this.checkExpectedVersion(existing, opts.expectedVersion);
580
+ const merged = applyMergePatch(existing.content, patch);
581
+ this.exec.transaction(() => {
582
+ if (opts.snapshot)
583
+ this.snapshotBeforeMutation(id, opts.snapshot);
584
+ fts5Strategy.remove(this.exec, id);
585
+ this.exec.run(`UPDATE records SET content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?`, [
586
+ JSON.stringify(merged),
587
+ toMs(/* @__PURE__ */ new Date()),
588
+ opts.updatedBy ?? null,
589
+ opts.updatedVia ?? null,
590
+ id
591
+ ]);
592
+ fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
593
+ this.syncFileRefs(id, existing.typeId, merged);
594
+ });
595
+ const updated = await this.getRecord(id);
596
+ if (!updated)
597
+ throw new Error(`Record not found after patchContent: "${id}"`);
598
+ return updated;
599
+ }
600
+ async deleteRecord(id, opts = {}) {
601
+ if (opts.hard) {
602
+ return this.exec.transaction(() => this.hardDeleteRecord(id, opts.expectedVersion));
603
+ } else {
604
+ this.exec.transaction(() => {
605
+ if (opts.snapshot)
606
+ this.snapshotBeforeMutation(id, opts.snapshot);
607
+ const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
608
+ const changed = this.exec.run(`UPDATE records SET deleted_at = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
609
+ toMs(/* @__PURE__ */ new Date()),
610
+ toMs(/* @__PURE__ */ new Date()),
611
+ opts.updatedBy ?? null,
612
+ opts.updatedVia ?? null,
613
+ id,
614
+ ...verParams
615
+ ]);
616
+ if (changed === 0)
617
+ this.throwVersionConflict(id, opts.expectedVersion);
618
+ });
619
+ }
620
+ const updated = await this.getRecord(id);
621
+ if (!updated)
622
+ throw new Error(`Record not found after deleteRecord: "${id}"`);
623
+ return updated;
624
+ }
625
+ /**
626
+ * Deletes a record's FTS entry, associations, versions, file-refs, and
627
+ * row, returning the record as it stood — the last copy that will ever
628
+ * exist, read here so that nothing can overtake it between the read and
629
+ * the deletes. Null when there was no such record. No write() call —
630
+ * callers batch it. expectedVersion is checked first so a lost CAS race
631
+ * leaves nothing touched (children reference the row, so the check can't
632
+ * fold into the final DELETE).
633
+ */
634
+ hardDeleteRecord(id, expectedVersion) {
635
+ const purged = this.readRecord(id);
636
+ if (expectedVersion !== void 0) {
637
+ if (!purged)
638
+ throw new StackNotFoundError(`Record not found: "${id}"`);
639
+ if (purged.version !== expectedVersion) {
640
+ throw new StackVersionConflictError(`Record "${id}" is at version ${purged.version}, expected ${expectedVersion}`, id, expectedVersion, purged.version);
641
+ }
642
+ }
643
+ if (!purged)
644
+ return null;
645
+ fts5Strategy.remove(this.exec, id);
646
+ this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
647
+ this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
648
+ this.exec.run("DELETE FROM file_refs WHERE record_id = ?", [id]);
649
+ this.exec.run("DELETE FROM records WHERE id = ?", [id]);
650
+ return purged;
651
+ }
652
+ async undeleteRecord(id, opts = {}) {
653
+ this.exec.transaction(() => {
654
+ if (opts.snapshot)
655
+ this.snapshotBeforeMutation(id, opts.snapshot);
656
+ const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
657
+ const changed = this.exec.run(`UPDATE records SET deleted_at = NULL, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [toMs(/* @__PURE__ */ new Date()), opts.updatedBy ?? null, opts.updatedVia ?? null, id, ...verParams]);
658
+ if (changed === 0)
659
+ this.throwVersionConflict(id, opts.expectedVersion);
660
+ });
661
+ const updated = await this.getRecord(id);
662
+ if (!updated)
663
+ throw new Error(`Record not found after undelete: "${id}"`);
664
+ return updated;
665
+ }
666
+ async setPermissions(id, permissions, opts = {}) {
667
+ this.exec.transaction(() => {
668
+ if (opts.snapshot)
669
+ this.snapshotBeforeMutation(id, opts.snapshot);
670
+ const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
671
+ const changed = this.exec.run(`UPDATE records SET permissions = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
672
+ permissions.length ? JSON.stringify(permissions) : null,
673
+ toMs(/* @__PURE__ */ new Date()),
674
+ opts.updatedBy ?? null,
675
+ opts.updatedVia ?? null,
676
+ id,
677
+ ...verParams
678
+ ]);
679
+ if (changed === 0)
680
+ this.throwVersionConflict(id, opts.expectedVersion);
681
+ });
682
+ const updated = await this.getRecord(id);
683
+ if (!updated)
684
+ throw new Error(`Record not found after setPermissions: "${id}"`);
685
+ return updated;
686
+ }
687
+ async setUnlisted(id, unlisted, opts = {}) {
688
+ this.exec.transaction(() => {
689
+ if (opts.snapshot)
690
+ this.snapshotBeforeMutation(id, opts.snapshot);
691
+ const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
692
+ const now = toMs(/* @__PURE__ */ new Date());
693
+ const changed = this.exec.run(`UPDATE records SET unlisted_at = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
694
+ unlisted ? now : null,
695
+ now,
696
+ opts.updatedBy ?? null,
697
+ opts.updatedVia ?? null,
698
+ id,
699
+ ...verParams
700
+ ]);
701
+ if (changed === 0)
702
+ this.throwVersionConflict(id, opts.expectedVersion);
703
+ });
704
+ const updated = await this.getRecord(id);
705
+ if (!updated)
706
+ throw new Error(`Record not found after setUnlisted: "${id}"`);
707
+ return updated;
708
+ }
709
+ async restoreVersion(id, version, opts = {}) {
710
+ const existing = await this.getRecord(id);
711
+ if (!existing)
712
+ throw new Error(`Record not found: "${id}"`);
713
+ this.checkExpectedVersion(existing, opts.expectedVersion);
714
+ const target = await this.getVersion(id, version);
715
+ if (!target)
716
+ throw new Error(`Version not found: ${id}@${version}`);
717
+ this.exec.transaction(() => {
718
+ if (opts.snapshot)
719
+ this.snapshotBeforeMutation(id, opts.snapshot);
720
+ fts5Strategy.remove(this.exec, id);
721
+ this.exec.run(`UPDATE records SET type_id = ?, content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?`, [
722
+ target.typeId,
723
+ JSON.stringify(target.content),
724
+ toMs(/* @__PURE__ */ new Date()),
725
+ opts.updatedBy ?? null,
726
+ opts.updatedVia ?? null,
727
+ id
728
+ ]);
729
+ if (target.associations !== void 0) {
730
+ this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
731
+ if (target.associations.length)
732
+ this.insertAssociations(id, target.associations);
733
+ }
734
+ fts5Strategy.insert(this.exec, id, JSON.stringify(target.content));
735
+ this.syncFileRefs(id, target.typeId, target.content);
736
+ });
737
+ const updated = await this.getRecord(id);
738
+ if (!updated)
739
+ throw new Error(`Record not found after restoreVersion: "${id}"`);
740
+ return updated;
741
+ }
742
+ async commitMigration(id, toTypeId, content, opts = {}) {
743
+ const existing = await this.getRecord(id);
744
+ if (!existing)
745
+ throw new Error(`Record not found: "${id}"`);
746
+ this.checkExpectedVersion(existing, opts.expectedVersion);
747
+ this.exec.transaction(() => {
748
+ if (opts.snapshot)
749
+ this.snapshotBeforeMutation(id, opts.snapshot);
750
+ fts5Strategy.remove(this.exec, id);
751
+ this.exec.run(`UPDATE records SET type_id = ?, content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?`, [
752
+ toTypeId,
753
+ JSON.stringify(content),
754
+ toMs(/* @__PURE__ */ new Date()),
755
+ opts.updatedBy ?? null,
756
+ opts.updatedVia ?? null,
757
+ id
758
+ ]);
759
+ fts5Strategy.insert(this.exec, id, JSON.stringify(content));
760
+ this.syncFileRefs(id, toTypeId, content);
761
+ });
762
+ const updated = await this.getRecord(id);
763
+ if (!updated)
764
+ throw new Error(`Record not found after commitMigration: "${id}"`);
765
+ return updated;
766
+ }
767
+ async queryRecords(query) {
768
+ const { sql: where, params } = buildWhereClause(query);
769
+ const order = buildOrderClause(query);
770
+ const limit = query.limit ?? 50;
771
+ const rows = this.exec.all(`SELECT r.* FROM records r ${where} ${order} LIMIT ?`, [...params, limit + 1]);
772
+ const hasMore = rows.length > limit;
773
+ const page = hasMore ? rows.slice(0, limit) : rows;
774
+ const records = page.map((row) => {
775
+ const associations = this.getAssociationsForRecord(row.id);
776
+ return rowToRecord(row, associations);
777
+ });
778
+ const countRows = this.exec.all(`SELECT COUNT(*) as total FROM records r ${where}`, params);
779
+ const total = countRows[0]?.total ?? 0;
780
+ const lastRecord = records[records.length - 1];
781
+ const cursor = hasMore && lastRecord ? makeCursor(lastRecord, getSortField(query)) : null;
782
+ return { records, cursor, total };
783
+ }
784
+ /**
785
+ * Atomically verify fileId is unreferenced, then hard-delete its
786
+ * metadata records (see deleteUnreferencedAttachmentRecords on the
787
+ * adapter contract). A real SQL transaction of synchronous calls — no
788
+ * `await` between the check and the deletes, so nothing interleaves.
789
+ */
790
+ async deleteUnreferencedAttachmentRecords(fileId, metadataTypeId) {
791
+ return this.exec.transaction(() => {
792
+ const referenced = this.exec.all(`SELECT 1 as found FROM associations WHERE kind = 'attachment' AND file_id = ?
793
+ UNION ALL
794
+ SELECT 1 FROM file_refs WHERE file_id = ?
795
+ LIMIT 1`, [fileId, fileId]);
796
+ if (referenced.length) {
797
+ throw new StackConflictError("Attachment is still referenced by one or more records");
798
+ }
799
+ const metaRows = this.exec.all(`SELECT id FROM records WHERE type_id = ? AND json_extract(content, '$.fileId') = ?`, [metadataTypeId, fileId]);
800
+ const deleted = [];
801
+ for (const row of metaRows) {
802
+ const purged = this.hardDeleteRecord(row.id);
803
+ if (purged)
804
+ deleted.push(purged);
805
+ }
806
+ return deleted;
807
+ });
808
+ }
809
+ // -------------------------------------------------------
810
+ // Versions
811
+ // -------------------------------------------------------
812
+ async getVersions(id) {
813
+ const rows = this.exec.all("SELECT * FROM versions WHERE record_id = ? ORDER BY version DESC", [id]);
814
+ return rows.map(rowToVersion);
815
+ }
816
+ async getVersion(id, version) {
817
+ const rows = this.exec.all("SELECT * FROM versions WHERE record_id = ? AND version = ?", [id, version]);
818
+ return rows.length ? rowToVersion(rows[0]) : null;
819
+ }
820
+ /**
821
+ * A (record_id, version) collision is rejected loudly via the UNIQUE
822
+ * constraint, mapped to StackConflictError — never a silently discarded
823
+ * snapshot leaving a hole in rollback history. See
824
+ * docs/spec/versioning.md § Optimistic concurrency (`ifVersion`).
825
+ */
826
+ insertVersionRow(id, version) {
827
+ try {
828
+ this.exec.run(`INSERT INTO versions
829
+ (record_id, version, type_id, content, updated_at, entity_id,
830
+ updated_by, updated_via, associations, permissions)
831
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
832
+ id,
833
+ version.version,
834
+ version.typeId,
835
+ JSON.stringify(version.content),
836
+ toMs(version.updatedAt),
837
+ version.entityId ?? null,
838
+ version.updatedBy ?? null,
839
+ version.updatedVia ?? null,
840
+ version.associations ? JSON.stringify(version.associations) : null,
841
+ version.permissions ? JSON.stringify(version.permissions) : null
842
+ ]);
843
+ } catch (err) {
844
+ if (isUniqueConstraintViolation(err)) {
845
+ throw new StackConflictError(`Version ${version.version} already exists for record "${id}" \u2014 a concurrent writer raced past this version. Use ifVersion to detect this before it happens.`);
846
+ }
847
+ throw err;
848
+ }
849
+ }
850
+ overwriteVersionRow(id, version) {
851
+ this.exec.run(`UPDATE versions
852
+ SET type_id = ?, content = ?, updated_at = ?, entity_id = ?, associations = ?, permissions = ?
853
+ WHERE record_id = ? AND version = ?`, [
854
+ version.typeId,
855
+ JSON.stringify(version.content),
856
+ toMs(version.updatedAt),
857
+ version.entityId ?? null,
858
+ version.associations ? JSON.stringify(version.associations) : null,
859
+ version.permissions ? JSON.stringify(version.permissions) : null,
860
+ id,
861
+ version.version
862
+ ]);
863
+ }
864
+ /**
865
+ * Standalone snapshot write for tooling and tests — loud on any
866
+ * collision, since nothing here is about to bump the version. Mutating
867
+ * methods take a `snapshot` option instead; see snapshotBeforeMutation.
868
+ */
869
+ async saveVersion(id, version) {
870
+ this.insertVersionRow(id, version);
871
+ }
872
+ /**
873
+ * The snapshot half of a mutating method's atomic snapshot-then-mutate
874
+ * transaction. A collision is a racing-writer conflict, except an orphan
875
+ * row at the record's *current* version — recognized by version number
876
+ * alone, never content — which is overwritten so the interrupted write
877
+ * can complete. See docs/spec/versioning.md § Snapshot atomicity.
878
+ */
879
+ snapshotBeforeMutation(id, version) {
880
+ try {
881
+ this.insertVersionRow(id, version);
882
+ } catch (err) {
883
+ if (!(err instanceof StackConflictError))
884
+ throw err;
885
+ const row = this.exec.get("SELECT version FROM records WHERE id = ?", [
886
+ id
887
+ ]);
888
+ if (!row || row.version !== version.version)
889
+ throw err;
890
+ this.overwriteVersionRow(id, version);
891
+ }
892
+ }
893
+ // -------------------------------------------------------
894
+ // Types
895
+ // -------------------------------------------------------
896
+ async saveType(type) {
897
+ this.exec.run(`INSERT OR REPLACE INTO types
898
+ (id, base_id, version, name, schema, schema_hash, migrates_from, created_at)
899
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
900
+ type.id,
901
+ type.baseId,
902
+ type.version,
903
+ type.name,
904
+ JSON.stringify(type.schema),
905
+ type.schemaHash,
906
+ type.migratesFrom ?? null,
907
+ toMs(type.createdAt)
908
+ ]);
909
+ this.fileRefFieldsByType.set(type.id, fileRefFieldNames(type.schema));
910
+ }
911
+ async getType(id) {
912
+ const rows = this.exec.all("SELECT * FROM types WHERE id = ?", [id]);
913
+ return rows.length ? rowToType(rows[0]) : null;
914
+ }
915
+ async listTypes() {
916
+ const rows = this.exec.all("SELECT * FROM types ORDER BY base_id, version");
917
+ return rows.map(rowToType);
918
+ }
919
+ // -------------------------------------------------------
920
+ // Associations
921
+ // -------------------------------------------------------
922
+ async associate(recordId, association, opts = {}) {
923
+ this.exec.transaction(() => {
924
+ if (opts.snapshot)
925
+ this.snapshotBeforeMutation(recordId, opts.snapshot);
926
+ this.bumpVersion(recordId, opts);
927
+ this.insertAssociations(recordId, [association]);
928
+ });
929
+ const updated = await this.getRecord(recordId);
930
+ if (!updated)
931
+ throw new Error(`Record not found after associate: "${recordId}"`);
932
+ return updated;
933
+ }
934
+ async dissociate(recordId, association, opts = {}) {
935
+ this.exec.transaction(() => {
936
+ if (opts.snapshot)
937
+ this.snapshotBeforeMutation(recordId, opts.snapshot);
938
+ this.bumpVersion(recordId, opts);
939
+ this.exec.run(`DELETE FROM associations
940
+ WHERE record_id = ?
941
+ AND kind = ?
942
+ AND label = ?
943
+ AND file_id = ?
944
+ AND related_scope = ?
945
+ AND related_id = ?
946
+ AND related_ns = ?
947
+ AND related_stack = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
948
+ });
949
+ const updated = await this.getRecord(recordId);
950
+ if (!updated)
951
+ throw new Error(`Record not found after dissociate: "${recordId}"`);
952
+ return updated;
953
+ }
954
+ bumpVersion(id, opts = {}) {
955
+ const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
956
+ const changed = this.exec.run(`UPDATE records SET version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [toMs(/* @__PURE__ */ new Date()), opts.updatedBy ?? null, opts.updatedVia ?? null, id, ...verParams]);
957
+ if (changed === 0)
958
+ this.throwVersionConflict(id, opts.expectedVersion);
959
+ }
960
+ /**
961
+ * FK enforcement (PRAGMA_FOREIGN_KEYS_ON) means inserting an
962
+ * association against a record that doesn't exist throws — mapped
963
+ * here to StackNotFoundError so associate() on a nonexistent record
964
+ * fails loudly instead of silently creating an orphan row.
965
+ */
966
+ insertAssociations(recordId, associations) {
967
+ for (const assoc of associations) {
968
+ try {
969
+ this.exec.run(`INSERT OR IGNORE INTO associations
970
+ (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack)
971
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [recordId, assoc.kind, assoc.label, ...associationKeyColumns(assoc)]);
972
+ } catch (err) {
973
+ if (isForeignKeyViolation(err)) {
974
+ throw new StackNotFoundError(`Record not found: "${recordId}"`);
975
+ }
976
+ throw err;
977
+ }
978
+ }
979
+ }
980
+ getAssociationsForRecord(recordId) {
981
+ const rows = this.exec.all("SELECT * FROM associations WHERE record_id = ?", [recordId]);
982
+ return rows.map(rowToAssociation);
983
+ }
984
+ /**
985
+ * File-ref field names for typeId, from the in-memory cache — falling
986
+ * back to a `types` lookup (and populating the cache) only for a typeId
987
+ * this process hasn't seen via saveType() yet, e.g. right after open().
988
+ */
989
+ getFileRefFields(typeId) {
990
+ const cached = this.fileRefFieldsByType.get(typeId);
991
+ if (cached)
992
+ return cached;
993
+ const typeRow = this.exec.get("SELECT schema FROM types WHERE id = ?", [
994
+ typeId
995
+ ]);
996
+ const fields = typeRow ? fileRefFieldNames(JSON.parse(typeRow.schema)) : [];
997
+ this.fileRefFieldsByType.set(typeId, fields);
998
+ return fields;
999
+ }
1000
+ /**
1001
+ * Replace a record's file_refs rows with whatever its content currently
1002
+ * holds in top-level file-ref fields, on every write that can change
1003
+ * content or typeId, so the index never drifts. Only top-level scalars
1004
+ * are indexed; the schema lookup is cached (fileRefFieldsByType).
1005
+ */
1006
+ syncFileRefs(recordId, typeId, content) {
1007
+ this.exec.run("DELETE FROM file_refs WHERE record_id = ?", [recordId]);
1008
+ const fields = this.getFileRefFields(typeId);
1009
+ for (const field of fields) {
1010
+ const value = content[field];
1011
+ if (typeof value === "string") {
1012
+ this.exec.run("INSERT OR IGNORE INTO file_refs (record_id, field, file_id) VALUES (?, ?, ?)", [recordId, field, value]);
1013
+ }
1014
+ }
1015
+ }
1016
+ };
1017
+
1018
+ // src/executor.ts
1019
+ var DurableObjectSqliteExecutor = class {
1020
+ constructor(storage) {
1021
+ this.storage = storage;
1022
+ }
1023
+ storage;
1024
+ get sql() {
1025
+ return this.storage.sql;
1026
+ }
1027
+ exec(sql) {
1028
+ this.sql.exec(sql);
1029
+ }
1030
+ run(sql, params = []) {
1031
+ const cursor = this.sql.exec(sql, ...params);
1032
+ return cursor.rowsWritten;
1033
+ }
1034
+ get(sql, params = []) {
1035
+ const rows = this.sql.exec(sql, ...params).toArray();
1036
+ return rows[0];
1037
+ }
1038
+ all(sql, params = []) {
1039
+ return this.sql.exec(sql, ...params).toArray();
1040
+ }
1041
+ transaction(fn) {
1042
+ return this.storage.transactionSync(fn);
1043
+ }
1044
+ };
1045
+
1046
+ // src/index.ts
1047
+ var DoSQLiteRecordAdapter = class _DoSQLiteRecordAdapter {
1048
+ constructor(exec) {
1049
+ this.exec = exec;
1050
+ this.record = new SharedSqlRecordLogic({ exec });
1051
+ }
1052
+ exec;
1053
+ capabilities = {
1054
+ fullTextSearch: true,
1055
+ contentFieldQuery: true,
1056
+ sortableFields: ["createdAt", "updatedAt", "version"],
1057
+ maxAttachmentBytes: null,
1058
+ maxContentBytes: null
1059
+ };
1060
+ ownerEntityId;
1061
+ timezone;
1062
+ record;
1063
+ /**
1064
+ * Create (or reattach to) the adapter for a DO instance. There is no
1065
+ * initialize()/open() split the way file-based adapters need one: a DO
1066
+ * id either already has a config record (a previous call created it —
1067
+ * reattach, opts.entityId/timezone ignored in favor of what's stored)
1068
+ * or it doesn't (first call — opts.entityId/timezone become the config).
1069
+ * Schema DDL is `CREATE TABLE IF NOT EXISTS`, so running it every call
1070
+ * is idempotent and cheap.
1071
+ */
1072
+ static async create(storage, opts) {
1073
+ const exec = new DurableObjectSqliteExecutor(storage);
1074
+ exec.exec(RECORD_SCHEMA_SQL);
1075
+ exec.exec(FTS5_SCHEMA_SQL);
1076
+ exec.exec(PRAGMA_FOREIGN_KEYS_ON);
1077
+ const adapter = new _DoSQLiteRecordAdapter(exec);
1078
+ const existing = exec.get(
1079
+ `SELECT content FROM records WHERE id = '_config'`
1080
+ );
1081
+ if (existing) {
1082
+ const config = readStackConfig(exec);
1083
+ adapter.ownerEntityId = config.entityId;
1084
+ adapter.timezone = config.timezone;
1085
+ } else {
1086
+ insertConfigRecord(exec, opts.entityId, opts.timezone);
1087
+ adapter.ownerEntityId = opts.entityId;
1088
+ adapter.timezone = opts.timezone;
1089
+ }
1090
+ return adapter;
1091
+ }
1092
+ // -------------------------------------------------------
1093
+ // Records
1094
+ // -------------------------------------------------------
1095
+ createRecord(record) {
1096
+ return this.record.createRecord(record);
1097
+ }
1098
+ getRecord(id) {
1099
+ return this.record.getRecord(id);
1100
+ }
1101
+ patchContent(id, patch, opts) {
1102
+ return this.record.patchContent(id, patch, opts);
1103
+ }
1104
+ deleteRecord(id, opts) {
1105
+ return this.record.deleteRecord(id, opts);
1106
+ }
1107
+ undeleteRecord(id, opts) {
1108
+ return this.record.undeleteRecord(id, opts);
1109
+ }
1110
+ setPermissions(id, permissions, opts) {
1111
+ return this.record.setPermissions(id, permissions, opts);
1112
+ }
1113
+ setUnlisted(id, unlisted, opts) {
1114
+ return this.record.setUnlisted(id, unlisted, opts);
1115
+ }
1116
+ restoreVersion(id, version, opts) {
1117
+ return this.record.restoreVersion(id, version, opts);
1118
+ }
1119
+ commitMigration(id, toTypeId, content, opts) {
1120
+ return this.record.commitMigration(id, toTypeId, content, opts);
1121
+ }
1122
+ queryRecords(query) {
1123
+ return this.record.queryRecords(query);
1124
+ }
1125
+ deleteUnreferencedAttachmentRecords(fileId, metadataTypeId) {
1126
+ return this.record.deleteUnreferencedAttachmentRecords(fileId, metadataTypeId);
1127
+ }
1128
+ // -------------------------------------------------------
1129
+ // Versions
1130
+ // -------------------------------------------------------
1131
+ getVersions(id) {
1132
+ return this.record.getVersions(id);
1133
+ }
1134
+ getVersion(id, version) {
1135
+ return this.record.getVersion(id, version);
1136
+ }
1137
+ saveVersion(id, version) {
1138
+ return this.record.saveVersion(id, version);
1139
+ }
1140
+ // -------------------------------------------------------
1141
+ // Types
1142
+ // -------------------------------------------------------
1143
+ saveType(type) {
1144
+ return this.record.saveType(type);
1145
+ }
1146
+ getType(id) {
1147
+ return this.record.getType(id);
1148
+ }
1149
+ listTypes() {
1150
+ return this.record.listTypes();
1151
+ }
1152
+ // -------------------------------------------------------
1153
+ // Associations
1154
+ // -------------------------------------------------------
1155
+ associate(recordId, association, opts) {
1156
+ return this.record.associate(recordId, association, opts);
1157
+ }
1158
+ dissociate(recordId, association, opts) {
1159
+ return this.record.dissociate(recordId, association, opts);
1160
+ }
1161
+ // -------------------------------------------------------
1162
+ // Lifecycle
1163
+ // -------------------------------------------------------
1164
+ /** No-op: every write through ctx.storage.sql is already durable. */
1165
+ async flush() {
1166
+ }
1167
+ /** No-op: no lock file, no connection to release — the DO's own lifecycle governs storage. */
1168
+ async close() {
1169
+ }
1170
+ };
1171
+
1172
+ export { DoSQLiteRecordAdapter, DurableObjectSqliteExecutor };
1173
+ //# sourceMappingURL=index.js.map
1174
+ //# sourceMappingURL=index.js.map