@haverstack/record-adapter-sqlite 0.31.0 → 0.33.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.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { StackCapabilities, StackRecord, RecordChangeSet, RecordVersion, ActorOptions, TypeId, StackQuery, QueryResult, FileId, JournalQuery, RecordJournalEntry, StackType, Association, Actor } from '@haverstack/core';
1
+ import { StackCapabilities, StackRecord, RecordChangeSet, RecordVersion, ActorOptions, TypeId, StackQuery, QueryResult, FileId, VersionsQuery, JournalQuery, RecordJournalEntry, StackType, AssociationEdit, Actor } from '@haverstack/core';
2
2
  import { StackRecordAdapter, JournalOptions } from '@haverstack/core/adapter';
3
3
  import { StackTokenStore, TokenSession, TokenInfo } from '@haverstack/core/wire';
4
4
 
@@ -91,15 +91,14 @@ declare abstract class SharedSqlRecordAdapter implements StackRecordAdapter {
91
91
  } & ActorOptions & JournalOptions): Promise<StackRecord>;
92
92
  queryRecords(query: StackQuery): Promise<QueryResult>;
93
93
  deleteUnreferencedAttachmentRecords(fileId: FileId, metadataTypeIds: TypeId[]): Promise<StackRecord[]>;
94
- getVersions(id: string): Promise<RecordVersion[]>;
94
+ getVersions(id: string, query?: VersionsQuery): Promise<RecordVersion[]>;
95
95
  getVersion(id: string, version: number): Promise<RecordVersion | null>;
96
96
  saveVersion(id: string, version: RecordVersion): Promise<void>;
97
97
  getJournal(id: string, query?: JournalQuery): Promise<RecordJournalEntry[]>;
98
98
  saveType(type: StackType): Promise<void>;
99
99
  getType(id: TypeId): Promise<StackType | null>;
100
100
  listTypes(): Promise<StackType[]>;
101
- associate(recordId: string, association: Association, opts?: JournalOptions): Promise<StackRecord>;
102
- dissociate(recordId: string, association: Association, opts?: JournalOptions): Promise<StackRecord>;
101
+ amendAssociations(recordId: string, changes: AssociationEdit[], opts?: JournalOptions): Promise<StackRecord>;
103
102
  abstract flush(): Promise<void>;
104
103
  abstract close(): Promise<void>;
105
104
  }
@@ -157,8 +156,8 @@ declare class NativeTokenStore implements StackTokenStore {
157
156
  * Full-text search uses FTS5.
158
157
  *
159
158
  * A stack file is owned by exactly one process at a time (see
160
- * docs/spec/adapters.md § Concurrency & storage ownership). open()/initialize()
161
- * acquire a PID-stamped lock file beside the database and reject if
159
+ * docs/spec/adapters.md § Concurrency & storage ownership). open()
160
+ * acquires a PID-stamped lock file beside the database and rejects if
162
161
  * another live process already holds it; close() releases it.
163
162
  *
164
163
  * Token storage is a separate concern — see NativeTokenStore in this
@@ -173,19 +172,15 @@ declare class NativeTokenStore implements StackTokenStore {
173
172
  * spread-args run/get/all calls to it).
174
173
  */
175
174
 
