@haverstack/record-adapter-sqlite 0.35.0 → 0.36.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,5 +1,5 @@
1
- import { StackCapabilities, StackRecord, TypeId, QueryResult, FileId, BaseId, VersionsQuery, RecordVersion, JournalQuery, RecordJournalEntry, StackType, AssociationEdit, DataAssociation, AuthorityAssociation, Actor } from '@haverstack/core';
2
- import { StackRecordAdapter, JournalOptions, MutateOptions, AspectWriteOptions, AdapterQuery, Reach, ElementWriteOptions } from '@haverstack/core/adapter';
1
+ import { StackCapabilities, ConfigContent, StackRecord, TypeId, QueryResult, FileId, BaseId, VersionsQuery, RecordVersion, JournalQuery, RecordJournalEntry, StackType, AssociationEdit, DataAssociation, AuthorityAssociation, Actor } from '@haverstack/core';
2
+ import { StackRecordAdapter, JournalOptions, RecordHistory, MutateOptions, AspectWriteOptions, AdapterQuery, Reach, ElementWriteOptions, StoreSeed } from '@haverstack/core/adapter';
3
3
  import { StackTokenStore, TokenSession, TokenInfo } from '@haverstack/core/wire';
4
4
 
5
5
  /**
@@ -32,11 +32,10 @@ interface SqlExecutor {
32
32
  transaction<T>(fn: () => T): T;
33
33
  }
34
34
 
35
- /** timezone is optional passthrough app metadata — no default. */
36
- type StackConfig = {
37
- entityId: string;
38
- timezone: string | undefined;
39
- };
35
+ /** What open() does when the store is missing or present, like O_CREAT / O_EXCL. */
36
+ type StoreCreateMode = 'never' | 'ifMissing' | 'exclusive';
37
+ /** A plain DID, or a lazy provider called only when a store is actually created. */
38
+ type OwnerEntityIdInput = string | (() => string | Promise<string>);
40
39
 
41
40
  /**
42
41
  * The engine-independent half of a SQLite-backed StackRecordAdapter: the
@@ -58,10 +57,10 @@ type StackConfig = {
58
57
  declare abstract class SharedSqlRecordAdapter implements StackRecordAdapter {
59
58
  readonly capabilities: StackCapabilities;
60
59
  readonly ownerEntityId: string;
61
- readonly timezone: string | undefined;
62
60
  private readonly record;
63
- protected constructor(exec: SqlExecutor, config: StackConfig);
61
+ protected constructor(exec: SqlExecutor, config: ConfigContent);
64
62
  createRecord(record: StackRecord, opts?: JournalOptions): Promise<StackRecord>;
63
+ importRecord(record: StackRecord, history: RecordHistory): Promise<StackRecord>;
65
64
  getRecord(id: string): Promise<StackRecord | null>;
66
65
  patchContent(id: string, patch: Record<string, unknown | null>, opts?: MutateOptions): Promise<StackRecord>;
67
66
  setParent(id: string, parentId: string | null, opts?: AspectWriteOptions): Promise<StackRecord>;
@@ -77,7 +76,6 @@ declare abstract class SharedSqlRecordAdapter implements StackRecordAdapter {
77
76
  deleteUnreferencedAttachmentRecords(fileId: FileId, metadataBaseId: BaseId): Promise<StackRecord[]>;
78
77
  getVersions(id: string, query?: VersionsQuery): Promise<RecordVersion[]>;
79
78
  getVersion(id: string, version: number): Promise<RecordVersion | null>;
80
- saveVersion(id: string, version: RecordVersion): Promise<void>;
81
79
  getJournal(id: string, query?: JournalQuery): Promise<RecordJournalEntry[]>;
82
80
  saveType(type: StackType): Promise<void>;
83
81
  getType(id: TypeId): Promise<StackType | null>;
@@ -159,15 +157,9 @@ declare class NativeTokenStore implements StackTokenStore {
159
157
  * spread-args run/get/all calls to it).
160
158
  */
161
159
 
