@haverstack/record-adapter-do-sqlite 0.28.0 → 0.30.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 } from '@haverstack/core';
2
- import { StackRecordAdapter, JournalOptions, MutateOptions, AspectWriteOptions, AdapterQuery, Reach, ElementWriteOptions } from '@haverstack/core/adapter';
1
+ import { StackRecordAdapter, StoreWriters, JournalOptions, RecordHistory, MutateOptions, AspectWriteOptions, AdapterQuery, Reach, ElementWriteOptions, StoreSeed } from '@haverstack/core/adapter';
2
+ import { StackCapabilities, ConfigContent, StackRecord, TypeId, QueryResult, FileId, BaseId, VersionsQuery, RecordVersion, JournalQuery, RecordJournalEntry, StackType, AssociationEdit, DataAssociation, AuthorityAssociation } from '@haverstack/core';
3
3
 
4
4
  /**
5
5
  * Normalizes a SQLite binding's call convention (array-bind-then-step,
@@ -31,12 +31,6 @@ interface SqlExecutor {
31
31
  transaction<T>(fn: () => T): T;
32
32
  }
33
33
 
34
- /** timezone is optional passthrough app metadata — no default. */
35
- type StackConfig = {
36
- entityId: string;
37
- timezone: string | undefined;
38
- };
39
-
40
34
  /**
41
35
  * The engine-independent half of a SQLite-backed StackRecordAdapter: the
42
36
  * capability declaration and the delegation of all twenty-three
@@ -57,10 +51,15 @@ type StackConfig = {
57
51
  declare abstract class SharedSqlRecordAdapter implements StackRecordAdapter {
58
52
  readonly capabilities: StackCapabilities;
59
53
  readonly ownerEntityId: string;
60
- readonly timezone: string | undefined;
54
+ /** Each engine holds its database for one process; see docs/spec/adapters.md § Concurrency & storage ownership. */
55
+ readonly writers: StoreWriters;
61
56
  private readonly record;
62
- protected constructor(exec: SqlExecutor, config: StackConfig);
57
+ /** Moved by every write method; see head(). */
58
+ private readonly written;
59
+ protected constructor(exec: SqlExecutor, config: ConfigContent);
60
+ head(): Promise<string>;
63
61
  createRecord(record: StackRecord, opts?: JournalOptions): Promise<StackRecord>;
62
+ importRecord(record: StackRecord, history: RecordHistory): Promise<StackRecord>;
64
63
  getRecord(id: string): Promise<StackRecord | null>;
65
64
  patchContent(id: string, patch: Record<string, unknown | null>, opts?: MutateOptions): Promise<StackRecord>;
66
65
  setParent(id: string, parentId: string | null, opts?: AspectWriteOptions): Promise<StackRecord>;