176
- type NativeSQLiteRecordAdapterInitializeOptions = {
177
- /** Absolute path to the .db file. Must not already exist. */
178
- path: string;
179
- /** IANA timezone string e.g. "America/New_York". Optional passthrough app metadata — no default. */
180
- timezone?: string;
181
- /** Entity ID of the stack owner. */
182
- ownerEntityId: string;
183
- /** Bypass the storage-ownership lock check. See NativeSQLiteRecordAdapterOpenOptions.force. */
184
- force?: boolean;
185
- };
175
+ /** What open() does when the store is missing or present, like O_CREAT / O_EXCL. */
176
+ type StoreCreateMode = 'never' | 'ifMissing' | 'exclusive';
177
+ /** A plain DID, or a lazy provider called only when a store is actually created. */
178
+ type OwnerEntityIdInput = string | (() => string | Promise<string>);
186
179
  type NativeSQLiteRecordAdapterOpenOptions = {
187
- /** Absolute path to an existing .db file. */
180
+ /** Absolute path to the .db file. */
188
181
  path: string;
182
+ /** IANA timezone string e.g. "America/New_York". Consulted only when a store is created. No default. */
183
+ timezone?: string;
189
184
  /**
190
185
  * Open even if a lock file from another live process is present.
191
186
  * Only needed if that process is gone but its PID was reused by
@@ -193,26 +188,29 @@ type NativeSQLiteRecordAdapterOpenOptions = {
193
188
  * locks whose owning process is no longer running).
194
189
  */
195
190
  force?: boolean;
196
- };
191
+ } & ({
192
+ /** Open an existing store; fail if the file is missing. */
193
+ create?: 'never';
194
+ /** Checked against the existing store's owner; a mismatch throws OwnerMismatchError. */
195
+ ownerEntityId?: string;
196
+ } | {
197
+ /** `'ifMissing'` opens the store if present and creates it if not; `'exclusive'` creates it and fails if present. */
198
+ create: 'ifMissing' | 'exclusive';
199
+ /**
200
+ * Owner of a newly created store. A plain string is also checked against an
201
+ * existing store's owner; a lazy provider is never called just to compare.
202
+ */
203
+ ownerEntityId: OwnerEntityIdInput;
204
+ });
197
205
  declare class NativeSQLiteRecordAdapter extends SharedSqlRecordAdapter {
198
206
  private readonly path;
199
207
  private readonly db;
200
208
  private constructor();
201
209
  /**
202
- * Takes the storage-ownership lock, opens the file, and brings the
203
- * schema up. Shared by initialize() and open(), which differ only in
204
- * whether the file may already exist and in where the config record
205
- * comes from.
206
- */
207
- private static attach;
208
- /**
209
- * Initialize a new stack database. Fails if the file already exists —
210
- * use open() for existing databases.
211
- */
212
- static initialize(opts: NativeSQLiteRecordAdapterInitializeOptions): Promise<NativeSQLiteRecordAdapter>;
213
- /**
214
- * Open an existing stack database. Fails if the file does not exist —
215
- * use initialize() for new databases.
210
+ * Opens or creates the stack database at `path` according to `create`.
211
+ * Takes the storage-ownership lock first and releases it again if the
212
+ * open fails, since the caller never receives an adapter to close.
213
+ * See docs/spec/adapters.md § Construction.
216
214
  */
217
215
  static open(opts: NativeSQLiteRecordAdapterOpenOptions): Promise<NativeSQLiteRecordAdapter>;
218
216
  /** Folds the WAL back into the main file — useful before copying/backing up the database. */
@@ -220,4 +218,4 @@ declare class NativeSQLiteRecordAdapter extends SharedSqlRecordAdapter {
220
218
  close(): Promise<void>;
221
219
  }
222
220
 
223
- export { NativeSQLiteRecordAdapter, type NativeSQLiteRecordAdapterInitializeOptions, type NativeSQLiteRecordAdapterOpenOptions, NativeTokenStore, type NativeTokenStoreOpenOptions, defaultTokenStorePath };
221
+ export { NativeSQLiteRecordAdapter, type NativeSQLiteRecordAdapterOpenOptions, NativeTokenStore, type NativeTokenStoreOpenOptions, type OwnerEntityIdInput, type StoreCreateMode, defaultTokenStorePath };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, writeFileSync, unlinkSync, readFileSync } from 'fs';
2
- import { StackNotFoundError, StackConflictError, applyMergePatch, StackVersionConflictError, StackBadRequestError, NATIVE_SORT_FIELDS } from '@haverstack/core';
3
- import { contentSortEntry, parseContentFilterKey, contentSortKey } from '@haverstack/core/adapter';
2
+ import { StackNotFoundError, StackConflictError, applyMergePatch, assertOneSurface, StackVersionConflictError, StackBadRequestError, NATIVE_SORT_FIELDS } from '@haverstack/core';
3
+ import { OwnerMismatchError, contentSortEntry, parseContentFilterKey, contentSortKey } from '@haverstack/core/adapter';
4
4
  import { randomBytes, createHash } from 'crypto';