162
- /** What open() does when the store is missing or present, like O_CREAT / O_EXCL. */
163
- type StoreCreateMode = 'never' | 'ifMissing' | 'exclusive';
164
- /** A plain DID, or a lazy provider called only when a store is actually created. */
165
- type OwnerEntityIdInput = string | (() => string | Promise<string>);
166
160
  type NativeSQLiteRecordAdapterOpenOptions = {
167
161
  /** Absolute path to the .db file. */
168
162
  path: string;
169
- /** IANA timezone string e.g. "America/New_York". Consulted only when a store is created. No default. */
170
- timezone?: string;
171
163
  /**
172
164
  * Open even if a lock file from another live process is present.
173
165
  * Only needed if that process is gone but its PID was reused by
@@ -180,6 +172,7 @@ type NativeSQLiteRecordAdapterOpenOptions = {
180
172
  create?: 'never';
181
173
  /** Checked against the existing store's owner; a mismatch throws OwnerMismatchError. */
182
174
  ownerEntityId?: string;
175
+ seed?: never;
183
176
  } | {
184
177
  /** `'ifMissing'` opens the store if present and creates it if not; `'exclusive'` creates it and fails if present. */
185
178
  create: 'ifMissing' | 'exclusive';
@@ -188,6 +181,13 @@ type NativeSQLiteRecordAdapterOpenOptions = {
188
181
  * existing store's owner; a lazy provider is never called just to compare.
189
182
  */
190
183
  ownerEntityId: OwnerEntityIdInput;
184
+ seed?: never;
185
+ } | {
186
+ /** A seed only creates, as `'exclusive'` does: an existing store refuses it. */
187
+ create?: never;
188
+ /** The `_config` record and history the store is created holding: a copy's source's. */
189
+ seed: StoreSeed;
190
+ ownerEntityId?: never;
191
191
  });
