@haverstack/record-adapter-sqlite 0.32.0 → 0.34.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, writeFileSync, unlinkSync, readFileSync } from 'fs';
2
- import { StackNotFoundError, StackConflictError, applyMergePatch, assertOneSurface, StackVersionConflictError, StackBadRequestError, NATIVE_SORT_FIELDS } from '@haverstack/core';
3
- import { OwnerMismatchError, contentSortEntry, parseContentFilterKey, contentSortKey } from '@haverstack/core/adapter';
2
+ import { StackConflictError, applyMergePatch, StackNotFoundError, assertOneSurface, StackVersionConflictError, StackBadRequestError, NATIVE_SORT_FIELDS } from '@haverstack/core';
3
+ import { OwnerMismatchError, AUTHORITY_ASSOCIATION_KINDS, contentSortEntry, partitionAssociations, constantSortEntry, contentSortKey, parseContentFilterKey } from '@haverstack/core/adapter';
4
4
  import { randomBytes, createHash } from 'crypto';
5
5
 
6
6
  // src/node-sqlite.ts
@@ -28,39 +28,32 @@ var RECORD_SCHEMA_SQL = `
28
28
  ) STRICT;
29
29
 
30
30
  -- Every edge a record carries, authority and data alike: a permission
31
- -- entry has element identity and a delta the same shape as a tag's, so
32
- -- it earns the same table and the same durability tier. The permissions
33
- -- and associations fields are projections over it, partitioned by
34
- -- kind. See docs/spec/access-control.md \xA7 Record-level permissions.
35
- --
36
- -- A relationship's target is (related_scope, related_id) plus one
37
- -- qualifier: related_stack for a record in another stack, related_ns for
38
- -- a foreign namespace. All four are in the primary key, so two targets
39
- -- differing only by namespace are two associations. A permission's
40
- -- grantee reuses them \u2014 related_scope 'entity' or 'group', related_id the
41
- -- DID or group id \u2014 plus related_role, which only a group grantee sets
42
- -- and which is in the key because member and admin name two different
43
- -- sets of people.
44
- --
45
- -- attachment_record_id stays out of the primary key: it annotates a
46
- -- reference rather than naming one, so re-pointing it updates the row an
47
- -- an amendAssociations() add lands on instead of writing a second reference to the same
48
- -- file. See docs/spec/attachments.md \xA7 Naming the upload a reference came from.
49
- --
50
- -- The primary key leads with record_id, so its implicit index already
51
- -- serves every by-record read and delete; the indexes below exist only
52
- -- for the filter directions it cannot answer.
31
+ -- has element identity and a delta shaped like a tag's, so it earns the
32
+ -- same table. The permissions and associations fields are projections
33
+ -- over it, partitioned by kind.
34
+ -- See docs/spec/access-control.md \xA7 Record-level permissions.
53
35
  CREATE TABLE IF NOT EXISTS associations (
54
36
  record_id TEXT NOT NULL REFERENCES records(id),
55
37
  kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship', 'permission', 'anyone')),
56
38
  label TEXT NOT NULL,
57
39
  file_id TEXT NOT NULL DEFAULT '',
40
+ -- A relationship target is (related_scope, related_id) qualified by
41
+ -- related_ns (a foreign namespace) or related_stack (another stack); a
42
+ -- permission's grantee reuses scope and id. All are in the key, so
43
+ -- targets differing only by a qualifier are distinct associations.
58
44
  related_scope TEXT NOT NULL DEFAULT '' CHECK (related_scope IN ('', 'record', 'entity', 'external', 'group')),
59
45
  related_id TEXT NOT NULL DEFAULT '',
60
46
  related_ns TEXT NOT NULL DEFAULT '',
61
47
  related_stack TEXT NOT NULL DEFAULT '',
48
+ -- Set only by a group grantee; in the key because member and admin name
49
+ -- two different sets of people.
62
50
  related_role TEXT NOT NULL DEFAULT '' CHECK (related_role IN ('', 'member', 'admin')),
51
+ -- Out of the key: it annotates a reference rather than naming one, so
52
+ -- re-pointing it updates the existing row. See docs/spec/attachments.md
53
+ -- \xA7 Naming the upload a reference came from.
63
54
  attachment_record_id TEXT NOT NULL DEFAULT '',
55
+ -- Leads with record_id, so its implicit index serves every by-record
56
+ -- read and delete; the indexes below cover only the filter directions.
64
57
  PRIMARY KEY (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack, related_role)
65
58
  ) STRICT;
66
59
 
@@ -123,7 +116,6 @@ var RECORD_SCHEMA_SQL = `
123
116
 
124
117
  -- One row per top-level scalar content field a record holds a value at:
125
118
  -- what it orders as, and the file it references. See
126
- -- docs/spec/adapters.md \xA7 Adapter backends and
127
119
  -- docs/spec/data-model.md \xA7 Sorting by a content field.
128
120
  --
129
121
  -- value_rank is 0 for a numeric value and 1 for a text one \u2014 the same