5
5
 
6
6
  // src/node-sqlite.ts
@@ -44,7 +44,7 @@ var RECORD_SCHEMA_SQL = `
44
44
  --
45
45
  -- attachment_record_id stays out of the primary key: it annotates a
46
46
  -- reference rather than naming one, so re-pointing it updates the row an
47
- -- associate() lands on instead of writing a second reference to the same
47
+ -- an amendAssociations() add lands on instead of writing a second reference to the same
48
48
  -- file. See docs/spec/attachments.md \xA7 Naming the upload a reference came from.
49
49
  --
50
50
  -- The primary key leads with record_id, so its implicit index already
@@ -93,7 +93,7 @@ var RECORD_SCHEMA_SQL = `
93
93
  -- No 'purged': a purge destroys the log rather than appending to it,
94
94
  -- so the constraint names exactly the kinds an entry can carry.
95
95
  -- See docs/spec/journal.md \xA7 A purge destroys the journal.
96
- kind TEXT NOT NULL CHECK (kind IN ('created', 'changed', 'deleted')),
96
+ kind TEXT NOT NULL CHECK (kind IN ('created', 'changed', 'removed')),
97
97
  ops TEXT NOT NULL CHECK (json_valid(ops)),
98
98
  version INTEGER NOT NULL,
99
99
  type_id TEXT NOT NULL,
@@ -375,7 +375,7 @@ var atBudget = (page, rows) => ({
375
375
  params: [...page.params, rows]
376
376
  });