@@ -76,7 +75,6 @@ declare abstract class SharedSqlRecordAdapter implements StackRecordAdapter {
76
75
  deleteUnreferencedAttachmentRecords(fileId: FileId, metadataBaseId: BaseId): Promise<StackRecord[]>;
77
76
  getVersions(id: string, query?: VersionsQuery): Promise<RecordVersion[]>;
78
77
  getVersion(id: string, version: number): Promise<RecordVersion | null>;
79
- saveVersion(id: string, version: RecordVersion): Promise<void>;
80
78
  getJournal(id: string, query?: JournalQuery): Promise<RecordJournalEntry[]>;
81
79
  saveType(type: StackType): Promise<void>;
82
80
  getType(id: TypeId): Promise<StackType | null>;
@@ -149,17 +147,23 @@ type DoSQLiteRecordAdapterOpenOptions = {
149
147
  * provider is called only when the DO's storage has none yet.
150
148
  */
151
149
  ownerEntityId: string | (() => string | Promise<string>);
152
- /** IANA timezone string e.g. "America/New_York". Optional passthrough app metadata — no default. */
153
- timezone?: string;
150
+ seed?: never;
151
+ } | {
152
+ /**
153
+ * The `_config` record and history new storage is created holding: a
154
+ * copy's source's. Storage that already holds a store refuses it.
155
+ */
156
+ seed: StoreSeed;
157
+ ownerEntityId?: never;
154
158
  };
155
159
  declare class DoSQLiteRecordAdapter extends SharedSqlRecordAdapter {
156
160
  private constructor();
157
161
  /**
158
162
  * Open the adapter for a DO instance, creating its storage on first use.
159
163
  * A DO id either already has a config record (reattach) or it doesn't
160
- * (first call — opts.ownerEntityId/timezone become the config), so there
161
- * is no `create` option. Schema DDL is `CREATE TABLE IF NOT EXISTS`, so
162
- * running it every call is idempotent and cheap.
164
+ * (first call — opts.ownerEntityId becomes the config), so there
165
+ * is no `create` option. A new store's tables are written in the same
166
+ * transaction as its config record, so storage holds a whole store or none.
163
167
  *
164
168
  * DO SQLite manages its own durability and rejects PRAGMA journal_mode
165
169
  * outright ("not authorized") — verified against the real runtime, not
package/dist/index.js CHANGED
@@ -1,9 +1,17 @@
1
+ import { trackWrites, OwnerMismatchError, newStoreSeed, softDeletedRefusal, sameAssociationSet, ElementSetMovedError, FormatMismatchError, contentSortEntry, constantSortEntry, contentSortKey, parseContentFilterKey } from '@haverstack/core/adapter';
1
2
  import { StackConflictError, applyMergePatch, StackNotFoundError, StackVersionConflictError, StackBadRequestError, NATIVE_SORT_FIELDS } from '@haverstack/core';
2
- import { OwnerMismatchError, softDeletedRefusal, sameAssociationSet, ElementSetMovedError, contentSortEntry, constantSortEntry, contentSortKey, parseContentFilterKey } from '@haverstack/core/adapter';
3
3
 
4
4
  // ../sqlite-shared/dist/schema.js
5
+ var FORMAT_VERSION = 1;
5
6
  var RECORD_SCHEMA_SQL = `
6
- CREATE TABLE IF NOT EXISTS records (
7
+ -- Which layout this store is, in its one row, so an export names the one
8
+ -- a reader holds. See docs/spec/portability.md \xA7 The portable format.
9
+ CREATE TABLE format (
10
+ id INTEGER PRIMARY KEY CHECK (id = 1),
11
+ version INTEGER NOT NULL
12
+ ) STRICT;
13
+
14
+ CREATE TABLE records (
7
15
  id TEXT PRIMARY KEY,
8
16
  type_id TEXT NOT NULL,
9
17
  created_at INTEGER NOT NULL,
@@ -23,7 +31,7 @@ var RECORD_SCHEMA_SQL = `
23
31
  -- A record's data associations: tags, attachments and relationships.
24
32
  -- Its permissions are in their own table below, so no predicate over this
25
33
  -- one can match a grantee as a relationship target.
26
- CREATE TABLE IF NOT EXISTS associations (
34
+ CREATE TABLE associations (
27
35
  record_id TEXT NOT NULL REFERENCES records(id),
28
36
  kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship')),
29
37
  label TEXT NOT NULL,
@@ -49,7 +57,7 @@ var RECORD_SCHEMA_SQL = `
49
57
  -- grantee; a group grantee's role is in the key, because member and
50
58
  -- admin name two different sets of people.
51
59
  -- See docs/spec/access-control.md \xA7 Record-level permissions.
52
- CREATE TABLE IF NOT EXISTS permissions (
60
+ CREATE TABLE permissions (
53
61
  record_id TEXT NOT NULL REFERENCES records(id),
54
62
  kind TEXT NOT NULL CHECK (kind IN ('permission', 'anyone')),
55
63
  label TEXT NOT NULL CHECK (label IN ('read', 'write')),
@@ -63,7 +71,7 @@ var RECORD_SCHEMA_SQL = `
63
71
  -- associations column: none of those aspects bumps version, so no
64
72
  -- version ever snapshots one. See
65
73
  -- docs/spec/versioning.md \xA7 Version history.
66
- CREATE TABLE IF NOT EXISTS versions (
74
+ CREATE TABLE versions (
67
75
  record_id TEXT NOT NULL REFERENCES records(id),
68
76
  version INTEGER NOT NULL,
69
77
  type_id TEXT NOT NULL,
@@ -81,7 +89,7 @@ var RECORD_SCHEMA_SQL = `
81
89
  -- by design \u2014 content lives on a versions row, and copying it here
82
90
  -- would make the journal the larger of the two stores.
83
91
  -- See docs/spec/journal.md.
84
- CREATE TABLE IF NOT EXISTS journal (
92
+ CREATE TABLE journal (
85
93
  record_id TEXT NOT NULL REFERENCES records(id),
86
94
  seq INTEGER NOT NULL,
87
95
  at INTEGER NOT NULL,
@@ -105,7 +113,7 @@ var RECORD_SCHEMA_SQL = `
105
113
  PRIMARY KEY (record_id, seq)
106
114
  ) STRICT;
107
115
 
108
- CREATE TABLE IF NOT EXISTS types (
116
+ CREATE TABLE types (
109
117
  id TEXT PRIMARY KEY,
110
118
  base_id TEXT NOT NULL,
111
119
  version INTEGER NOT NULL,
@@ -123,7 +131,7 @@ var RECORD_SCHEMA_SQL = `
123
131
  -- value_rank is 0 for a numeric value and 1 for a text one \u2014 the same
124
132
  -- split as "num_value IS NULL", but non-null, so one index can carry
125
133
  -- the whole ordering. file_id is set only for a 'file-ref' field.
126
- CREATE TABLE IF NOT EXISTS content_index (
134
+ CREATE TABLE content_index (
127
135
  record_id TEXT NOT NULL REFERENCES records(id),
128
136
  field TEXT NOT NULL,
129
137
  value_rank INTEGER NOT NULL CHECK (value_rank IN (0, 1)),
@@ -139,39 +147,39 @@ var RECORD_SCHEMA_SQL = `
139
147
  -- leaves the planner sorting every match before it can honor LIMIT.
140
148
  -- deleted_at and unlisted_at get none: an index on a column every query
141
149
  -- tests IS NULL only lures the planner off the one answering ORDER BY.
142
- CREATE INDEX IF NOT EXISTS idx_records_type_id ON records(type_id, created_at, id);
143
- CREATE INDEX IF NOT EXISTS idx_records_parent_id ON records(parent_id, created_at, id);
144
- CREATE INDEX IF NOT EXISTS idx_records_entity_id ON records(entity_id, created_at, id);
145
- CREATE INDEX IF NOT EXISTS idx_records_app_id ON records(app_id, created_at, id);
146
- CREATE INDEX IF NOT EXISTS idx_records_principal_id ON records(principal_id, created_at, id);
147
- CREATE INDEX IF NOT EXISTS idx_records_created_at ON records(created_at, id);
148
- CREATE INDEX IF NOT EXISTS idx_records_updated_at ON records(updated_at, id);
149
- CREATE INDEX IF NOT EXISTS idx_records_version ON records(version, id);
150
+ CREATE INDEX idx_records_type_id ON records(type_id, created_at, id);
151
+ CREATE INDEX idx_records_parent_id ON records(parent_id, created_at, id);
152
+ CREATE INDEX idx_records_entity_id ON records(entity_id, created_at, id);
153
+ CREATE INDEX idx_records_app_id ON records(app_id, created_at, id);
154
+ CREATE INDEX idx_records_principal_id ON records(principal_id, created_at, id);
155
+ CREATE INDEX idx_records_created_at ON records(created_at, id);
156
+ CREATE INDEX idx_records_updated_at ON records(updated_at, id);
157
+ CREATE INDEX idx_records_version ON records(version, id);
150
158
 
151
159
  -- Each association index ends with record_id so the semi-joins in
152
160
  -- query.ts read the matching record ids out of the index alone.
153
161
  -- idx_assoc_related carries related_ns ahead of related_id because a
154
162
  -- record- or entity-scoped target always stores '' there, which
155
163
  -- buildQueryPlan states explicitly to keep the prefix contiguous.
156
- CREATE INDEX IF NOT EXISTS idx_assoc_kind_label ON associations(kind, label, record_id);
157
- CREATE INDEX IF NOT EXISTS idx_assoc_kind_file_id ON associations(kind, file_id, record_id);
158
- CREATE INDEX IF NOT EXISTS idx_assoc_related ON associations(kind, related_scope, related_ns, related_id, record_id);
159
- CREATE INDEX IF NOT EXISTS idx_types_base_id ON types(base_id, version);
164
+ CREATE INDEX idx_assoc_kind_label ON associations(kind, label, record_id);
165
+ CREATE INDEX idx_assoc_kind_file_id ON associations(kind, file_id, record_id);
166
+ CREATE INDEX idx_assoc_related ON associations(kind, related_scope, related_ns, related_id, record_id);
167
+ CREATE INDEX idx_types_base_id ON types(base_id, version);
160
168
 
161
169
  -- The permissions key with record_id moved last, so a scoped query reads
162
170
  -- the records carrying an element straight out of the index.
163
- CREATE INDEX IF NOT EXISTS idx_permissions_element
171
+ CREATE INDEX idx_permissions_element
164
172
  ON permissions(kind, label, grantee_kind, grantee_id, role, record_id);
165
173
 
166
174
  -- The whole content ordering, in index order, for one field at a time:
167
175
  -- a content-sorted page is an ordered walk of a slice of this index.
168
- CREATE INDEX IF NOT EXISTS idx_content_index_sort
176
+ CREATE INDEX idx_content_index_sort
169
177
  ON content_index(field, value_rank, num_value, text_key, text_value, record_id);
170
- CREATE INDEX IF NOT EXISTS idx_content_index_file
178
+ CREATE INDEX idx_content_index_file
171
179
  ON content_index(file_id, record_id) WHERE file_id IS NOT NULL;
172
180
  `;
173
181
  var FTS5_SCHEMA_SQL = `
174
- CREATE VIRTUAL TABLE IF NOT EXISTS records_fts USING fts5(
182
+ CREATE VIRTUAL TABLE records_fts USING fts5(
175
183
  content,
176
184
  content='records',
177
185
  content_rowid='rowid',
@@ -179,10 +187,11 @@ var FTS5_SCHEMA_SQL = `
179
187
  );
180
188
  `;
181
189
  var PRAGMA_FOREIGN_KEYS_ON = `PRAGMA foreign_keys = ON;`;
182
- var applyRecordSchema = (exec, opts) => {
190
+ var PRAGMA_JOURNAL_MODE_WAL = `PRAGMA journal_mode = WAL;`;
191
+ var applyConnectionPragmas = (exec, opts) => {
183
192
  exec.exec(PRAGMA_FOREIGN_KEYS_ON);
184
- exec.exec(RECORD_SCHEMA_SQL);
185
- exec.exec(FTS5_SCHEMA_SQL);
193
+ if (opts.wal)
194
+ exec.exec(PRAGMA_JOURNAL_MODE_WAL);
186
195
  };
187
196
 
188
197
  // ../sqlite-shared/dist/fts5.js
@@ -268,22 +277,6 @@ var restoreImplicitAnd = (query) => withPhrasesMasked(query, (masked) => masked.
268
277
  // ../sqlite-shared/dist/executor.js
269
278
  var isForeignKeyViolation = (err) => err instanceof Error && err.message.includes("FOREIGN KEY constraint failed");
270
279
  var isUniqueConstraintViolation = (err) => err instanceof Error && err.message.includes("UNIQUE constraint failed");
271
-
272
- // ../sqlite-shared/dist/config.js
273
- var CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`;
274
- var insertConfigRecord = (exec, entityId, timezone) => {
275
- const now = Date.now();
276
- exec.run(`INSERT INTO records (id, type_id, created_at, updated_at, content, version)
277
- VALUES ('_config', '_config@1', ?, ?, ?, 1)`, [now, now, JSON.stringify({ entityId, timezone })]);
278
- return { entityId, timezone };
279
- };
280
- var tryReadStackConfig = (exec) => {
281
- const row = exec.get(CONFIG_ROW_SQL);
282
- if (!row)
283
- return null;
284
- const content = JSON.parse(row.content);
285
- return { entityId: content.entityId, timezone: content.timezone };
286
- };
287
280
  var toBase64 = (text) => {
288
281
  const bytes = new TextEncoder().encode(text);
289
282
  let binary = "";
@@ -953,6 +946,35 @@ var versionRowValues = (version) => [
953
946
  version.updatedBy?.subjectId ?? null,
954
947
  version.updatedBy?.principalId ?? null
955
948
  ];
949
+ var JOURNAL_COLUMNS = [
950
+ "record_id",
951
+ "seq",
952
+ "at",
953
+ "kind",
954
+ "ops",
955
+ "previous_parent_id",
956
+ "actor",
957
+ "associations",
958
+ "version",
959
+ "type_id",
960
+ "parent_id"
961
+ ];
962
+ var INSERT_JOURNAL_SQL = `INSERT INTO journal (${JOURNAL_COLUMNS.join(", ")})
963
+ VALUES (${JOURNAL_COLUMNS.map(() => "?").join(", ")})`;
964
+ var journalValues = (recordId, seq, at, entry, row) => [
965
+ recordId,
966
+ seq,
967
+ toMs(at),
968
+ entry.kind,
969
+ JSON.stringify(entry.ops),
970
+ // '' is the root; NULL is an entry that names no origin at all.
971
+ entry.previousParentId === void 0 ? null : entry.previousParentId ?? "",
972
+ entry.actor ? JSON.stringify(entry.actor) : null,
973
+ entry.associations?.length ? JSON.stringify(entry.associations) : null,
974
+ row.version,
975
+ row.typeId,
976
+ row.parentId
977
+ ];
956
978
  var SharedSqlRecordLogic = class {
957
979
  exec;
958
980
  searchIndex;
@@ -1048,42 +1070,71 @@ var SharedSqlRecordLogic = class {
1048
1070
  */
1049
1071
  async createRecord(record, opts = {}) {
1050
1072
  this.exec.transaction(() => {
1051
- try {
1052
- this.exec.run(`INSERT INTO records
1073
+ this.insertRecord(record);
1074
+ this.appendJournal(record.id, opts.journal);
1075
+ });
1076
+ return record;
1077
+ }
1078
+ /**
1079
+ * createRecord() carrying the record's history as recorded: its version
1080
+ * rows and journal entries, `seq` and `at` included, in the same
1081
+ * transaction. Appends no entry of its own. See
1082
+ * docs/spec/portability.md § Writing history.
1083
+ */
1084
+ async importRecord(record, history) {
1085
+ this.exec.transaction(() => this.writeImported(record, history));
1086
+ return record;
1087
+ }
1088
+ /**
1089
+ * importRecord()'s write, for a caller already inside a transaction:
1090
+ * writeNewStore() writes a store's `_config` seed through it, so the one
1091
+ * Record a store starts with lands as every other Record does.
1092
+ */
1093
+ writeImported(record, history) {
1094
+ this.insertRecord(record);
1095
+ for (const version of history.versions)
1096
+ this.insertVersionRow(record.id, version);
1097
+ for (const entry of history.journal)
1098
+ this.insertJournalRow(record.id, entry);
1099
+ }
1100
+ /**
1101
+ * The record row and every table describing it, for a caller already
1102
+ * inside a transaction. A duplicate id maps to StackConflictError.
1103
+ */
1104
+ insertRecord(record) {
1105
+ try {
1106
+ this.exec.run(`INSERT INTO records
1053
1107
  (id, type_id, created_at, updated_at, content, version,
1054
1108
  parent_id, entity_id, app_id, principal_id, updated_by, updated_via,
1055
1109
  deleted_at, unlisted_at)
1056
1110
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1057
- record.id,
1058
- record.typeId,
1059
- toMs(record.createdAt),
1060
- toMs(record.updatedAt),
1061
- JSON.stringify(record.content),
1062
- record.version,
1063
- record.parentId ?? null,
1064
- record.createdBy?.subjectId ?? null,
1065
- record.appId ?? null,
1066
- record.createdBy?.principalId ?? null,
1067
- record.updatedBy?.subjectId ?? null,
1068
- record.updatedBy?.principalId ?? null,
1069
- record.deletedAt ? toMs(record.deletedAt) : null,
1070
- record.unlistedAt ? toMs(record.unlistedAt) : null
1071
- ]);
1072
- } catch (err) {
1073
- if (isUniqueConstraintViolation(err)) {
1074
- throw new StackConflictError(`Record already exists: "${record.id}"`);
1075
- }
1076
- throw err;
1111
+ record.id,
1112
+ record.typeId,
1113
+ toMs(record.createdAt),
1114
+ toMs(record.updatedAt),
1115
+ JSON.stringify(record.content),
1116
+ record.version,
1117
+ record.parentId ?? null,
1118
+ record.createdBy?.subjectId ?? null,
1119
+ record.appId ?? null,
1120
+ record.createdBy?.principalId ?? null,
1121
+ record.updatedBy?.subjectId ?? null,
1122
+ record.updatedBy?.principalId ?? null,
1123
+ record.deletedAt ? toMs(record.deletedAt) : null,
1124
+ record.unlistedAt ? toMs(record.unlistedAt) : null
1125
+ ]);
1126
+ } catch (err) {
1127
+ if (isUniqueConstraintViolation(err)) {
1128
+ throw new StackConflictError(`Record already exists: "${record.id}"`);
1077
1129
  }
1078
- if (record.associations?.length)
1079
- this.insertAssociations(record.id, record.associations);
1080
- if (record.permissions?.length)
1081
- this.insertPermissions(record.id, record.permissions);
1082
- this.searchIndex.insert(record.id, JSON.stringify(record.content));
1083
- this.contentIndex.sync(record.id, record.typeId, record.content);
1084
- this.appendJournal(record.id, opts.journal);
1085
- });
1086
- return record;
1130
+ throw err;
1131
+ }
1132
+ if (record.associations?.length)
1133
+ this.insertAssociations(record.id, record.associations);
1134
+ if (record.permissions?.length)
1135
+ this.insertPermissions(record.id, record.permissions);
1136
+ this.searchIndex.insert(record.id, JSON.stringify(record.content));
1137
+ this.contentIndex.sync(record.id, record.typeId, record.content);
1087
1138
  }