192
192
  declare class NativeSQLiteRecordAdapter extends SharedSqlRecordAdapter {
193
193
  private readonly path;
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { existsSync, writeFileSync, unlinkSync, readFileSync } from 'fs';
1
+ import { existsSync, rmSync, writeFileSync, unlinkSync, readFileSync } from 'fs';
2
+ import { OwnerMismatchError, newStoreSeed, softDeletedRefusal, sameAssociationSet, ElementSetMovedError, FormatMismatchError, contentSortEntry, constantSortEntry, contentSortKey, parseContentFilterKey } from '@haverstack/core/adapter';
2
3
  import { StackConflictError, applyMergePatch, StackNotFoundError, StackVersionConflictError, StackBadRequestError, NATIVE_SORT_FIELDS } from '@haverstack/core';
3
- import { OwnerMismatchError, softDeletedRefusal, sameAssociationSet, ElementSetMovedError, contentSortEntry, constantSortEntry, contentSortKey, parseContentFilterKey } from '@haverstack/core/adapter';
4
4
  import { randomBytes, createHash } from 'crypto';
5
5
 
6
6
  // src/node-sqlite.ts
@@ -9,8 +9,16 @@ var { DatabaseSync } = process.getBuiltinModule(
9
9
  );
10
10
 
11
11
  // ../sqlite-shared/dist/schema.js
12
+ var FORMAT_VERSION = 1;
12
13
  var RECORD_SCHEMA_SQL = `
13
- CREATE TABLE IF NOT EXISTS records (
14
+ -- Which layout this store is, in its one row, so an export names the one
15
+ -- a reader holds. See docs/spec/portability.md \xA7 The portable format.
16
+ CREATE TABLE format (
17
+ id INTEGER PRIMARY KEY CHECK (id = 1),
18
+ version INTEGER NOT NULL
19
+ ) STRICT;
20
+
21
+ CREATE TABLE records (
14
22
  id TEXT PRIMARY KEY,
15
23
  type_id TEXT NOT NULL,
16
24
  created_at INTEGER NOT NULL,
@@ -30,7 +38,7 @@ var RECORD_SCHEMA_SQL = `
30
38
  -- A record's data associations: tags, attachments and relationships.
31
39
  -- Its permissions are in their own table below, so no predicate over this
32
40
  -- one can match a grantee as a relationship target.
33
- CREATE TABLE IF NOT EXISTS associations (
41
+ CREATE TABLE associations (
34
42
  record_id TEXT NOT NULL REFERENCES records(id),
35
43
  kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship')),
36
44
  label TEXT NOT NULL,
@@ -56,7 +64,7 @@ var RECORD_SCHEMA_SQL = `
56
64
  -- grantee; a group grantee's role is in the key, because member and
57
65
  -- admin name two different sets of people.
58
66
  -- See docs/spec/access-control.md \xA7 Record-level permissions.
59
- CREATE TABLE IF NOT EXISTS permissions (
67
+ CREATE TABLE permissions (
60
68
  record_id TEXT NOT NULL REFERENCES records(id),
61
69
  kind TEXT NOT NULL CHECK (kind IN ('permission', 'anyone')),
62
70
  label TEXT NOT NULL CHECK (label IN ('read', 'write')),
@@ -70,7 +78,7 @@ var RECORD_SCHEMA_SQL = `
70
78
  -- associations column: none of those aspects bumps version, so no
71
79
  -- version ever snapshots one. See
72
80
  -- docs/spec/versioning.md \xA7 Version history.
73
- CREATE TABLE IF NOT EXISTS versions (
81
+ CREATE TABLE versions (
74
82
  record_id TEXT NOT NULL REFERENCES records(id),
75
83
  version INTEGER NOT NULL,
76
84
  type_id TEXT NOT NULL,
@@ -88,7 +96,7 @@ var RECORD_SCHEMA_SQL = `
88
96
  -- by design \u2014 content lives on a versions row, and copying it here
89
97
  -- would make the journal the larger of the two stores.
90
98
  -- See docs/spec/journal.md.
91
- CREATE TABLE IF NOT EXISTS journal (
99
+ CREATE TABLE journal (
92
100
  record_id TEXT NOT NULL REFERENCES records(id),
93
101
  seq INTEGER NOT NULL,
94
102
  at INTEGER NOT NULL,
@@ -112,7 +120,7 @@ var RECORD_SCHEMA_SQL = `
112
120
  PRIMARY KEY (record_id, seq)
113
121
  ) STRICT;
114
122
 
115
- CREATE TABLE IF NOT EXISTS types (
123
+ CREATE TABLE types (
116
124
  id TEXT PRIMARY KEY,
117
125
  base_id TEXT NOT NULL,
118
126
  version INTEGER NOT NULL,
@@ -130,7 +138,7 @@ var RECORD_SCHEMA_SQL = `
130
138
  -- value_rank is 0 for a numeric value and 1 for a text one \u2014 the same
131
139
  -- split as "num_value IS NULL", but non-null, so one index can carry
132
140
  -- the whole ordering. file_id is set only for a 'file-ref' field.
133
- CREATE TABLE IF NOT EXISTS content_index (
141
+ CREATE TABLE content_index (
134
142
  record_id TEXT NOT NULL REFERENCES records(id),
135
143
  field TEXT NOT NULL,
136
144
  value_rank INTEGER NOT NULL CHECK (value_rank IN (0, 1)),
@@ -146,35 +154,35 @@ var RECORD_SCHEMA_SQL = `
146
154
  -- leaves the planner sorting every match before it can honor LIMIT.
147
155
  -- deleted_at and unlisted_at get none: an index on a column every query
148
156
  -- tests IS NULL only lures the planner off the one answering ORDER BY.
149
- CREATE INDEX IF NOT EXISTS idx_records_type_id ON records(type_id, created_at, id);
150
- CREATE INDEX IF NOT EXISTS idx_records_parent_id ON records(parent_id, created_at, id);
151
- CREATE INDEX IF NOT EXISTS idx_records_entity_id ON records(entity_id, created_at, id);
152
- CREATE INDEX IF NOT EXISTS idx_records_app_id ON records(app_id, created_at, id);
153
- CREATE INDEX IF NOT EXISTS idx_records_principal_id ON records(principal_id, created_at, id);
154
- CREATE INDEX IF NOT EXISTS idx_records_created_at ON records(created_at, id);
155
- CREATE INDEX IF NOT EXISTS idx_records_updated_at ON records(updated_at, id);
156
- CREATE INDEX IF NOT EXISTS idx_records_version ON records(version, id);
157
+ CREATE INDEX idx_records_type_id ON records(type_id, created_at, id);
158
+ CREATE INDEX idx_records_parent_id ON records(parent_id, created_at, id);
159
+ CREATE INDEX idx_records_entity_id ON records(entity_id, created_at, id);
160
+ CREATE INDEX idx_records_app_id ON records(app_id, created_at, id);
161
+ CREATE INDEX idx_records_principal_id ON records(principal_id, created_at, id);
162
+ CREATE INDEX idx_records_created_at ON records(created_at, id);
163
+ CREATE INDEX idx_records_updated_at ON records(updated_at, id);
164
+ CREATE INDEX idx_records_version ON records(version, id);
157
165
 
158
166
  -- Each association index ends with record_id so the semi-joins in
159
167
  -- query.ts read the matching record ids out of the index alone.
160
168
  -- idx_assoc_related carries related_ns ahead of related_id because a
161
169
  -- record- or entity-scoped target always stores '' there, which
162
170
  -- buildQueryPlan states explicitly to keep the prefix contiguous.
163
- CREATE INDEX IF NOT EXISTS idx_assoc_kind_label ON associations(kind, label, record_id);
164
- CREATE INDEX IF NOT EXISTS idx_assoc_kind_file_id ON associations(kind, file_id, record_id);
165
- CREATE INDEX IF NOT EXISTS idx_assoc_related ON associations(kind, related_scope, related_ns, related_id, record_id);
166
- CREATE INDEX IF NOT EXISTS idx_types_base_id ON types(base_id, version);
171
+ CREATE INDEX idx_assoc_kind_label ON associations(kind, label, record_id);
172
+ CREATE INDEX idx_assoc_kind_file_id ON associations(kind, file_id, record_id);
173
+ CREATE INDEX idx_assoc_related ON associations(kind, related_scope, related_ns, related_id, record_id);
174
+ CREATE INDEX idx_types_base_id ON types(base_id, version);
167
175
 
168
176
  -- The permissions key with record_id moved last, so a scoped query reads
169
177
  -- the records carrying an element straight out of the index.
170
- CREATE INDEX IF NOT EXISTS idx_permissions_element
178
+ CREATE INDEX idx_permissions_element
171
179
  ON permissions(kind, label, grantee_kind, grantee_id, role, record_id);
172
180
 
173
181
  -- The whole content ordering, in index order, for one field at a time:
174
182
  -- a content-sorted page is an ordered walk of a slice of this index.
175
- CREATE INDEX IF NOT EXISTS idx_content_index_sort
183
+ CREATE INDEX idx_content_index_sort
176
184
  ON content_index(field, value_rank, num_value, text_key, text_value, record_id);
177
- CREATE INDEX IF NOT EXISTS idx_content_index_file
185
+ CREATE INDEX idx_content_index_file
178
186
  ON content_index(file_id, record_id) WHERE file_id IS NOT NULL;
179
187
  `;
180
188
  var TOKENS_SCHEMA_SQL = `
@@ -189,7 +197,7 @@ var TOKENS_SCHEMA_SQL = `
189
197
  ) STRICT;
190
198
  `;
191
199
  var FTS5_SCHEMA_SQL = `
192
- CREATE VIRTUAL TABLE IF NOT EXISTS records_fts USING fts5(
200
+ CREATE VIRTUAL TABLE records_fts USING fts5(
193
201
  content,
194
202
  content='records',
195
203
  content_rowid='rowid',
@@ -198,11 +206,10 @@ var FTS5_SCHEMA_SQL = `
198
206
  `;
199
207
  var PRAGMA_FOREIGN_KEYS_ON = `PRAGMA foreign_keys = ON;`;
200
208
  var PRAGMA_JOURNAL_MODE_WAL = `PRAGMA journal_mode = WAL;`;
201
- var applyRecordSchema = (exec, opts) => {
209
+ var applyConnectionPragmas = (exec, opts) => {
202
210
  exec.exec(PRAGMA_FOREIGN_KEYS_ON);
203
- exec.exec(PRAGMA_JOURNAL_MODE_WAL);
204
- exec.exec(RECORD_SCHEMA_SQL);
205
- exec.exec(FTS5_SCHEMA_SQL);
211
+ if (opts.wal)
212
+ exec.exec(PRAGMA_JOURNAL_MODE_WAL);
206
213
  };
207
214
 
208
215
  // ../sqlite-shared/dist/fts5.js
@@ -288,28 +295,6 @@ var restoreImplicitAnd = (query) => withPhrasesMasked(query, (masked) => masked.
288
295
  // ../sqlite-shared/dist/executor.js
289
296
  var isForeignKeyViolation = (err) => err instanceof Error && err.message.includes("FOREIGN KEY constraint failed");
290
297
  var isUniqueConstraintViolation = (err) => err instanceof Error && err.message.includes("UNIQUE constraint failed");
291
-
292
- // ../sqlite-shared/dist/config.js
293
- var CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`;
294
- var insertConfigRecord = (exec, entityId, timezone) => {
295
- const now = Date.now();
296
- exec.run(`INSERT INTO records (id, type_id, created_at, updated_at, content, version)
297
- VALUES ('_config', '_config@1', ?, ?, ?, 1)`, [now, now, JSON.stringify({ entityId, timezone })]);
298
- return { entityId, timezone };
299
- };
300
- var tryReadStackConfig = (exec) => {
301
- const row = exec.get(CONFIG_ROW_SQL);
302
- if (!row)
303
- return null;
304
- const content = JSON.parse(row.content);
305
- return { entityId: content.entityId, timezone: content.timezone };
306
- };
307
- var readStackConfig = (exec) => {
308
- const config = tryReadStackConfig(exec);
309
- if (!config)
310
- throw new Error("Stack database is missing its config record.");
311
- return config;
312
- };
313
298
  var toBase64 = (text) => {
314
299
  const bytes = new TextEncoder().encode(text);
315
300
  let binary = "";
@@ -979,6 +964,35 @@ var versionRowValues = (version) => [
979
964
  version.updatedBy?.subjectId ?? null,
980
965
  version.updatedBy?.principalId ?? null
981
966
  ];
967
+ var JOURNAL_COLUMNS = [
968
+ "record_id",
969
+ "seq",
970
+ "at",
971
+ "kind",
972
+ "ops",
973
+ "previous_parent_id",
974
+ "actor",
975
+ "associations",
976
+ "version",
977
+ "type_id",
978
+ "parent_id"
979
+ ];
980
+ var INSERT_JOURNAL_SQL = `INSERT INTO journal (${JOURNAL_COLUMNS.join(", ")})
981
+ VALUES (${JOURNAL_COLUMNS.map(() => "?").join(", ")})`;
982
+ var journalValues = (recordId, seq, at, entry, row) => [
983
+ recordId,
984
+ seq,
985
+ toMs(at),
986
+ entry.kind,
987
+ JSON.stringify(entry.ops),
988
+ // '' is the root; NULL is an entry that names no origin at all.
989
+ entry.previousParentId === void 0 ? null : entry.previousParentId ?? "",
990
+ entry.actor ? JSON.stringify(entry.actor) : null,
991
+ entry.associations?.length ? JSON.stringify(entry.associations) : null,
992
+ row.version,
993
+ row.typeId,
994
+ row.parentId
995
+ ];
982
996
  var SharedSqlRecordLogic = class {
983
997
  exec;
984
998
  searchIndex;
@@ -1074,42 +1088,71 @@ var SharedSqlRecordLogic = class {
1074
1088
  */
1075
1089
  async createRecord(record, opts = {}) {
1076
1090
  this.exec.transaction(() => {
1077
- try {
1078
- this.exec.run(`INSERT INTO records
1091
+ this.insertRecord(record);
1092
+ this.appendJournal(record.id, opts.journal);
1093
+ });
1094
+ return record;
1095
+ }
1096
+ /**
1097
+ * createRecord() carrying the record's history as recorded: its version
1098
+ * rows and journal entries, `seq` and `at` included, in the same
1099
+ * transaction. Appends no entry of its own. See
1100
+ * docs/spec/portability.md § Writing history.
1101
+ */
1102
+ async importRecord(record, history) {
1103
+ this.exec.transaction(() => this.writeImported(record, history));
1104
+ return record;
1105
+ }
1106
+ /**
1107
+ * importRecord()'s write, for a caller already inside a transaction:
1108
+ * writeNewStore() writes a store's `_config` seed through it, so the one
1109
+ * Record a store starts with lands as every other Record does.
1110
+ */
1111
+ writeImported(record, history) {
1112
+ this.insertRecord(record);
1113
+ for (const version of history.versions)
1114
+ this.insertVersionRow(record.id, version);
1115
+ for (const entry of history.journal)
1116
+ this.insertJournalRow(record.id, entry);
1117
+ }
1118
+ /**
1119
+ * The record row and every table describing it, for a caller already
1120
+ * inside a transaction. A duplicate id maps to StackConflictError.
1121
+ */
1122
+ insertRecord(record) {
1123
+ try {
1124
+ this.exec.run(`INSERT INTO records
1079
1125
  (id, type_id, created_at, updated_at, content, version,
1080
1126
  parent_id, entity_id, app_id, principal_id, updated_by, updated_via,
1081
1127
  deleted_at, unlisted_at)
1082
1128
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1083
- record.id,
1084
- record.typeId,
1085
- toMs(record.createdAt),
1086
- toMs(record.updatedAt),
1087
- JSON.stringify(record.content),
1088
- record.version,
1089
- record.parentId ?? null,
1090
- record.createdBy?.subjectId ?? null,
1091
- record.appId ?? null,
1092
- record.createdBy?.principalId ?? null,
1093
- record.updatedBy?.subjectId ?? null,
1094
- record.updatedBy?.principalId ?? null,
1095
- record.deletedAt ? toMs(record.deletedAt) : null,
1096
- record.unlistedAt ? toMs(record.unlistedAt) : null
1097
- ]);
1098
- } catch (err) {
1099
- if (isUniqueConstraintViolation(err)) {
1100
- throw new StackConflictError(`Record already exists: "${record.id}"`);
1101
- }
1102
- throw err;
1129
+ record.id,
1130
+ record.typeId,
1131
+ toMs(record.createdAt),
1132
+ toMs(record.updatedAt),
1133
+ JSON.stringify(record.content),
1134
+ record.version,
1135
+ record.parentId ?? null,
1136
+ record.createdBy?.subjectId ?? null,
1137
+ record.appId ?? null,
1138
+ record.createdBy?.principalId ?? null,
1139
+ record.updatedBy?.subjectId ?? null,
1140
+ record.updatedBy?.principalId ?? null,
1141
+ record.deletedAt ? toMs(record.deletedAt) : null,
1142
+ record.unlistedAt ? toMs(record.unlistedAt) : null
1143
+ ]);
1144
+ } catch (err) {
1145
+ if (isUniqueConstraintViolation(err)) {
1146
+ throw new StackConflictError(`Record already exists: "${record.id}"`);
1103
1147
  }
1104
- if (record.associations?.length)
1105
- this.insertAssociations(record.id, record.associations);
1106
- if (record.permissions?.length)
1107
- this.insertPermissions(record.id, record.permissions);
1108
- this.searchIndex.insert(record.id, JSON.stringify(record.content));
1109
- this.contentIndex.sync(record.id, record.typeId, record.content);
1110
- this.appendJournal(record.id, opts.journal);
1111
- });
1112
- return record;
1148
+ throw err;
1149
+ }
1150
+ if (record.associations?.length)
1151
+ this.insertAssociations(record.id, record.associations);
1152
+ if (record.permissions?.length)
1153
+ this.insertPermissions(record.id, record.permissions);
1154
+ this.searchIndex.insert(record.id, JSON.stringify(record.content));
1155
+ this.contentIndex.sync(record.id, record.typeId, record.content);
1113
1156
  }
1114
1157
  async getRecord(id) {
1115
1158
  return this.readRecord(id);
@@ -1334,7 +1377,7 @@ var SharedSqlRecordLogic = class {
1334
1377
  VALUES (?, ?, ${VERSION_COLUMNS.map(() => "?").join(", ")})`, [id, version.version, ...versionRowValues(version)]);
1335
1378
  } catch (err) {
1336
1379
  if (isUniqueConstraintViolation(err)) {
1337
- 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.`);
1380
+ throw new StackConflictError(`Version ${version.version} already exists for record "${id}"`);
1338
1381
  }
1339
1382
  throw err;
1340
1383
  }
@@ -1350,14 +1393,6 @@ var SharedSqlRecordLogic = class {
1350
1393
  SET ${VERSION_COLUMNS.map((c) => `${c} = ?`).join(", ")}
1351
1394
  WHERE record_id = ? AND version = ?`, [...versionRowValues(version), id, version.version]);
1352
1395
  }
1353
- /**
1354
- * Standalone snapshot write for tooling and tests — loud on any
1355
- * collision, since nothing here is about to bump the version. Mutating
1356
- * methods take a `snapshot` option instead; see snapshotBeforeMutation.
1357
- */
1358
- async saveVersion(id, version) {
1359
- this.insertVersionRow(id, version);
1360
- }
1361
1396
  /**
1362
1397
  * The snapshot half of a mutating method's atomic snapshot-then-mutate
1363
1398
  * transaction. A collision is a racing-writer conflict, except an orphan
@@ -1371,8 +1406,9 @@ var SharedSqlRecordLogic = class {
1371
1406
  } catch (err) {
1372
1407
  if (!(err instanceof StackConflictError))
1373
1408
  throw err;
1374
- if (this.currentVersion(id) !== version.version)
1375
- throw err;
1409
+ if (this.currentVersion(id) !== version.version) {
1410
+ 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.`);
1411
+ }
1376
1412
  this.overwriteVersionRow(id, version);
1377
1413
  }
1378
1414
  }
@@ -1381,9 +1417,9 @@ var SharedSqlRecordLogic = class {
1381
1417
  // -------------------------------------------------------
1382
1418
  /**
1383
1419
  * Append one entry, stamped from the row as it now stands, so callers run
1384
- * this inside their write's transaction, after the write. `seq` is
1385
- * allocated in the INSERT itself, so no caller ever holds one it expects
1386
- * to be free. See docs/spec/journal.md § Ordering.
1420
+ * this inside their write's transaction, after the write. `seq` is read
1421
+ * from the log's maximum in that same transaction, so no caller ever holds
1422
+ * one it expects to be free. See docs/spec/journal.md § Ordering.
1387
1423
  */
1388
1424
  appendJournal(recordId, entry) {
1389
1425
  if (!entry)
@@ -1391,26 +1427,20 @@ var SharedSqlRecordLogic = class {
1391
1427
  const row = this.exec.get("SELECT version, type_id, parent_id FROM records WHERE id = ?", [recordId]);
1392
1428
  if (!row)
1393
1429
  throw recordNotFound(recordId);
1394
- this.exec.run(`INSERT INTO journal
1395
- (record_id, seq, at, kind, ops, version, type_id, parent_id,
1396
- previous_parent_id, actor, associations)
1397
- VALUES (
1398
- ?,
1399
- (SELECT COALESCE(MAX(seq), 0) + 1 FROM journal WHERE record_id = ?),
1400
- ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1401
- recordId,
1402
- recordId,
1403
- toMs(/* @__PURE__ */ new Date()),
1404
- entry.kind,
1405
- JSON.stringify(entry.ops),
1406
- row.version,
1407
- row.type_id,
1408
- row.parent_id,
1409
- // '' is the root; NULL is an entry that names no origin at all.
1410
- entry.previousParentId === void 0 ? null : entry.previousParentId ?? "",
1411
- entry.actor ? JSON.stringify(entry.actor) : null,
1412
- entry.associations?.length ? JSON.stringify(entry.associations) : null
1413
- ]);
1430
+ const { seq } = this.exec.get("SELECT COALESCE(MAX(seq), 0) + 1 AS seq FROM journal WHERE record_id = ?", [recordId]);
1431
+ this.exec.run(INSERT_JOURNAL_SQL, journalValues(recordId, seq, /* @__PURE__ */ new Date(), entry, {
1432
+ version: row.version,
1433
+ typeId: row.type_id,
1434
+ parentId: row.parent_id
1435
+ }));
1436
+ }
1437
+ /** A whole entry as recorded, `seq` and `at` included — importRecord()'s half of the journal. */
1438
+ insertJournalRow(recordId, entry) {
1439
+ this.exec.run(INSERT_JOURNAL_SQL, journalValues(recordId, entry.seq, entry.at, entry, {
1440
+ version: entry.version,
1441
+ typeId: entry.typeId,
1442
+ parentId: entry.parentId ?? null
1443
+ }));
1414
1444
  }
1415
1445
  async getJournal(id, query = {}) {
1416
1446
  if (!this.exec.get("SELECT 1 FROM records WHERE id = ?", [id])) {
@@ -1575,6 +1605,52 @@ var ASSOCIATION_KEY = "record_id, kind, label, file_id, related_scope, related_i
1575
1605
  var PERMISSION_KEY = "record_id, kind, label, grantee_kind, grantee_id, role";
1576
1606
  var addsOf = (edits) => edits.flatMap((e) => e.op === "add" ? [e.association] : []);
1577
1607
 
1608
+ // ../sqlite-shared/dist/config.js
1609
+ var CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`;
1610
+ var missingStoreError = (where) => new Error(`Cannot open: no database found at ${where}. Pass create: 'ifMissing' or 'exclusive' to create one.`);
1611
+ var writeNewStore = (exec, seed) => {
1612
+ exec.transaction(() => {
1613
+ exec.exec(RECORD_SCHEMA_SQL);
1614
+ exec.exec(FTS5_SCHEMA_SQL);
1615
+ exec.run("INSERT INTO format (id, version) VALUES (1, ?)", [FORMAT_VERSION]);
1616
+ new SharedSqlRecordLogic({ exec }).writeImported(seed.config, seed.history);
1617
+ });
1618
+ return seed.config.content;
1619
+ };
1620
+ var readStoredConfig = (exec, where) => {
1621
+ const hasFormat = exec.get(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'format'`) !== void 0;
1622
+ if (!hasFormat)
1623
+ return null;
1624
+ const version = exec.get("SELECT version FROM format WHERE id = 1")?.version;
1625
+ if (version !== FORMAT_VERSION)
1626
+ throw new FormatMismatchError(FORMAT_VERSION, version, where);
1627
+ const { content } = exec.get(CONFIG_ROW_SQL);
1628
+ return JSON.parse(content);
1629
+ };
1630
+ var openStore = async (exec, opts) => {
1631
+ const stored = readStoredConfig(exec, opts.where);
1632
+ if (!stored && opts.mode === "never")
1633
+ throw missingStoreError(opts.where);
1634
+ if (stored && (opts.mode === "exclusive" || opts.seed)) {
1635
+ throw new Error(`Cannot create: database already exists at ${opts.where}. Pass create: 'never' or 'ifMissing' without a seed to open it.`);
1636
+ }
1637
+ applyConnectionPragmas(exec, { wal: opts.wal });
1638
+ if (stored) {
1639
+ const expected = typeof opts.ownerEntityId === "string" ? opts.ownerEntityId : void 0;
1640
+ if (expected !== void 0 && expected !== stored.ownerEntityId) {
1641
+ throw new OwnerMismatchError(expected, stored.ownerEntityId, opts.where);
1642
+ }
1643
+ return stored;
1644
+ }
1645
+ if (opts.seed)
1646
+ return writeNewStore(exec, opts.seed);
1647
+ const owner = typeof opts.ownerEntityId === "function" ? await opts.ownerEntityId() : opts.ownerEntityId;
1648
+ if (!owner) {
1649
+ throw new Error(`Cannot create: no ownerEntityId given for the new database at ${opts.where}.`);
1650
+ }
1651
+ return writeNewStore(exec, newStoreSeed({ ownerEntityId: owner }));
1652
+ };
1653
+
1578
1654
  // ../sqlite-shared/dist/record-adapter.js
1579
1655
  var SQLITE_RECORD_CAPABILITIES = {
1580
1656
  filter: {
@@ -1596,12 +1672,10 @@ var SQLITE_RECORD_CAPABILITIES = {
1596
1672
  var SharedSqlRecordAdapter = class {
1597
1673
  capabilities = SQLITE_RECORD_CAPABILITIES;
1598
1674
  ownerEntityId;
1599
- timezone;
1600
1675
  record;
1601
1676
  constructor(exec, config) {
1602
1677
  this.record = new SharedSqlRecordLogic({ exec });
1603
- this.ownerEntityId = config.entityId;
1604
- this.timezone = config.timezone;
1678
+ this.ownerEntityId = config.ownerEntityId;
1605
1679
  }
1606
1680
  // -------------------------------------------------------
1607
1681
  // Records
@@ -1609,6 +1683,9 @@ var SharedSqlRecordAdapter = class {
1609
1683
  createRecord(record, opts) {
1610
1684
  return this.record.createRecord(record, opts);
1611
1685
  }
1686
+ importRecord(record, history) {
1687
+ return this.record.importRecord(record, history);
1688
+ }
1612
1689
  getRecord(id) {
1613
1690
  return this.record.getRecord(id);
1614
1691
  }
@@ -1651,9 +1728,6 @@ var SharedSqlRecordAdapter = class {
1651
1728
  getVersion(id, version) {
1652
1729
  return this.record.getVersion(id, version);
1653
1730
  }
1654
- saveVersion(id, version) {
1655
- return this.record.saveVersion(id, version);
1656
- }
1657
1731
  // -------------------------------------------------------
1658
1732
  // Change journal
1659
1733
  // -------------------------------------------------------
@@ -1857,51 +1931,22 @@ var NativeSQLiteRecordAdapter = class _NativeSQLiteRecordAdapter extends SharedS
1857
1931
  * See docs/spec/adapters.md § Construction.
1858
1932
  */
1859
1933
  static async open(opts) {
1860
- const mode = opts.create ?? "never";
1861
- const checkMode = (exists) => {
1862
- if (!exists && mode === "never") {
1863
- throw new Error(
1864
- `Cannot open: no database found at "${opts.path}". Pass create: 'ifMissing' or 'exclusive' to create one.`
1865
- );
1866
- }
1867
- if (exists && mode === "exclusive") {
1868
- throw new Error(
1869
- `Cannot create: database already exists at "${opts.path}". Pass create: 'never' or 'ifMissing' to open it.`
1870
- );
1871
- }
1872
- };
1873
- const existedBeforeLock = existsSync(opts.path);
1874
- checkMode(existedBeforeLock);
1875
- let newOwner;
1876
- if (!existedBeforeLock) {
1877
- newOwner = typeof opts.ownerEntityId === "function" ? await opts.ownerEntityId() : opts.ownerEntityId;
1878
- if (!newOwner) {
1879
- throw new Error(
1880
- `Cannot create: no ownerEntityId given for the new database at "${opts.path}".`
1881
- );
1882
- }
1883
- }
1934
+ const mode = opts.seed ? "exclusive" : opts.create ?? "never";
1935
+ const where = `"${opts.path}"`;
1936
+ if (mode === "never" && !existsSync(opts.path)) throw missingStoreError(where);
1884
1937
  acquireLock(opts.path, opts.force);
1938
+ const madeFile = !existsSync(opts.path);
1885
1939
  let db;
1886
1940
  try {
1887
- const exists = existsSync(opts.path);
1888
- checkMode(exists);
1889
1941
  db = new DatabaseSync(opts.path);
1890
1942
  const exec = new NativeSqliteExecutor(db);
1891
- applyRecordSchema(exec, { wal: true });
1892
- let config;
1893
- if (exists) {
1894
- config = readStackConfig(exec);
1895
- if (typeof opts.ownerEntityId === "string" && opts.ownerEntityId !== config.entityId) {
1896
- throw new OwnerMismatchError(opts.ownerEntityId, config.entityId, `"${opts.path}"`);
1897
- }
1898
- } else {
1899
- if (!newOwner) throw new Error(`Cannot create: "${opts.path}" vanished while opening.`);
1900
- config = insertConfigRecord(exec, newOwner, opts.timezone);
1901
- }
1943
+ const config = await openStore(exec, { ...opts, mode, where, wal: true });
1902
1944
  return new _NativeSQLiteRecordAdapter(opts.path, db, exec, config);
1903
1945
  } catch (err) {
1904
1946
  db?.close();
1947
+ if (madeFile) {
1948
+ for (const suffix of ["", "-wal", "-shm"]) rmSync(`${opts.path}${suffix}`, { force: true });
1949
+ }
1905
1950
  releaseLock(opts.path);
1906
1951
  throw err;
1907
1952
  }