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