1088
1139
  async getRecord(id) {
1089
1140
  return this.readRecord(id);
@@ -1308,7 +1359,7 @@ var SharedSqlRecordLogic = class {
1308
1359
  VALUES (?, ?, ${VERSION_COLUMNS.map(() => "?").join(", ")})`, [id, version.version, ...versionRowValues(version)]);
1309
1360
  } catch (err) {
1310
1361
  if (isUniqueConstraintViolation(err)) {
1311
- 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.`);
1362
+ throw new StackConflictError(`Version ${version.version} already exists for record "${id}"`);
1312
1363
  }
1313
1364
  throw err;
1314
1365
  }
@@ -1324,14 +1375,6 @@ var SharedSqlRecordLogic = class {
1324
1375
  SET ${VERSION_COLUMNS.map((c) => `${c} = ?`).join(", ")}
1325
1376
  WHERE record_id = ? AND version = ?`, [...versionRowValues(version), id, version.version]);
1326
1377
  }
1327
- /**
1328
- * Standalone snapshot write for tooling and tests — loud on any
1329
- * collision, since nothing here is about to bump the version. Mutating
1330
- * methods take a `snapshot` option instead; see snapshotBeforeMutation.
1331
- */
1332
- async saveVersion(id, version) {
1333
- this.insertVersionRow(id, version);
1334
- }
1335
1378
  /**
1336
1379
  * The snapshot half of a mutating method's atomic snapshot-then-mutate
1337
1380
  * transaction. A collision is a racing-writer conflict, except an orphan
@@ -1345,8 +1388,9 @@ var SharedSqlRecordLogic = class {
1345
1388
  } catch (err) {
1346
1389
  if (!(err instanceof StackConflictError))
1347
1390
  throw err;
1348
- if (this.currentVersion(id) !== version.version)
1349
- throw err;
1391
+ if (this.currentVersion(id) !== version.version) {
1392
+ 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.`);
1393
+ }
1350
1394
  this.overwriteVersionRow(id, version);