377
377
  var sqlDirection = (query) => {
378
- const dir = query.sort?.direction ?? "desc";
378
+ const dir = query.sort ? query.sort.direction : "desc";
379
379
  if (dir !== "asc" && dir !== "desc") {
380
380
  throw new StackBadRequestError(`Invalid sort direction "${dir}": expected "asc" or "desc".`);
381
381
  }
@@ -1191,8 +1191,14 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1191
1191
  // -------------------------------------------------------
1192
1192
  // Versions
1193
1193
  // -------------------------------------------------------
1194
- async getVersions(id) {
1195
- const rows = this.exec.all("SELECT * FROM versions WHERE record_id = ? ORDER BY version DESC", [id]);
1194
+ async getVersions(id, query = {}) {
1195
+ const conditions = ["record_id = ?"];
1196
+ const values = [id];
1197
+ if (query.beforeVersion !== void 0) {
1198
+ conditions.push("version < ?");
1199
+ values.push(query.beforeVersion);
1200
+ }
1201
+ const rows = this.exec.all(`SELECT * FROM versions WHERE ${conditions.join(" AND ")} ORDER BY version DESC${query.limit !== void 0 ? " LIMIT ?" : ""}`, query.limit !== void 0 ? [...values, query.limit] : values);
1196
1202
  return rows.map(rowToVersion);
1197
1203
  }
1198
1204
  async getVersion(id, version) {
@@ -1342,24 +1348,20 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1342
1348
  // -------------------------------------------------------
1343
1349
  /**
1344
1350
  * Never bumps `version`/`updatedAt` and never snapshots — see
1345
- * StackRecordAdapter.associate(). The records row is untouched entirely;
1346
- * only the associations table changes.
1351
+ * StackRecordAdapter.amendAssociations(). The records row is untouched
1352
+ * entirely; only the associations table changes, in one transaction:
1353
+ * removes first, then adds.
1347
1354
  */
1348
- async associate(recordId, association, opts = {}) {
1355
+ async amendAssociations(recordId, changes, opts = {}) {
1356
+ assertOneSurface(changes);
1349
1357
  this.exec.transaction(() => {
1350
1358
  if (!this.readRecord(recordId))
1351
1359
  throw new StackNotFoundError(`Record not found: "${recordId}"`);
1352
- this.insertAssociations(recordId, [association]);
1353
- this.appendJournal(recordId, opts.journal);
1354
- });
1355
- return this.reread(recordId, "associate");
1356
- }
1357
- /** Never bumps `version`/`updatedAt` — see associate(). */
1358
- async dissociate(recordId, association, opts = {}) {
1359
- this.exec.transaction(() => {
1360
- if (!this.readRecord(recordId))
1361
- throw new StackNotFoundError(`Record not found: "${recordId}"`);
1362
- this.exec.run(`DELETE FROM associations
1360
+ for (const change of changes) {
1361
+ if (change.op !== "remove")
1362
+ continue;
1363
+ const { association } = change;
1364
+ this.exec.run(`DELETE FROM associations
1363
1365
  WHERE record_id = ?
1364
1366
  AND kind = ?
1365
1367
  AND label = ?
@@ -1369,9 +1371,13 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1369
1371
  AND related_ns = ?
1370
1372
  AND related_stack = ?
1371
1373
  AND related_role = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
1374
+ }
1375
+ const adds = changes.flatMap((c) => c.op === "add" ? [c.association] : []);
1376
+ if (adds.length)
1377
+ this.insertAssociations(recordId, adds);
1372
1378
  this.appendJournal(recordId, opts.journal);
1373
1379
  });
1374
- return this.reread(recordId, "dissociate");
1380
+ return this.reread(recordId, "amendAssociations");
1375
1381
  }
1376
1382
  /** The kinds each change-set key replaces, and nothing else. */
1377
1383
  static DATA_KINDS = ["tag", "attachment", "relationship"];
@@ -1408,7 +1414,7 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1408
1414
  *
1409
1415
  * FK enforcement (PRAGMA_FOREIGN_KEYS_ON) means inserting an
1410
1416
  * association against a record that doesn't exist throws — mapped
1411
- * here to StackNotFoundError so associate() on a nonexistent record
1417
+ * here to StackNotFoundError so amendAssociations() on a nonexistent record
1412
1418
  * fails loudly instead of silently creating an orphan row.
1413
1419
  */
1414
1420
  insertAssociations(recordId, associations) {
@@ -1543,8 +1549,8 @@ var SharedSqlRecordAdapter = class {
1543
1549
  // -------------------------------------------------------
1544
1550
  // Versions
1545
1551
  // -------------------------------------------------------
1546
- getVersions(id) {
1547
- return this.record.getVersions(id);
1552
+ getVersions(id, query) {
1553
+ return this.record.getVersions(id, query);
1548
1554
  }
1549
1555
  getVersion(id, version) {
1550
1556
  return this.record.getVersion(id, version);
@@ -1573,11 +1579,8 @@ var SharedSqlRecordAdapter = class {
1573
1579
  // -------------------------------------------------------
1574
1580
  // Associations
1575
1581
  // -------------------------------------------------------
1576
- associate(recordId, association, opts) {
1577
- return this.record.associate(recordId, association, opts);
1578
- }
1579
- dissociate(recordId, association, opts) {
1580
- return this.record.dissociate(recordId, association, opts);
1582
+ amendAssociations(recordId, changes, opts) {
1583
+ return this.record.amendAssociations(recordId, changes, opts);
1581
1584
  }
1582
1585
  };
1583
1586
  var lockPathFor = (dbPath) => `${dbPath}.lock`;
@@ -1746,44 +1749,60 @@ var NativeSQLiteRecordAdapter = class _NativeSQLiteRecordAdapter extends SharedS
1746
1749
  path;
1747
1750
  db;
1748
1751
  /**
1749
- * Takes the storage-ownership lock, opens the file, and brings the
1750
- * schema up. Shared by initialize() and open(), which differ only in
1751
- * whether the file may already exist and in where the config record
1752
- * comes from.
1753
- */
1754
- static attach(path, force) {
1755
- acquireLock(path, force);
1756
- const db = new DatabaseSync(path);
1757
- const exec = new NativeSqliteExecutor(db);
1758
- applyRecordSchema(exec);
1759
- return [db, exec];
1760
- }
1761
- /**
1762
- * Initialize a new stack database. Fails if the file already exists —
1763
- * use open() for existing databases.
1764
- */
1765
- static async initialize(opts) {
1766
- if (existsSync(opts.path)) {
1767
- throw new Error(
1768
- `Cannot initialize: database already exists at "${opts.path}". Use NativeSQLiteRecordAdapter.open() instead.`
1769
- );
1770
- }
1771
- const [db, exec] = _NativeSQLiteRecordAdapter.attach(opts.path, opts.force);
1772
- const config = insertConfigRecord(exec, opts.ownerEntityId, opts.timezone);
1773
- return new _NativeSQLiteRecordAdapter(opts.path, db, exec, config);
1774
- }
1775
- /**
1776
- * Open an existing stack database. Fails if the file does not exist —
1777
- * use initialize() for new databases.
1752
+ * Opens or creates the stack database at `path` according to `create`.
1753
+ * Takes the storage-ownership lock first and releases it again if the
1754
+ * open fails, since the caller never receives an adapter to close.
1755
+ * See docs/spec/adapters.md § Construction.
1778
1756
  */
1779
1757
  static async open(opts) {
1780
- if (!existsSync(opts.path)) {
1781
- throw new Error(
1782
- `Cannot open: no database found at "${opts.path}". Use NativeSQLiteRecordAdapter.initialize() to create one.`
1783
- );
1758
+ const mode = opts.create ?? "never";
1759
+ const checkMode = (exists) => {
1760
+ if (!exists && mode === "never") {
1761
+ throw new Error(
1762
+ `Cannot open: no database found at "${opts.path}". Pass create: 'ifMissing' or 'exclusive' to create one.`
1763
+ );
1764
+ }
1765
+ if (exists && mode === "exclusive") {
1766
+ throw new Error(
1767
+ `Cannot create: database already exists at "${opts.path}". Pass create: 'never' or 'ifMissing' to open it.`
1768
+ );
1769
+ }
1770
+ };
1771
+ const existedBeforeLock = existsSync(opts.path);
1772
+ checkMode(existedBeforeLock);
1773
+ let newOwner;
1774
+ if (!existedBeforeLock) {
1775
+ newOwner = typeof opts.ownerEntityId === "function" ? await opts.ownerEntityId() : opts.ownerEntityId;
1776
+ if (!newOwner) {
1777
+ throw new Error(
1778
+ `Cannot create: no ownerEntityId given for the new database at "${opts.path}".`
1779
+ );
1780
+ }
1781
+ }
1782
+ acquireLock(opts.path, opts.force);
1783
+ let db;
1784
+ try {
1785
+ const exists = existsSync(opts.path);
1786
+ checkMode(exists);
1787
+ db = new DatabaseSync(opts.path);
1788
+ const exec = new NativeSqliteExecutor(db);
1789
+ applyRecordSchema(exec, { wal: true });
1790
+ let config;
1791
+ if (exists) {
1792
+ config = readStackConfig(exec);
1793
+ if (typeof opts.ownerEntityId === "string" && opts.ownerEntityId !== config.entityId) {
1794
+ throw new OwnerMismatchError(opts.ownerEntityId, config.entityId, `"${opts.path}"`);
1795
+ }
1796
+ } else {
1797
+ if (!newOwner) throw new Error(`Cannot create: "${opts.path}" vanished while opening.`);
1798
+ config = insertConfigRecord(exec, newOwner, opts.timezone);
1799
+ }
1800
+ return new _NativeSQLiteRecordAdapter(opts.path, db, exec, config);
1801
+ } catch (err) {
1802
+ db?.close();
1803
+ releaseLock(opts.path);
1804
+ throw err;
1784
1805
  }
1785
- const [db, exec] = _NativeSQLiteRecordAdapter.attach(opts.path, opts.force);
1786
- return new _NativeSQLiteRecordAdapter(opts.path, db, exec, readStackConfig(exec));
1787
1806
  }
1788
1807
  // -------------------------------------------------------
1789
1808
  // Lifecycle