@@ -140,14 +132,11 @@ var RECORD_SCHEMA_SQL = `
140
132
  PRIMARY KEY (record_id, field)
141
133
  ) STRICT;
142
134
 
143
- -- Indexes. Each filter column continues into (created_at, id) \u2014 the
144
- -- default ordering and its tiebreak \u2014 because these columns are
145
- -- low-cardinality by design, and a narrow index on one leaves the
146
- -- planner sorting the whole matching set before it can honor LIMIT.
147
- --
148
- -- deleted_at and unlisted_at deliberately have none: nothing searches
149
- -- for the rows they mark, and an index on a column every query tests
150
- -- for IS NULL only lures the planner off the one answering ORDER BY.
135
+ -- Each filter column continues into (created_at, id), the default order
136
+ -- and its tiebreak: these columns are low-cardinality, and a narrow index
137
+ -- leaves the planner sorting every match before it can honor LIMIT.
138
+ -- deleted_at and unlisted_at get none: an index on a column every query
139
+ -- tests IS NULL only lures the planner off the one answering ORDER BY.
151
140
  CREATE INDEX IF NOT EXISTS idx_records_type_id ON records(type_id, created_at, id);
152
141
  CREATE INDEX IF NOT EXISTS idx_records_parent_id ON records(parent_id, created_at, id);
153
142
  CREATE INDEX IF NOT EXISTS idx_records_entity_id ON records(entity_id, created_at, id);
@@ -201,71 +190,6 @@ var applyRecordSchema = (exec, opts) => {
201
190
  exec.exec(RECORD_SCHEMA_SQL);
202
191
  exec.exec(FTS5_SCHEMA_SQL);
203
192
  };
204
- var SORT_FIELDS = NATIVE_SORT_FIELDS;
205
- var toBase64 = (text) => {
206
- const bytes = new TextEncoder().encode(text);
207
- let binary = "";
208
- for (const byte of bytes)
209
- binary += String.fromCharCode(byte);
210
- return btoa(binary);
211
- };
212
- var fromBase64 = (cursor) => {
213
- const binary = atob(cursor);
214
- const bytes = new Uint8Array(binary.length);
215
- for (let i = 0; i < binary.length; i++)
216
- bytes[i] = binary.charCodeAt(i);
217
- return new TextDecoder().decode(bytes);
218
- };
219
- var encodeCursor = (decoded) => toBase64(JSON.stringify(decoded.kind === "absent" ? { k: decoded.kind, f: decoded.field, i: decoded.id } : { k: decoded.kind, f: decoded.field, i: decoded.id, v: decoded.value }));
220
- var decodeCursor = (cursor) => {
221
- const malformed = () => new StackBadRequestError(`Invalid cursor: malformed "${cursor}"`);
222
- let parsed;
223
- try {
224
- parsed = JSON.parse(fromBase64(cursor));
225
- } catch {
226
- throw malformed();
227
- }
228
- if (!parsed || typeof parsed !== "object")
229
- throw malformed();
230
- const { k, f, i, v } = parsed;
231
- if (typeof f !== "string" || !f || typeof i !== "string")
232
- throw malformed();
233
- if (k === "absent")
234
- return { kind: "absent", field: f, id: i };
235
- if (k === "text") {
236
- if (typeof v !== "string")
237
- throw malformed();
238
- return { kind: "text", field: f, value: v, id: i };
239
- }
240
- if (k === "num" || k === "native") {
241
- if (typeof v !== "number" || !isFinite(v)) {
242
- throw new StackBadRequestError(`Invalid cursor: non-numeric sort value`);
243
- }
244
- if (k === "native") {
245
- if (!SORT_FIELDS.includes(f)) {
246
- throw new StackBadRequestError(`Invalid cursor: unknown sort field "${f}"`);
247
- }
248
- return { kind: "native", field: f, value: v, id: i };
249
- }
250
- return { kind: "num", field: f, value: v, id: i };
251
- }
252
- throw malformed();
253
- };
254
- var getSortField = (query) => query.sort?.field ?? "createdAt";
255
- var getSortColumn = (field) => field === "createdAt" ? "created_at" : field === "updatedAt" ? "updated_at" : "version";
256
- var makeCursor = (record, sort, kindOf) => {
257
- const contentField = sort?.contentField;
258
- if (contentField === void 0) {
259
- const field = getSortField({ sort });
260
- const value = field === "updatedAt" ? record.updatedAt.getTime() : field === "version" ? record.version : record.createdAt.getTime();
261
- return encodeCursor({ kind: "native", field, value, id: record.id });
262
- }
263
- const kind = kindOf?.(record, contentField);
264
- const entry = kind ? contentSortEntry(kind, record.content[contentField]) : null;
265
- if (!entry)
266
- return encodeCursor({ kind: "absent", field: contentField, id: record.id });
267
- return entry.kind === "num" ? encodeCursor({ kind: "num", field: contentField, value: entry.num, id: record.id }) : encodeCursor({ kind: "text", field: contentField, value: entry.text, id: record.id });
268
- };
269
193
 
270
194
  // ../sqlite-shared/dist/fts5.js
271
195
  var OUTSIDE_PHRASE_DISALLOWED = /[^\p{L}\p{N}\p{M}_\s()"]/gu;
@@ -279,7 +203,7 @@ var sanitizeFts5Query = (query, maxDepth = 2) => {
279
203
  clean = clean.replace(/\bNEAR\s*\(/gi, "(");
280
204
  clean = clean.replace(/"[^"]*"|[^"]+/g, (span) => span.startsWith('"') ? span : span.replace(OUTSIDE_PHRASE_DISALLOWED, " "));
281
205
  clean = collapseWhitespace(clean);
282
- clean = clean.replace(/(?:^|\(\s*)NOT\s+/gi, (m) => m.replace(/NOT\s+/i, ""));
206
+ clean = clean.replace(/"[^"]*"?|(^|\(\s*)NOT\s+/gi, (m, lead) => lead ?? m);
283
207
  let currentDepth = 0;
284
208
  let inPhrase = false;
285
209
  let result = "";
@@ -346,27 +270,107 @@ var restoreImplicitAnd = (query) => withPhrasesMasked(query, (masked) => masked.
346
270
  const next = /^\s*([\p{L}\p{N}_]+)/u.exec(whole.slice(offset + match.length));
347
271
  return next && isOperator(next[1]) ? match : ") AND ";
348
272
  }).replace(new RegExp(String.raw`(?<!${OPERAND_CHAR})(${OPERAND_CHAR}+)\s*(?=\()`, "gu"), (match, word) => isOperator(word) ? match : `${word} AND `));
349
- var fts5Strategy = {
350
- /** Index a record that has no FTS entry yet (used right after an INSERT). */
351
- insert(exec, recordId, content) {
352
- exec.run(`INSERT INTO records_fts(rowid, content) SELECT rowid, ? FROM records WHERE id = ?`, [
353
- content,
354
- recordId
355
- ]);
356
- },
357
- /**
358
- * Remove a record's FTS entry. MUST be called before the records row's
359
- * content changes or the row is deleted — see the note above.
360
- */
361
- remove(exec, recordId) {
362
- const row = exec.get("SELECT rowid, content FROM records WHERE id = ?", [recordId]);
363
- if (!row)
364
- return;
365
- exec.run(`INSERT INTO records_fts(records_fts, rowid, content) VALUES('delete', ?, ?)`, [
366
- row.rowid,
367
- row.content
368
- ]);
273
+
274
+ // ../sqlite-shared/dist/executor.js
275
+ var isForeignKeyViolation = (err) => err instanceof Error && err.message.includes("FOREIGN KEY constraint failed");
276
+ var isUniqueConstraintViolation = (err) => err instanceof Error && err.message.includes("UNIQUE constraint failed");
277
+
278
+ // ../sqlite-shared/dist/config.js
279
+ var CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`;
280
+ var insertConfigRecord = (exec, entityId, timezone) => {
281
+ const now = Date.now();
282
+ exec.run(`INSERT INTO records (id, type_id, created_at, updated_at, content, version)
283
+ VALUES ('_config', '_config@1', ?, ?, ?, 1)`, [now, now, JSON.stringify({ entityId, timezone })]);
284
+ return { entityId, timezone };
285
+ };
286
+ var tryReadStackConfig = (exec) => {
287
+ const row = exec.get(CONFIG_ROW_SQL);
288
+ if (!row)
289
+ return null;
290
+ const content = JSON.parse(row.content);
291
+ return { entityId: content.entityId, timezone: content.timezone };
292
+ };
293
+ var readStackConfig = (exec) => {
294
+ const config = tryReadStackConfig(exec);
295
+ if (!config)
296
+ throw new Error("Stack database is missing its config record.");
297
+ return config;
298
+ };
299
+ var toBase64 = (text) => {
300
+ const bytes = new TextEncoder().encode(text);
301
+ let binary = "";
302
+ for (const byte of bytes)
303
+ binary += String.fromCharCode(byte);
304
+ return btoa(binary);
305
+ };
306
+ var fromBase64 = (cursor) => {
307
+ const binary = atob(cursor);
308
+ const bytes = new Uint8Array(binary.length);
309
+ for (let i = 0; i < binary.length; i++)
310
+ bytes[i] = binary.charCodeAt(i);
311
+ return new TextDecoder().decode(bytes);
312
+ };
313
+ var encodeCursor = (decoded) => toBase64(JSON.stringify(decoded.kind === "absent" ? { k: decoded.kind, f: decoded.field, i: decoded.id } : { k: decoded.kind, f: decoded.field, i: decoded.id, v: decoded.value }));
314
+ var decodeCursor = (cursor) => {
315
+ const malformed = () => new StackBadRequestError(`Invalid cursor: malformed "${cursor}"`);
316
+ let parsed;
317
+ try {
318
+ parsed = JSON.parse(fromBase64(cursor));
319
+ } catch {
320
+ throw malformed();
369
321
  }
322
+ if (!parsed || typeof parsed !== "object")
323
+ throw malformed();
324
+ const { k, f, i, v } = parsed;
325
+ if (typeof f !== "string" || !f || typeof i !== "string")
326
+ throw malformed();
327
+ if (k === "absent")
328
+ return { kind: "absent", field: f, id: i };
329
+ if (k === "text") {
330
+ if (typeof v !== "string")
331
+ throw malformed();
332
+ return { kind: "text", field: f, value: v, id: i };
333
+ }
334
+ if (k === "num" || k === "native") {
335
+ if (typeof v !== "number" || !isFinite(v)) {
336
+ throw new StackBadRequestError(`Invalid cursor: non-numeric sort value`);
337
+ }
338
+ if (k === "native") {
339
+ if (!NATIVE_SORT_FIELDS.includes(f)) {
340
+ throw new StackBadRequestError(`Invalid cursor: unknown sort field "${f}"`);
341
+ }
342
+ return { kind: "native", field: f, value: v, id: i };
343
+ }
344
+ return { kind: "num", field: f, value: v, id: i };
345
+ }
346
+ throw malformed();
347
+ };
348
+ var byTypeDescriptor = (byType) => JSON.stringify(Object.entries(byType).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
349
+ var getSortField = (query) => query.sort?.field ?? "createdAt";
350
+ var getSortColumn = (field) => field === "createdAt" ? "created_at" : field === "updatedAt" ? "updated_at" : "version";
351
+ var makeCursor = (record, sort, kindOf) => {
352
+ const byType = sort?.contentFieldByType;
353
+ if (byType) {
354
+ const field = byTypeDescriptor(byType);
355
+ const key = Object.hasOwn(byType, record.typeId) ? byType[record.typeId] : void 0;
356
+ const kind2 = key?.field === void 0 ? void 0 : kindOf?.(record, key.field);
357
+ const entry2 = key?.field !== void 0 ? kind2 && contentSortEntry(kind2, record.content[key.field]) : key && constantSortEntry(key.value ?? null);
358
+ return encodeEntry(field, entry2 || null, record.id);
359
+ }
360
+ const contentField = sort?.contentField;
361
+ if (contentField === void 0) {
362
+ const field = getSortField({ sort });
363
+ const value = field === "updatedAt" ? record.updatedAt.getTime() : field === "version" ? record.version : record.createdAt.getTime();
364
+ return encodeCursor({ kind: "native", field, value, id: record.id });
365
+ }
366
+ const kind = kindOf?.(record, contentField);
367
+ const entry = kind ? contentSortEntry(kind, record.content[contentField]) : null;
368
+ return encodeEntry(contentField, entry, record.id);
369
+ };
370
+ var encodeEntry = (field, entry, id) => {
371
+ if (!entry)
372
+ return encodeCursor({ kind: "absent", field, id });
373
+ return entry.kind === "num" ? encodeCursor({ kind: "num", field, value: entry.num, id }) : encodeCursor({ kind: "text", field, value: entry.text, id });
370
374
  };
371
375
 
372
376
  // ../sqlite-shared/dist/query.js
@@ -413,10 +417,31 @@ var contentCursor = (cursor, field) => {
413
417
  throw cursorMismatch(cursor, field);
414
418
  return cursor;
415
419
  };
420
+ var contentConditions = (predicate, params, nextAlias) => {
421
+ const conditions = [];
422
+ for (const [key, value] of Object.entries(predicate.content ?? {})) {
423
+ const segments = parseContentFilterKey(key);
424
+ if (value === null || value === void 0) {
425
+ const isNull = contentPathExists(segments, (a) => `${a}.value IS NULL`, params, nextAlias);
426
+ const anyValue = contentPathExists(segments, (a) => `${a}.value IS NOT NULL`, params, nextAlias);
427
+ conditions.push(`(${isNull} OR NOT ${anyValue})`);
428
+ } else {
429
+ const match = contentPathExists(segments, equalsLeaf, params, nextAlias);
430
+ params.push(value);
431
+ conditions.push(match);
432
+ }
433
+ }
434
+ for (const key of predicate.contentPresent ?? []) {
435
+ conditions.push(contentPathExists(parseContentFilterKey(key), (a) => `${a}.value IS NOT NULL`, params, nextAlias));
436
+ }
437
+ return conditions;
438
+ };
416
439
  var recordConditions = (query) => {
417
440
  const conditions = ["r.id != '_config'"];
418
441
  const params = [];
419
442
  const f = query.filter ?? {};
443
+ let aliasSeq = 0;
444
+ const nextContentAlias = () => `cp${aliasSeq++}`;
420
445
  const inFilter = (column, value) => {
421
446
  if (value === void 0)
422
447
  return;
@@ -505,28 +530,15 @@ var recordConditions = (query) => {
505
530
  }
506
531
  conditions.push(hasAssociation("relationship", clauses));
507
532
  }
508
- if (f.content) {
509
- let aliasSeq = 0;
510
- const nextAlias = () => `cp${aliasSeq++}`;
511
- for (const [key, value] of Object.entries(f.content)) {
512
- const segments = parseContentFilterKey(key);
513
- if (value === null || value === void 0) {
514
- const isNull = contentPathExists(segments, (a) => `${a}.value IS NULL`, params, nextAlias);
515
- const anyValue = contentPathExists(segments, (a) => `${a}.value IS NOT NULL`, params, nextAlias);
516
- conditions.push(`(${isNull} OR NOT ${anyValue})`);
517
- } else {
518
- const match = contentPathExists(segments, equalsLeaf, params, nextAlias);
519
- params.push(value);
520
- conditions.push(match);
521
- }
522
- }
533
+ if (f.content || f.contentPresent?.length) {
534
+ conditions.push(...contentConditions(f, params, nextContentAlias));
523
535
  }
524
- if (f.contentPresent?.length) {
525
- let aliasSeq = 0;
526
- const nextAlias = () => `pp${aliasSeq++}`;
527
- for (const key of f.contentPresent) {
528
- conditions.push(contentPathExists(parseContentFilterKey(key), (a) => `${a}.value IS NOT NULL`, params, nextAlias));
529
- }
536
+ if (f.contentByType) {
537
+ const arms = Object.entries(f.contentByType).map(([typeId, arm]) => {
538
+ params.push(typeId);
539
+ return ["r.type_id = ?", ...contentConditions(arm, params, nextContentAlias)].join(" AND ");
540
+ });
541
+ conditions.push(`(${arms.map((arm) => `(${arm})`).join(" OR ")})`);
530
542
  }
531
543
  if (f.search) {
532
544
  const sanitized = sanitizeFts5Query(f.search);
@@ -556,12 +568,16 @@ var buildQueryPlan = (query) => {
556
568
  const dir = sqlDirection(query);
557
569
  const op = dir === "ASC" ? ">" : "<";
558
570
  const decoded = query.cursor ? decodeCursor(query.cursor) : null;
571
+ if (query.sort?.contentFieldByType) {
572
+ return byTypePlan(query, query.sort.contentFieldByType, dir, op, decoded);
573
+ }
559
574
  const contentField = query.sort?.contentField;
560
575
  if (contentField === void 0) {
561
576
  const { conditions, params } = recordConditions(query);
562
- const col = getSortColumn(getSortField(query));
577
+ const field = getSortField(query);
578
+ const col = getSortColumn(field);
563
579
  if (decoded) {
564
- const cursor2 = nativeCursor(decoded, getSortField(query));
580
+ const cursor2 = nativeCursor(decoded, field);
565
581
  conditions.push(`(r.${col} ${op} ? OR (r.${col} = ? AND r.id ${op} ?))`);
566
582
  params.push(cursor2.value, cursor2.value, cursor2.id);
567
583
  }
@@ -593,18 +609,154 @@ var buildQueryPlan = (query) => {
593
609
  }
594
610
  return pages;
595
611
  };
596
-
597
- // ../sqlite-shared/dist/mappers.js
612
+ var byTypePlan = (query, byType, dir, op, decoded) => {
613
+ const cursor = decoded ? contentCursor(decoded, byTypeDescriptor(byType)) : null;
614
+ const fields = [];
615
+ const constants = [];
616
+ for (const [typeId, key] of Object.entries(byType)) {
617
+ if (key.field !== void 0) {
618
+ fields.push([typeId, key.field]);
619
+ continue;
620
+ }
621
+ const entry = constantSortEntry(key.value ?? null);
622
+ if (entry)
623
+ constants.push([typeId, entry]);
624
+ }
625
+ const pages = [];
626
+ const fieldOf = (alias, record) => fields.map(() => `(${record}.type_id = ? AND ${alias}.field = ?)`).join(" OR ");
627
+ if (cursor?.kind !== "absent" && fields.length + constants.length > 0) {
628
+ const sources = [];
629
+ const sourceParams = [];
630
+ if (fields.length > 0) {
631
+ sources.push(`SELECT c.record_id AS record_id, c.value_rank AS value_rank, c.num_value AS num_value, c.text_key AS text_key, c.text_value AS text_value FROM content_index c JOIN records t ON t.id = c.record_id WHERE ${fieldOf("c", "t")}`);
632
+ for (const [typeId, field] of fields)
633
+ sourceParams.push(typeId, field);
634
+ }
635
+ for (const [typeId, entry] of constants) {
636
+ sources.push("SELECT t.id AS record_id, ? AS value_rank, ? AS num_value, ? AS text_key, ? AS text_value FROM records t WHERE t.type_id = ?");
637
+ sourceParams.push(...entry.kind === "num" ? [0, entry.num, null, null] : [1, null, entry.key, entry.text], typeId);
638
+ }
639
+ const { conditions, params } = recordConditions(query);
640
+ if (cursor)
641
+ conditions.push(presentCursorCondition(cursor, op, params));
642
+ const from = `(${sources.join(" UNION ALL ")}) ${SORT_ALIAS} JOIN records r ON r.id = ${SORT_ALIAS}.record_id`;
643
+ const order = `${SORT_ALIAS}.value_rank ${dir}, ${SORT_ALIAS}.num_value ${dir}, ${SORT_ALIAS}.text_key ${dir}, ${SORT_ALIAS}.text_value ${dir}, ${SORT_ALIAS}.record_id ${dir}`;
644
+ pages.push(statement("r.*", from, conditions, [...sourceParams, ...params], ` ORDER BY ${order} LIMIT ?`));
645
+ }
646
+ {
647
+ const { conditions, params } = recordConditions(query);
648
+ if (fields.length > 0) {
649
+ conditions.push(`NOT EXISTS (SELECT 1 FROM content_index ${SORT_ALIAS} WHERE ${SORT_ALIAS}.record_id = r.id AND (${fieldOf(SORT_ALIAS, "r")}))`);
650
+ for (const [typeId, field] of fields)
651
+ params.push(typeId, field);
652
+ }
653
+ if (constants.length > 0) {
654
+ conditions.push(`r.type_id NOT IN (${constants.map(() => "?").join(",")})`);
655
+ params.push(...constants.map(([typeId]) => typeId));
656
+ }
657
+ if (cursor?.kind === "absent") {
658
+ conditions.push(`r.id ${op} ?`);
659
+ params.push(cursor.id);
660
+ }
661
+ pages.push(statement("r.*", "records r", conditions, params, ` ORDER BY r.id ${dir} LIMIT ?`));
662
+ }
663
+ return pages;
664
+ };
665
+ var SearchIndex = class {
666
+ exec;
667
+ constructor(exec) {
668
+ this.exec = exec;
669
+ }
670
+ /** Index a record that has no FTS entry yet (used right after an INSERT). */
671
+ insert(recordId, content) {
672
+ this.exec.run(`INSERT INTO records_fts(rowid, content) SELECT rowid, ? FROM records WHERE id = ?`, [content, recordId]);
673
+ }
674
+ /**
675
+ * Remove a record's FTS entry. MUST be called before the records row's
676
+ * content changes or the row is deleted — see the note above.
677
+ */
678
+ remove(recordId) {
679
+ const row = this.exec.get("SELECT rowid, content FROM records WHERE id = ?", [recordId]);
680
+ if (!row)
681
+ return;
682
+ this.exec.run(`INSERT INTO records_fts(records_fts, rowid, content) VALUES('delete', ?, ?)`, [
683
+ row.rowid,
684
+ row.content
685
+ ]);
686
+ }
687
+ };
688
+ var indexedFieldKinds = (schema) => {
689
+ const kinds = /* @__PURE__ */ new Map();
690
+ for (const [field, def] of Object.entries(schema)) {
691
+ if (!def.kind || def.kind === "array" || def.kind === "object")
692
+ continue;
693
+ kinds.set(field, def.kind);
694
+ }
695
+ return kinds;
696
+ };
697
+ var ContentIndex = class {
698
+ exec;
699
+ fieldsByType = /* @__PURE__ */ new Map();
700
+ constructor(exec) {
701
+ this.exec = exec;
702
+ }
703
+ /** Prime the cache from a type as it is saved. */
704
+ rememberType(type) {
705
+ this.fieldsByType.set(type.id, indexedFieldKinds(type.schema));
706
+ }
707
+ /**
708
+ * Replace a record's rows with what its content currently holds, on every
709
+ * write that can change content or typeId, so neither the ordering nor the
710
+ * file-reference lookup can drift from the record. A field holding no
711
+ * orderable value gets no row at all, which is what puts the record at the
712
+ * end of a sort on that field.
713
+ */
714
+ sync(recordId, typeId, content) {
715
+ this.remove(recordId);
716
+ for (const [field, kind] of this.fieldsOf(typeId)) {
717
+ const entry = contentSortEntry(kind, content[field]);
718
+ if (!entry)
719
+ continue;
720
+ const sql = `INSERT OR IGNORE INTO content_index
721
+ (record_id, field, value_rank, num_value, text_key, text_value, file_id)
722
+ VALUES (?, ?, ?, ?, ?, ?, ?)`;
723
+ if (entry.kind === "num") {
724
+ this.exec.run(sql, [recordId, field, 0, entry.num, null, null, null]);
725
+ } else {
726
+ const fileId = kind === "file-ref" ? entry.text : null;
727
+ this.exec.run(sql, [recordId, field, 1, null, entry.key, entry.text, fileId]);
728
+ }
729
+ }
730
+ }
731
+ remove(recordId) {
732
+ this.exec.run("DELETE FROM content_index WHERE record_id = ?", [recordId]);
733
+ }
734
+ /** The declared kind of a type's top-level `field`, if it has one. */
735
+ kindOf(typeId, field) {
736
+ return this.fieldsOf(typeId).get(field);
737
+ }
738
+ /**
739
+ * From the cache, falling back to a `types` lookup only for a typeId this
740
+ * process hasn't seen via rememberType() yet, e.g. right after open().
741
+ */
742
+ fieldsOf(typeId) {
743
+ const cached = this.fieldsByType.get(typeId);
744
+ if (cached)
745
+ return cached;
746
+ const typeRow = this.exec.get("SELECT schema FROM types WHERE id = ?", [
747
+ typeId
748
+ ]);
749
+ const fields = typeRow ? indexedFieldKinds(JSON.parse(typeRow.schema)) : /* @__PURE__ */ new Map();
750
+ this.fieldsByType.set(typeId, fields);
751
+ return fields;
752
+ }
753
+ };
598
754
  var toMs = (d) => d.getTime();
599
755
  var actorFromColumns = (subject, principal) => subject == null ? void 0 : {
600
756
  subjectId: subject,
601
757
  ...principal != null && { principalId: principal }
602
758
  };
603
759
  var fromMs = (ms) => new Date(ms);
604
- var partitionAssociations = (associations) => ({
605
- associations: associations.filter((a) => a.kind !== "permission" && a.kind !== "anyone"),
606
- permissions: associations.filter((a) => a.kind === "permission" || a.kind === "anyone")
607
- });
608
760
  var rowToRecord = (row, associations) => {
609
761
  const record = {
610
762
  id: row.id,
@@ -748,31 +900,7 @@ var rowToJournalEntry = (row) => {
748
900
  return e;
749
901
  };
750
902
 
751
- // ../sqlite-shared/dist/executor.js
752
- var isForeignKeyViolation = (err) => err instanceof Error && err.message.includes("FOREIGN KEY constraint failed");
753
- var isUniqueConstraintViolation = (err) => err instanceof Error && err.message.includes("UNIQUE constraint failed");
754
-
755
- // ../sqlite-shared/dist/config.js
756
- var CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`;
757
- var insertConfigRecord = (exec, entityId, timezone) => {
758
- const now = Date.now();
759
- exec.run(`INSERT INTO records (id, type_id, created_at, updated_at, content, version)
760
- VALUES ('_config', '_config@1', ?, ?, ?, 1)`, [now, now, JSON.stringify({ entityId, timezone })]);
761
- return { entityId, timezone };
762
- };
763
- var tryReadStackConfig = (exec) => {
764
- const row = exec.get(CONFIG_ROW_SQL);
765
- if (!row)
766
- return null;
767
- const content = JSON.parse(row.content);
768
- return { entityId: content.entityId, timezone: content.timezone };
769
- };
770
- var readStackConfig = (exec) => {
771
- const config = tryReadStackConfig(exec);
772
- if (!config)
773
- throw new Error("Stack database is missing its config record.");
774
- return config;
775
- };
903
+ // ../sqlite-shared/dist/record-logic.js
776
904
  var asStackBadRequestError = (query, run) => {
777
905
  try {
778
906
  return run();
@@ -783,6 +911,7 @@ var asStackBadRequestError = (query, run) => {
783
911
  }
784
912
  };
785
913
  var versionConflict = (id, expected, actual) => new StackVersionConflictError(`Record "${id}" is at version ${actual}, expected ${expected}`, id, expected, actual);
914
+ var recordNotFound = (id) => new StackNotFoundError(`Record not found: "${id}"`);
786
915
  var VERSION_COLUMNS = [
787
916
  "type_id",
788
917
  "content",
@@ -801,44 +930,20 @@ var versionRowValues = (version) => [
801
930
  version.updatedBy?.subjectId ?? null,
802
931
  version.updatedBy?.principalId ?? null
803
932
  ];
804
- var indexedFieldKinds = (schema) => {
805
- const kinds = /* @__PURE__ */ new Map();
806
- for (const [field, def] of Object.entries(schema)) {
807
- if (!def.kind || def.kind === "array" || def.kind === "object")
808
- continue;
809
- kinds.set(field, def.kind);
810
- }
811
- return kinds;
812
- };
813
- var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
814
- deps;
815
- /**
816
- * Per-typeId cache of the fields content_index covers and their declared
817
- * kinds, so syncContentIndex() doesn't hit the `types` table and
818
- * re-parse schema JSON on every write. Populated eagerly in saveType()
819
- * and lazily via getIndexedFields().
820
- */
821
- indexedFieldsByType = /* @__PURE__ */ new Map();
822
- constructor(deps) {
823
- this.deps = deps;
824
- }
825
- get exec() {
826
- return this.deps.exec;
827
- }
828
- /**
829
- * Precondition check for a mutation that has already read the record —
830
- * either because it can't fold the check into its own statement
831
- * (purgeRecord: children reference the row, so the DELETE can't
832
- * carry it), or because it has to settle the precondition *before*
833
- * fts5Strategy.remove() runs, which needs the records row still holding
834
- * the old content. In the latter case versionedUpdate() re-checks it
835
- * inside the statement, and that is the enforcement; this is what lets
836
- * the work before it be skipped.
837
- */
838
- checkExpectedVersion(record, expectedVersion) {
839
- if (expectedVersion === void 0 || record.version === expectedVersion)
933
+ var SharedSqlRecordLogic = class {
934
+ exec;
935
+ searchIndex;
936
+ contentIndex;
937
+ constructor({ exec }) {
938
+ this.exec = exec;
939
+ this.searchIndex = new SearchIndex(exec);
940
+ this.contentIndex = new ContentIndex(exec);
941
+ }
942
+ /** Check `ifVersion` against a version read inside the caller's transaction. */
943
+ checkExpectedVersion(id, actual, expected) {
944
+ if (expected === void 0 || actual === expected)
840
945
  return;
841
- throw versionConflict(record.id, expectedVersion, record.version);
946
+ throw versionConflict(id, expected, actual);
842
947
  }
843
948
  /**
844
949
  * Called after a version-gated statement affected zero rows.
@@ -847,23 +952,31 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
847
952
  * the actual current version for the caller to act on).
848
953
  */
849
954
  throwVersionConflict(id, expectedVersion) {
850
- const row = this.exec.get("SELECT version FROM records WHERE id = ?", [
851
- id
852
- ]);
955
+ const version = this.currentVersion(id);
956
+ if (version === void 0)
957
+ throw recordNotFound(id);
958
+ throw versionConflict(id, expectedVersion, version);
959
+ }
960
+ currentVersion(id) {
961
+ return this.exec.get("SELECT version FROM records WHERE id = ?", [id])?.version;
962
+ }
963
+ /**
964
+ * What a mutation builds on, read inside its transaction so nothing can
965
+ * land between the read and the write: a patch applied to an earlier read
966
+ * would overwrite whatever changed since. Throws for a missing record or
967
+ * a failed `ifVersion`.
968
+ */
969
+ readForWrite(id, ifVersion) {
970
+ const row = this.exec.get("SELECT version, type_id, content FROM records WHERE id = ?", [id]);
853
971
  if (!row)
854
- throw new StackNotFoundError(`Record not found: "${id}"`);
855
- throw versionConflict(id, expectedVersion, row.version);
972
+ throw recordNotFound(id);
973
+ this.checkExpectedVersion(id, row.version, ifVersion);
974
+ return { typeId: row.type_id, content: JSON.parse(row.content) };
856
975
  }
857
976
  /**
858
- * The one shape every mutating UPDATE in this class has: whatever columns
859
- * the caller is changing, then the version bump and the actor/timestamp
860
- * stamp that every mutation owes, gated on the opt-in `ifVersion`
861
- * precondition and failing loudly when nothing matched.
862
- *
863
- * The guard rides in the WHERE clause even where the caller has already
864
- * checked the version against a record it read: that read is outside this
865
- * statement, so a writer slipping in between the two is caught here
866
- * rather than silently overwritten.
977
+ * Every version-bumping UPDATE. The `ifVersion` guard rides in the WHERE
978
+ * clause, so a write that reads nothing first (a soft delete, an
979
+ * undelete) enforces it with no separate read.
867
980
  */
868
981
  versionedUpdate(id, sets, values, opts, now = toMs(/* @__PURE__ */ new Date())) {
869
982
  const guarded = opts.ifVersion !== void 0;
@@ -879,15 +992,9 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
879
992
  this.throwVersionConflict(id, opts.ifVersion);
880
993
  }
881
994
  /**
882
- * Read a record back after its own mutation committed, for the caller to
883
- * return. Its absence is not a "not found" for the caller to handle — the
884
- * mutation just succeeded against it — so it fails as the invariant
885
- * breach it would be.
886
- *
887
- * Synchronous, via readRecord() rather than getRecord(), so the read
888
- * cannot be separated from the commit by anything: an async caller
889
- * returning this still hands back a record read in the same tick its
890
- * transaction committed. See readRecord() for why that matters.
995
+ * Read a record back after its own mutation committed. Absence here is an
996
+ * invariant breach, not a "not found". Synchronous, so nothing can run
997
+ * between the commit and the read — see readRecord().
891
998
  */
892
999
  reread(id, verb) {
893
1000
  const updated = this.readRecord(id);
@@ -899,16 +1006,10 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
899
1006
  // Records
900
1007
  // -------------------------------------------------------
901
1008
  /**
902
- * A duplicate id hits the `records` PK — mapped to StackConflictError
903
- * instead of surfacing the raw engine exception, mirroring saveVersion's
904
- * collision mapping below.
905
- *
906
- * One transaction, because a record and the four tables that describe it
907
- * are one fact. A half-applied create is worse than a failed one: the
908
- * call raises, so the caller believes nothing landed, while the records
909
- * row survives to fail their retry on the PK — and the row it leaves is
910
- * invisible to `filter.search` and mis-ordered by `sort.contentField`
911
- * until something writes it again.
1009
+ * One transaction, because a record and the tables that describe it are
1010
+ * one fact: a half-applied create raises, so the caller believes nothing
1011
+ * landed, while the surviving row fails their retry on the PK and is
1012
+ * missing from both indexes. A duplicate id maps to StackConflictError.
912
1013
  */
913
1014
  async createRecord(record, opts = {}) {
914
1015
  this.exec.transaction(() => {
@@ -942,8 +1043,8 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
942
1043
  const edges = [...record.associations ?? [], ...record.permissions ?? []];
943
1044
  if (edges.length)
944
1045
  this.insertAssociations(record.id, edges);
945
- fts5Strategy.insert(this.exec, record.id, JSON.stringify(record.content));
946
- this.syncContentIndex(record.id, record.typeId, record.content);
1046
+ this.searchIndex.insert(record.id, JSON.stringify(record.content));
1047
+ this.contentIndex.sync(record.id, record.typeId, record.content);
947
1048
  this.appendJournal(record.id, opts.journal);
948
1049
  });
949
1050
  return record;
@@ -952,53 +1053,24 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
952
1053
  return this.readRecord(id);
953
1054
  }
954
1055
  /**
955
- * The synchronous read behind getRecord(). Callers inside exec.transaction()
956
- * use this one: an `await` inside that callback yields the microtask queue
957
- * mid-transaction, and the next operation to run would try to open one of
958
- * its own — transaction() requires a synchronous callback for exactly this
959
- * reason (see SqlExecutor.transaction).
960
- *
961
- * The same synchrony is what lets the post-commit reads below report the
962
- * version their own mutation produced: reread() calls this directly, so
963
- * nothing interleaves between the commit and the read even though the
964
- * method returning it is async. A backend that made these reads genuinely
965
- * asynchronous would open that window, and a concurrent mutation could be
966
- * reported under this one's verb.
1056
+ * The synchronous read behind getRecord(), for use inside
1057
+ * exec.transaction(), whose callback can't await. reread() relies on the
1058
+ * same synchrony: nothing interleaves between a commit and its read, so a
1059
+ * concurrent mutation is never reported under this one's verb.
967
1060
  */
968
1061
  readRecord(id) {
969
1062
  const row = this.exec.get("SELECT * FROM records WHERE id = ?", [id]);
970
- if (!row)
971
- return null;
972
- const associations = this.getAssociationsForRecord(id);
973
- return rowToRecord(row, associations);
1063
+ return row ? this.hydrate(row) : null;
1064
+ }
1065
+ /** A records row with its association set attached. */
1066
+ hydrate(row) {
1067
+ return rowToRecord(row, this.getAssociationsForRecord(row.id));
974
1068
  }
975
1069
  /**
976
- * Apply a change set in one transaction: one UPDATE over the record row
977
- * with only the columns the change set names, the association set
978
- * replaced when it carries one, and the snapshot written alongside — so
979
- * one call is one version whatever it moved.
980
- *
981
- * `opts.bumpsVersion: false` is a change set that touches only
982
- * `associations`, `parentId` and/or `unlisted` — Stack computes this from
983
- * which ops the set actually moves. Such a write still lands its columns,
984
- * but leaves `version`, `updatedAt` and the actor stamps exactly as they
985
- * stood and takes no snapshot. `ifVersion`, if given, is re-checked
986
- * synchronously inside the transaction — the CAS guard the bumping path
987
- * gets from its UPDATE's WHERE clause, restated as a read because the
988
- * UPDATE here carries no version predicate. See
989
- * docs/spec/versioning.md § Version history.
990
- *
991
- * The ifVersion check for a bumping write is standalone rather than
992
- * folded into the UPDATE alone, because a content change needs
993
- * fts.remove() to run *before* the records-table content changes. The
994
- * guard is still in the UPDATE's WHERE, so a writer that slipped in
995
- * between the read and the write is caught there rather than overwriting.
1070
+ * One transaction, so one call is one version whatever it moved, and the
1071
+ * patch applies to the content as it stands when the write lands.
996
1072
  */
997
1073
  async mutateRecord(id, changes, opts = {}) {
998
- const existing = await this.getRecord(id);
999
- if (!existing)
1000
- throw new StackNotFoundError(`Record not found: "${id}"`);
1001
- this.checkExpectedVersion(existing, opts.ifVersion);
1002
1074
  const now = toMs(/* @__PURE__ */ new Date());
1003
1075
  const sets = [];
1004
1076
  const values = [];
@@ -1010,35 +1082,30 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1010
1082
  sets.push("unlisted_at = ?");
1011
1083
  values.push(changes.unlisted ? now : null);
1012
1084
  }
1013
- if (opts.bumpsVersion === false) {
1014
- this.exec.transaction(() => {
1015
- const current = this.readRecord(id);
1016
- if (!current)
1017
- throw new StackNotFoundError(`Record not found: "${id}"`);
1018
- this.checkExpectedVersion(current, opts.ifVersion);
1085
+ this.exec.transaction(() => {
1086
+ const current = this.readForWrite(id, opts.ifVersion);
1087
+ if (opts.bumpsVersion === false) {
1019
1088
  if (sets.length > 0) {
1020
1089
  this.exec.run(`UPDATE records SET ${sets.join(", ")} WHERE id = ?`, [...values, id]);
1021
1090
  }
1022
1091
  this.replaceAssociationHalves(id, changes);
1023
1092
  this.appendJournal(id, opts.journal);
1024
- });
1025
- return this.reread(id, "mutateRecord");
1026
- }
1027
- const merged = changes.contentPatch ? applyMergePatch(existing.content, changes.contentPatch) : void 0;
1028
- if (merged !== void 0) {
1029
- sets.unshift("content = ?");
1030
- values.unshift(JSON.stringify(merged));
1031
- }
1032
- this.exec.transaction(() => {
1093
+ return;
1094
+ }
1095
+ const merged = changes.contentPatch ? applyMergePatch(current.content, changes.contentPatch) : void 0;
1096
+ if (merged !== void 0) {
1097
+ sets.unshift("content = ?");
1098
+ values.unshift(JSON.stringify(merged));
1099
+ }
1033
1100
  if (opts.snapshot)
1034
1101
  this.snapshotBeforeMutation(id, opts.snapshot);
1035
1102
  if (merged !== void 0)
1036
- fts5Strategy.remove(this.exec, id);
1103
+ this.searchIndex.remove(id);
1037
1104
  this.versionedUpdate(id, sets, values, opts, now);
1038
1105
  this.replaceAssociationHalves(id, changes);
1039
1106
  if (merged !== void 0) {
1040
- fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
1041
- this.syncContentIndex(id, existing.typeId, merged);
1107
+ this.searchIndex.insert(id, JSON.stringify(merged));
1108
+ this.contentIndex.sync(id, current.typeId, merged);
1042
1109
  }
1043
1110
  this.appendJournal(id, opts.journal);
1044
1111
  });
@@ -1058,27 +1125,23 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1058
1125
  return this.reread(id, "deleteRecord");
1059
1126
  }
1060
1127
  /**
1061
- * Deletes a record's FTS entry, associations, versions, file-refs, sort
1062
- * index rows, and row, returning the record as it stood — the last copy that will ever
1063
- * exist, read here so that nothing can overtake it between the read and
1064
- * the deletes. Null when there was no such record. No write() call —
1065
- * callers batch it. ifVersion is checked first so a lost CAS race
1066
- * leaves nothing touched (children reference the row, so the check can't
1067
- * fold into the final DELETE).
1128
+ * Delete a record and everything that refers to it, returning the record
1129
+ * as it stood (null if there was none). Read here, inside the caller's
1130
+ * transaction, so nothing can overtake the last copy that will ever exist.
1068
1131
  */
1069
1132
  purgeRecord(id, ifVersion) {
1070
1133
  const purged = this.readRecord(id);
1071
1134
  if (!purged) {
1072
1135
  if (ifVersion !== void 0)
1073
- throw new StackNotFoundError(`Record not found: "${id}"`);
1136
+ throw recordNotFound(id);
1074
1137
  return null;
1075
1138
  }
1076
- this.checkExpectedVersion(purged, ifVersion);
1077
- fts5Strategy.remove(this.exec, id);
1139
+ this.checkExpectedVersion(id, purged.version, ifVersion);
1140
+ this.searchIndex.remove(id);
1078
1141
  this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
1079
1142
  this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
1080
1143
  this.exec.run("DELETE FROM journal WHERE record_id = ?", [id]);
1081
- this.exec.run("DELETE FROM content_index WHERE record_id = ?", [id]);
1144
+ this.contentIndex.remove(id);
1082
1145
  this.exec.run("DELETE FROM records WHERE id = ?", [id]);
1083
1146
  return purged;
1084
1147
  }
@@ -1092,14 +1155,11 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1092
1155
  return this.reread(id, "undelete");
1093
1156
  }
1094
1157
  async restoreVersion(id, version, opts = {}) {
1095
- const existing = await this.getRecord(id);
1096
- if (!existing)
1097
- throw new StackNotFoundError(`Record not found: "${id}"`);
1098
- this.checkExpectedVersion(existing, opts.ifVersion);
1099
- const target = await this.getVersion(id, version);
1100
- if (!target)
1101
- throw new StackNotFoundError(`Version not found: "${id}"@${version}`);
1102
1158
  this.exec.transaction(() => {
1159
+ this.readForWrite(id, opts.ifVersion);
1160
+ const target = this.readVersion(id, version);
1161
+ if (!target)
1162
+ throw new StackNotFoundError(`Version not found: "${id}"@${version}`);
1103
1163
  if (opts.snapshot)
1104
1164
  this.snapshotBeforeMutation(id, opts.snapshot);
1105
1165
  this.rewriteContent(id, target.typeId, target.content, opts);
@@ -1108,11 +1168,8 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1108
1168
  return this.reread(id, "restoreVersion");
1109
1169
  }
1110
1170
  async commitMigration(id, toTypeId, content, opts = {}) {
1111
- const existing = await this.getRecord(id);
1112
- if (!existing)
1113
- throw new StackNotFoundError(`Record not found: "${id}"`);
1114
- this.checkExpectedVersion(existing, opts.ifVersion);
1115
1171
  this.exec.transaction(() => {
1172
+ this.readForWrite(id, opts.ifVersion);
1116
1173
  if (opts.snapshot)
1117
1174
  this.snapshotBeforeMutation(id, opts.snapshot);
1118
1175
  this.rewriteContent(id, toTypeId, content, opts);
@@ -1121,21 +1178,16 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1121
1178
  return this.reread(id, "commitMigration");
1122
1179
  }
1123
1180
  /**
1124
- * Replace a record's type and content wholesale — what restoreVersion and
1125
- * commitMigration each do, differing only in where the content came from.
1126
- *
1127
- * The order is the constraint: fts5Strategy.remove() has to read the old
1128
- * content off the records row, so it runs before the UPDATE, and the
1129
- * re-index runs after. Every caller must already sit inside
1130
- * exec.transaction() — a failure between the two halves would otherwise
1131
- * leave the record unindexed.
1181
+ * Replace a record's type and content wholesale. Unindex, write, re-index,
1182
+ * in that order (docs/spec/adapters.md § The search index is unindexed
1183
+ * before the row moves); callers must be inside exec.transaction().
1132
1184
  */
1133
1185
  rewriteContent(id, typeId, content, opts) {
1134
1186
  const json = JSON.stringify(content);
1135
- fts5Strategy.remove(this.exec, id);
1187
+ this.searchIndex.remove(id);
1136
1188
  this.versionedUpdate(id, ["type_id = ?", "content = ?"], [typeId, json], opts);
1137
- fts5Strategy.insert(this.exec, id, json);
1138
- this.syncContentIndex(id, typeId, content);
1189
+ this.searchIndex.insert(id, json);
1190
+ this.contentIndex.sync(id, typeId, content);
1139
1191
  }
1140
1192
  async queryRecords(query) {
1141
1193
  const limit = query.limit ?? 50;
@@ -1150,20 +1202,14 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1150
1202
  }
1151
1203
  const hasMore = rows.length > limit;
1152
1204
  const page = hasMore ? rows.slice(0, limit) : rows;
1153
- const records = page.map((row) => {
1154
- const associations = this.getAssociationsForRecord(row.id);
1155
- return rowToRecord(row, associations);
1156
- });
1205
+ const records = page.map((row) => this.hydrate(row));
1157
1206
  const lastRecord = records[records.length - 1];
1158
- const cursor = hasMore && lastRecord ? makeCursor(lastRecord, query.sort, (record, field) => this.sortFieldKind(record, field)) : null;
1207
+ const cursor = hasMore && lastRecord ? makeCursor(lastRecord, query.sort, (record, field) => this.contentIndex.kindOf(record.typeId, field)) : null;
1159
1208
  return { records, cursor };
1160
1209
  }
1161
1210
  /**
1162
- * Atomically verify fileId is unreferenced, then purge its
1163
- * metadata records across every typeId in `metadataTypeIds` (see
1164
- * deleteUnreferencedAttachmentRecords on the adapter contract). A real
1165
- * SQL transaction of synchronous calls — no `await` between the check
1166
- * and the deletes, so nothing interleaves.
1211
+ * The reference check and the purges share one synchronous transaction,
1212
+ * so nothing can add a reference between the two.
1167
1213
  */
1168
1214
  async deleteUnreferencedAttachmentRecords(fileId, metadataTypeIds) {
1169
1215
  return this.exec.transaction(() => {
@@ -1202,6 +1248,9 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1202
1248
  return rows.map(rowToVersion);
1203
1249
  }
1204
1250
  async getVersion(id, version) {
1251
+ return this.readVersion(id, version);
1252
+ }
1253
+ readVersion(id, version) {
1205
1254
  const row = this.exec.get("SELECT * FROM versions WHERE record_id = ? AND version = ?", [id, version]);
1206
1255
  return row ? rowToVersion(row) : null;
1207
1256
  }
@@ -1255,10 +1304,7 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1255
1304
  } catch (err) {
1256
1305
  if (!(err instanceof StackConflictError))
1257
1306
  throw err;
1258
- const row = this.exec.get("SELECT version FROM records WHERE id = ?", [
1259
- id
1260
- ]);
1261
- if (!row || row.version !== version.version)
1307
+ if (this.currentVersion(id) !== version.version)
1262
1308
  throw err;
1263
1309
  this.overwriteVersionRow(id, version);
1264
1310
  }
@@ -1267,21 +1313,17 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1267
1313
  // Change journal
1268
1314
  // -------------------------------------------------------
1269
1315
  /**
1270
- * Append one entry, stamping the record-derived half off the row as it
1271
- * now stands — so every caller must already sit inside the same
1272
- * transaction as the write it describes, after that write has landed.
1273
- *
1274
- * `seq` is allocated here, from the log's own max, rather than computed
1275
- * by the caller. That is what spares this table the collision healing
1276
- * versions needs: nothing outside this statement ever holds a seq it
1277
- * expects to be free. See docs/spec/journal.md § Ordering.
1316
+ * Append one entry, stamped from the row as it now stands, so callers run
1317
+ * this inside their write's transaction, after the write. `seq` is
1318
+ * allocated in the INSERT itself, so no caller ever holds one it expects
1319
+ * to be free. See docs/spec/journal.md § Ordering.
1278
1320
  */
1279
1321
  appendJournal(recordId, entry) {
1280
1322
  if (!entry)
1281
1323
  return;
1282
1324
  const row = this.exec.get("SELECT version, type_id, parent_id FROM records WHERE id = ?", [recordId]);
1283
1325
  if (!row)
1284
- throw new StackNotFoundError(`Record not found: "${recordId}"`);
1326
+ throw recordNotFound(recordId);
1285
1327
  this.exec.run(`INSERT INTO journal
1286
1328
  (record_id, seq, at, kind, ops, version, type_id, parent_id,
1287
1329
  previous_parent_id, actor, associations)
@@ -1305,7 +1347,7 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1305
1347
  }
1306
1348
  async getJournal(id, query = {}) {
1307
1349
  if (!this.exec.get("SELECT 1 FROM records WHERE id = ?", [id])) {
1308
- throw new StackNotFoundError(`Record not found: "${id}"`);
1350
+ throw recordNotFound(id);
1309
1351
  }
1310
1352
  const conditions = ["record_id = ?"];
1311
1353
  const values = [id];
@@ -1332,8 +1374,7 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1332
1374
  type.migratesFrom ?? null,
1333
1375
  toMs(type.createdAt)
1334
1376
  ]);
1335
- const schema = type.schema;
1336
- this.indexedFieldsByType.set(type.id, indexedFieldKinds(schema));
1377
+ this.contentIndex.rememberType(type);
1337
1378
  }
1338
1379
  async getType(id) {
1339
1380
  const row = this.exec.get("SELECT * FROM types WHERE id = ?", [id]);
@@ -1348,15 +1389,14 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1348
1389
  // -------------------------------------------------------
1349
1390
  /**
1350
1391
  * Never bumps `version`/`updatedAt` and never snapshots — see
1351
- * StackRecordAdapter.amendAssociations(). The records row is untouched
1352
- * entirely; only the associations table changes, in one transaction:
1353
- * removes first, then adds.
1392
+ * StackRecordAdapter.amendAssociations(). Removes apply before adds, so
1393
+ * removing and re-adding one identity in a call leaves it present.
1354
1394
  */
1355
1395
  async amendAssociations(recordId, changes, opts = {}) {
1356
1396
  assertOneSurface(changes);
1357
1397
  this.exec.transaction(() => {
1358
- if (!this.readRecord(recordId))
1359
- throw new StackNotFoundError(`Record not found: "${recordId}"`);
1398
+ if (this.currentVersion(recordId) === void 0)
1399
+ throw recordNotFound(recordId);
1360
1400
  for (const change of changes) {
1361
1401
  if (change.op !== "remove")
1362
1402
  continue;
@@ -1379,43 +1419,33 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1379
1419
  });
1380
1420
  return this.reread(recordId, "amendAssociations");
1381
1421
  }
1382
- /** The kinds each change-set key replaces, and nothing else. */
1383
- static DATA_KINDS = ["tag", "attachment", "relationship"];
1384
- static AUTHORITY_KINDS = ["permission", "anyone"];
1385
1422
  /**
1386
- * Apply a change set's two association keys, each replacing only within
1387
- * its own half of the partition. That partition is the whole reason a
1388
- * tag write cannot drop an ACL: the `associations` key deletes data
1389
- * kinds alone, so the authority rows beside them are never in scope.
1390
- * Caller-transactional, like every write below.
1423
+ * Each key replaces only within its own half of the partition, which is
1424
+ * why a tag write cannot drop an ACL. Caller-transactional.
1391
1425
  * See docs/spec/access-control.md § Record-level permissions.
1392
1426
  */
1393
1427
  replaceAssociationHalves(recordId, changes) {
1394
1428
  if (changes.associations !== void 0) {
1395
- this.replaceAssociations(recordId, changes.associations, _SharedSqlRecordLogic.DATA_KINDS);
1429
+ this.replaceAssociations(recordId, changes.associations, "data");
1396
1430
  }
1397
1431
  if (changes.permissions !== void 0) {
1398
- this.replaceAssociations(recordId, changes.permissions, _SharedSqlRecordLogic.AUTHORITY_KINDS);
1432
+ this.replaceAssociations(recordId, changes.permissions, "authority");
1399
1433
  }
1400
1434
  }
1401
1435
  /**
1402
1436
  * Swap one half of a record's association set wholesale — the "replace,
1403
- * don't merge" semantics a change set's association list carries.
1437
+ * don't merge" semantics a change set's association list carries. The data
1438
+ * half is "not an authority kind", so core's list is the only one.
1404
1439
  */
1405
- replaceAssociations(recordId, associations, kinds) {
1406
- this.exec.run(`DELETE FROM associations WHERE record_id = ? AND kind IN (${kinds.map(() => "?").join(", ")})`, [recordId, ...kinds]);
1440
+ replaceAssociations(recordId, associations, half) {
1441
+ this.exec.run(`DELETE FROM associations WHERE record_id = ? AND kind ${half === "data" ? "NOT IN" : "IN"} (${AUTHORITY_ASSOCIATION_KINDS.map(() => "?").join(", ")})`, [recordId, ...AUTHORITY_ASSOCIATION_KINDS]);
1407
1442
  if (associations.length)
1408
1443
  this.insertAssociations(recordId, associations);
1409
1444
  }
1410
1445
  /**
1411
- * Upserts on association identity: the primary key decides which row a
1412
- * write lands on, and `attachment_record_id` — outside it — is whatever
1413
- * the incoming association says, so re-pointing one costs no second row.
1414
- *
1415
- * FK enforcement (PRAGMA_FOREIGN_KEYS_ON) means inserting an
1416
- * association against a record that doesn't exist throws — mapped
1417
- * here to StackNotFoundError so amendAssociations() on a nonexistent record
1418
- * fails loudly instead of silently creating an orphan row.
1446
+ * Upserts on association identity, so re-pointing `attachment_record_id`
1447
+ * (outside the key) costs no second row. A missing record trips the
1448
+ * foreign key, reported as StackNotFoundError rather than an orphan row.
1419
1449
  */
1420
1450
  insertAssociations(recordId, associations) {
1421
1451
  for (const assoc of associations) {
@@ -1433,7 +1463,7 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1433
1463
  ]);
1434
1464
  } catch (err) {
1435
1465
  if (isForeignKeyViolation(err)) {
1436
- throw new StackNotFoundError(`Record not found: "${recordId}"`);
1466
+ throw recordNotFound(recordId);
1437
1467
  }
1438
1468
  throw err;
1439
1469
  }
@@ -1443,51 +1473,6 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1443
1473
  const rows = this.exec.all("SELECT * FROM associations WHERE record_id = ?", [recordId]);
1444
1474
  return rows.map(rowToAssociation);
1445
1475
  }
1446
- /**
1447
- * The fields content_index covers for typeId, from the in-memory cache
1448
- * — falling back to a `types` lookup (and populating the cache) only for
1449
- * a typeId this process hasn't seen via saveType() yet, e.g. right after
1450
- * open().
1451
- */
1452
- getIndexedFields(typeId) {
1453
- const cached = this.indexedFieldsByType.get(typeId);
1454
- if (cached)
1455
- return cached;
1456
- const typeRow = this.exec.get("SELECT schema FROM types WHERE id = ?", [
1457
- typeId
1458
- ]);
1459
- const fields = typeRow ? indexedFieldKinds(JSON.parse(typeRow.schema)) : /* @__PURE__ */ new Map();
1460
- this.indexedFieldsByType.set(typeId, fields);
1461
- return fields;
1462
- }
1463
- /**
1464
- * Replace a record's content_index rows with what its content currently
1465
- * holds, on every write that can change content or typeId, so neither
1466
- * the ordering nor the file-reference lookup can drift from the record.
1467
- * A field holding no orderable value gets no row at all, which is what
1468
- * puts the record at the end of a sort on that field.
1469
- */
1470
- syncContentIndex(recordId, typeId, content) {
1471
- this.exec.run("DELETE FROM content_index WHERE record_id = ?", [recordId]);
1472
- for (const [field, kind] of this.getIndexedFields(typeId)) {
1473
- const entry = contentSortEntry(kind, content[field]);
1474
- if (!entry)
1475
- continue;
1476
- const sql = `INSERT OR IGNORE INTO content_index
1477
- (record_id, field, value_rank, num_value, text_key, text_value, file_id)
1478
- VALUES (?, ?, ?, ?, ?, ?, ?)`;
1479
- if (entry.kind === "num") {
1480
- this.exec.run(sql, [recordId, field, 0, entry.num, null, null, null]);
1481
- } else {
1482
- const fileId = kind === "file-ref" ? entry.text : null;
1483
- this.exec.run(sql, [recordId, field, 1, null, entry.key, entry.text, fileId]);
1484
- }
1485
- }
1486
- }
1487
- /** The declared kind of a record's top-level `field`, if it has one. */
1488
- sortFieldKind(record, field) {
1489
- return this.getIndexedFields(record.typeId).get(field);
1490
- }
1491
1476
  };
1492
1477
 
1493
1478
  // ../sqlite-shared/dist/record-adapter.js
@@ -1495,11 +1480,15 @@ var SQLITE_RECORD_CAPABILITIES = {
1495
1480
  filter: {
1496
1481
  content: "path",
1497
1482
  contentPresent: true,
1483
+ // Inside SQLite's limits on expression depth and compound selects; see
1484
+ // docs/spec/adapters.md § Adapter capabilities.
1485
+ contentByType: 64,
1498
1486
  search: true
1499
1487
  },
1500
1488
  sort: {
1501
1489
  fields: ["createdAt", "updatedAt", "version"],
1502
- contentField: true
1490
+ contentField: true,
1491
+ contentFieldByType: 64
1503
1492
  },
1504
1493
  limits: {
1505
1494
  attachmentBytes: null,
@@ -1594,7 +1583,7 @@ var isProcessAlive = (pid) => {
1594
1583
  };
1595
1584
  var acquireLock = (dbPath, force) => {
1596
1585
  const lockPath = lockPathFor(dbPath);
1597
- const info = existsSync(lockPath) ? readLock(lockPath) : null;
1586
+ const info = readLock(lockPath);
1598
1587
  if (info) {
1599
1588
  const ownedBySelf = info.pid === process.pid;
1600
1589
  if (!ownedBySelf && !force && isProcessAlive(info.pid)) {
@@ -1621,12 +1610,9 @@ var releaseLock = (dbPath) => {
1621
1610
  };
1622
1611
  var hashToken = (token) => createHash("sha256").update(token).digest("hex");
1623
1612
  var SharedTokenLogic = class {
1624
- deps;
1625
- constructor(deps) {
1626
- this.deps = deps;
1627
- }
1628
- get exec() {
1629
- return this.deps.exec;
1613
+ exec;
1614
+ constructor({ exec }) {
1615
+ this.exec = exec;
1630
1616
  }
1631
1617
  async createToken(actor, opts = {}) {
1632
1618
  const id = randomBytes(8).toString("hex");