1351
1395
  }
1352
1396
  }
@@ -1355,9 +1399,9 @@ var SharedSqlRecordLogic = class {
1355
1399
  // -------------------------------------------------------
1356
1400
  /**
1357
1401
  * Append one entry, stamped from the row as it now stands, so callers run
1358
- * this inside their write's transaction, after the write. `seq` is
1359
- * allocated in the INSERT itself, so no caller ever holds one it expects
1360
- * to be free. See docs/spec/journal.md § Ordering.
1402
+ * this inside their write's transaction, after the write. `seq` is read
1403
+ * from the log's maximum in that same transaction, so no caller ever holds
1404
+ * one it expects to be free. See docs/spec/journal.md § Ordering.
1361
1405
  */
1362
1406
  appendJournal(recordId, entry) {
1363
1407
  if (!entry)
@@ -1365,26 +1409,20 @@ var SharedSqlRecordLogic = class {
1365
1409
  const row = this.exec.get("SELECT version, type_id, parent_id FROM records WHERE id = ?", [recordId]);
1366
1410
  if (!row)
1367
1411
  throw recordNotFound(recordId);
1368
- this.exec.run(`INSERT INTO journal
1369
- (record_id, seq, at, kind, ops, version, type_id, parent_id,
1370
- previous_parent_id, actor, associations)
1371
- VALUES (
1372
- ?,
1373
- (SELECT COALESCE(MAX(seq), 0) + 1 FROM journal WHERE record_id = ?),
1374
- ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1375
- recordId,
1376
- recordId,
1377
- toMs(/* @__PURE__ */ new Date()),
1378
- entry.kind,
1379
- JSON.stringify(entry.ops),
1380
- row.version,
1381
- row.type_id,
1382
- row.parent_id,
1383
- // '' is the root; NULL is an entry that names no origin at all.
1384
- entry.previousParentId === void 0 ? null : entry.previousParentId ?? "",
1385
- entry.actor ? JSON.stringify(entry.actor) : null,
1386
- entry.associations?.length ? JSON.stringify(entry.associations) : null
1387
- ]);
1412
+ const { seq } = this.exec.get("SELECT COALESCE(MAX(seq), 0) + 1 AS seq FROM journal WHERE record_id = ?", [recordId]);
1413
+ this.exec.run(INSERT_JOURNAL_SQL, journalValues(recordId, seq, /* @__PURE__ */ new Date(), entry, {
1414
+ version: row.version,
1415
+ typeId: row.type_id,
1416
+ parentId: row.parent_id
1417
+ }));
1418
+ }
1419
+ /** A whole entry as recorded, `seq` and `at` included — importRecord()'s half of the journal. */
1420
+ insertJournalRow(recordId, entry) {
1421
+ this.exec.run(INSERT_JOURNAL_SQL, journalValues(recordId, entry.seq, entry.at, entry, {
1422
+ version: entry.version,
1423
+ typeId: entry.typeId,
1424
+ parentId: entry.parentId ?? null
1425
+ }));
1388
1426
  }
1389
1427
  async getJournal(id, query = {}) {
1390
1428
  if (!this.exec.get("SELECT 1 FROM records WHERE id = ?", [id])) {
@@ -1549,7 +1587,51 @@ var ASSOCIATION_KEY = "record_id, kind, label, file_id, related_scope, related_i
1549
1587
  var PERMISSION_KEY = "record_id, kind, label, grantee_kind, grantee_id, role";
1550
1588
  var addsOf = (edits) => edits.flatMap((e) => e.op === "add" ? [e.association] : []);
1551
1589
 
1552
- // ../sqlite-shared/dist/record-adapter.js
1590
+ // ../sqlite-shared/dist/config.js
1591
+ var CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`;
1592
+ var missingStoreError = (where) => new Error(`Cannot open: no database found at ${where}. Pass create: 'ifMissing' or 'exclusive' to create one.`);
1593
+ var writeNewStore = (exec, seed) => {
1594
+ exec.transaction(() => {
1595
+ exec.exec(RECORD_SCHEMA_SQL);
1596
+ exec.exec(FTS5_SCHEMA_SQL);
1597
+ exec.run("INSERT INTO format (id, version) VALUES (1, ?)", [FORMAT_VERSION]);
1598
+ new SharedSqlRecordLogic({ exec }).writeImported(seed.config, seed.history);
1599
+ });
1600
+ return seed.config.content;
1601
+ };
1602
+ var readStoredConfig = (exec, where) => {
1603
+ const hasFormat = exec.get(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'format'`) !== void 0;
1604
+ if (!hasFormat)
1605
+ return null;
1606
+ const version = exec.get("SELECT version FROM format WHERE id = 1")?.version;
1607
+ if (version !== FORMAT_VERSION)
1608
+ throw new FormatMismatchError(FORMAT_VERSION, version, where);
1609
+ const { content } = exec.get(CONFIG_ROW_SQL);
1610
+ return JSON.parse(content);
1611
+ };
1612
+ var openStore = async (exec, opts) => {
1613
+ const stored = readStoredConfig(exec, opts.where);
1614
+ if (!stored && opts.mode === "never")
1615
+ throw missingStoreError(opts.where);
1616
+ if (stored && (opts.mode === "exclusive" || opts.seed)) {
1617
+ throw new Error(`Cannot create: database already exists at ${opts.where}. Pass create: 'never' or 'ifMissing' without a seed to open it.`);
1618
+ }
1619
+ applyConnectionPragmas(exec, { wal: opts.wal });
1620
+ if (stored) {
1621
+ const expected = typeof opts.ownerEntityId === "string" ? opts.ownerEntityId : void 0;
1622
+ if (expected !== void 0 && expected !== stored.ownerEntityId) {
1623
+ throw new OwnerMismatchError(expected, stored.ownerEntityId, opts.where);
1624
+ }
1625
+ return stored;
1626
+ }
1627
+ if (opts.seed)
1628
+ return writeNewStore(exec, opts.seed);
1629
+ const owner = typeof opts.ownerEntityId === "function" ? await opts.ownerEntityId() : opts.ownerEntityId;
1630
+ if (!owner) {
1631
+ throw new Error(`Cannot create: no ownerEntityId given for the new database at ${opts.where}.`);
1632
+ }
1633
+ return writeNewStore(exec, newStoreSeed({ ownerEntityId: owner }));
1634
+ };
1553
1635
  var SQLITE_RECORD_CAPABILITIES = {
1554
1636
  filter: {
1555
1637
  content: "path",
@@ -1570,12 +1652,17 @@ var SQLITE_RECORD_CAPABILITIES = {
1570
1652
  var SharedSqlRecordAdapter = class {
1571
1653
  capabilities = SQLITE_RECORD_CAPABILITIES;
1572
1654
  ownerEntityId;
1573
- timezone;
1655
+ /** Each engine holds its database for one process; see docs/spec/adapters.md § Concurrency & storage ownership. */
1656
+ writers = "owner";
1574
1657
  record;
1658
+ /** Moved by every write method; see head(). */
1659
+ written = trackWrites(this);
1575
1660
  constructor(exec, config) {
1576
1661
  this.record = new SharedSqlRecordLogic({ exec });
1577
- this.ownerEntityId = config.entityId;
1578
- this.timezone = config.timezone;
1662
+ this.ownerEntityId = config.ownerEntityId;
1663
+ }
1664
+ async head() {
1665
+ return this.written.read();
1579
1666
  }
1580
1667
  // -------------------------------------------------------
1581
1668
  // Records
@@ -1583,6 +1670,9 @@ var SharedSqlRecordAdapter = class {
1583
1670
  createRecord(record, opts) {
1584
1671
  return this.record.createRecord(record, opts);
1585
1672
  }
1673
+ importRecord(record, history) {
1674
+ return this.record.importRecord(record, history);
1675
+ }
1586
1676
  getRecord(id) {
1587
1677
  return this.record.getRecord(id);
1588
1678
  }
@@ -1625,9 +1715,6 @@ var SharedSqlRecordAdapter = class {
1625
1715
  getVersion(id, version) {
1626
1716
  return this.record.getVersion(id, version);
1627
1717
  }
1628
- saveVersion(id, version) {
1629
- return this.record.saveVersion(id, version);
1630
- }
1631
1718
  // -------------------------------------------------------
1632
1719
  // Change journal
1633
1720
  // -------------------------------------------------------
@@ -1699,9 +1786,9 @@ var DoSQLiteRecordAdapter = class _DoSQLiteRecordAdapter extends SharedSqlRecord
1699
1786
  /**
1700
1787
  * Open the adapter for a DO instance, creating its storage on first use.
1701
1788
  * A DO id either already has a config record (reattach) or it doesn't
1702
- * (first call — opts.ownerEntityId/timezone become the config), so there
1703
- * is no `create` option. Schema DDL is `CREATE TABLE IF NOT EXISTS`, so
1704
- * running it every call is idempotent and cheap.
1789
+ * (first call — opts.ownerEntityId becomes the config), so there
1790
+ * is no `create` option. A new store's tables are written in the same
1791
+ * transaction as its config record, so storage holds a whole store or none.
1705
1792
  *
1706
1793
  * DO SQLite manages its own durability and rejects PRAGMA journal_mode
1707
1794
  * outright ("not authorized") — verified against the real runtime, not
@@ -1709,20 +1796,12 @@ var DoSQLiteRecordAdapter = class _DoSQLiteRecordAdapter extends SharedSqlRecord
1709
1796
  */
1710
1797
  static async open(storage, opts) {
1711
1798
  const exec = new DurableObjectSqliteExecutor(storage);
1712
- applyRecordSchema(exec);
1713
- let config = tryReadStackConfig(exec);
1714
- if (config) {
1715
- if (typeof opts.ownerEntityId === "string" && opts.ownerEntityId !== config.entityId) {
1716
- throw new OwnerMismatchError(
1717
- opts.ownerEntityId,
1718
- config.entityId,
1719
- "the Durable Object storage"
1720
- );
1721
- }
1722
- } else {
1723
- const ownerEntityId = typeof opts.ownerEntityId === "function" ? await opts.ownerEntityId() : opts.ownerEntityId;
1724
- config = insertConfigRecord(exec, ownerEntityId, opts.timezone);
1725
- }
1799
+ const config = await openStore(exec, {
1800
+ ...opts,
1801
+ mode: "ifMissing",
1802
+ where: "the Durable Object storage",
1803
+ wal: false
1804
+ });
1726
1805
  return new _DoSQLiteRecordAdapter(exec, config);
1727
1806
  }
1728
1807
  // -------------------------------------------------------