@haverstack/record-adapter-do-sqlite 0.25.0 → 0.27.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,5 +1,5 @@
1
- import { StackNotFoundError, StackConflictError, applyMergePatch, assertOneSurface, StackVersionConflictError, StackBadRequestError, NATIVE_SORT_FIELDS } from '@haverstack/core';
2
- import { OwnerMismatchError, contentSortEntry, parseContentFilterKey, contentSortKey } from '@haverstack/core/adapter';
1
+ import { StackConflictError, applyMergePatch, StackNotFoundError, assertOneSurface, StackVersionConflictError, StackBadRequestError, NATIVE_SORT_FIELDS } from '@haverstack/core';
2
+ import { OwnerMismatchError, AUTHORITY_ASSOCIATION_KINDS, contentSortEntry, partitionAssociations, constantSortEntry, contentSortKey, parseContentFilterKey } from '@haverstack/core/adapter';
3
3
 
4
4
  // ../sqlite-shared/dist/schema.js
5
5
  var RECORD_SCHEMA_SQL = `
@@ -21,39 +21,32 @@ var RECORD_SCHEMA_SQL = `
21
21
  ) STRICT;
22
22
 
23
23
  -- Every edge a record carries, authority and data alike: a permission
24
- -- entry has element identity and a delta the same shape as a tag's, so
25
- -- it earns the same table and the same durability tier. The permissions
26
- -- and associations fields are projections over it, partitioned by
27
- -- kind. See docs/spec/access-control.md \xA7 Record-level permissions.
28
- --
29
- -- A relationship's target is (related_scope, related_id) plus one
30
- -- qualifier: related_stack for a record in another stack, related_ns for
31
- -- a foreign namespace. All four are in the primary key, so two targets
32
- -- differing only by namespace are two associations. A permission's
33
- -- grantee reuses them \u2014 related_scope 'entity' or 'group', related_id the
34
- -- DID or group id \u2014 plus related_role, which only a group grantee sets
35
- -- and which is in the key because member and admin name two different
36
- -- sets of people.
37
- --
38
- -- attachment_record_id stays out of the primary key: it annotates a
39
- -- reference rather than naming one, so re-pointing it updates the row an
40
- -- an amendAssociations() add lands on instead of writing a second reference to the same
41
- -- file. See docs/spec/attachments.md \xA7 Naming the upload a reference came from.
42
- --
43
- -- The primary key leads with record_id, so its implicit index already
44
- -- serves every by-record read and delete; the indexes below exist only
45
- -- for the filter directions it cannot answer.
24
+ -- has element identity and a delta shaped like a tag's, so it earns the
25
+ -- same table. The permissions and associations fields are projections
26
+ -- over it, partitioned by kind.
27
+ -- See docs/spec/access-control.md \xA7 Record-level permissions.
46
28
  CREATE TABLE IF NOT EXISTS associations (
47
29
  record_id TEXT NOT NULL REFERENCES records(id),
48
30
  kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship', 'permission', 'anyone')),
49
31
  label TEXT NOT NULL,
50
32
  file_id TEXT NOT NULL DEFAULT '',
33
+ -- A relationship target is (related_scope, related_id) qualified by
34
+ -- related_ns (a foreign namespace) or related_stack (another stack); a
35
+ -- permission's grantee reuses scope and id. All are in the key, so
36
+ -- targets differing only by a qualifier are distinct associations.
51
37
  related_scope TEXT NOT NULL DEFAULT '' CHECK (related_scope IN ('', 'record', 'entity', 'external', 'group')),
52
38
  related_id TEXT NOT NULL DEFAULT '',
53
39
  related_ns TEXT NOT NULL DEFAULT '',
54
40
  related_stack TEXT NOT NULL DEFAULT '',
41
+ -- Set only by a group grantee; in the key because member and admin name
42
+ -- two different sets of people.
55
43
  related_role TEXT NOT NULL DEFAULT '' CHECK (related_role IN ('', 'member', 'admin')),
44
+ -- Out of the key: it annotates a reference rather than naming one, so
45
+ -- re-pointing it updates the existing row. See docs/spec/attachments.md
46
+ -- \xA7 Naming the upload a reference came from.
56
47
  attachment_record_id TEXT NOT NULL DEFAULT '',
48
+ -- Leads with record_id, so its implicit index serves every by-record
49
+ -- read and delete; the indexes below cover only the filter directions.
57
50
  PRIMARY KEY (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack, related_role)
58
51
  ) STRICT;
59
52
 
@@ -116,7 +109,6 @@ var RECORD_SCHEMA_SQL = `
116
109
 
117
110
  -- One row per top-level scalar content field a record holds a value at:
118
111
  -- what it orders as, and the file it references. See
119
- -- docs/spec/adapters.md \xA7 Adapter backends and
120
112
  -- docs/spec/data-model.md \xA7 Sorting by a content field.
121
113
  --
122
114
  -- value_rank is 0 for a numeric value and 1 for a text one \u2014 the same
@@ -133,14 +125,11 @@ var RECORD_SCHEMA_SQL = `
133
125
  PRIMARY KEY (record_id, field)
134
126
  ) STRICT;
135
127
 
136
- -- Indexes. Each filter column continues into (created_at, id) \u2014 the
137
- -- default ordering and its tiebreak \u2014 because these columns are
138
- -- low-cardinality by design, and a narrow index on one leaves the
139
- -- planner sorting the whole matching set before it can honor LIMIT.
140
- --
141
- -- deleted_at and unlisted_at deliberately have none: nothing searches
142
- -- for the rows they mark, and an index on a column every query tests
143
- -- for IS NULL only lures the planner off the one answering ORDER BY.
128
+ -- Each filter column continues into (created_at, id), the default order
129
+ -- and its tiebreak: these columns are low-cardinality, and a narrow index
130
+ -- leaves the planner sorting every match before it can honor LIMIT.
131
+ -- deleted_at and unlisted_at get none: an index on a column every query
132
+ -- tests IS NULL only lures the planner off the one answering ORDER BY.
144
133
  CREATE INDEX IF NOT EXISTS idx_records_type_id ON records(type_id, created_at, id);
145
134
  CREATE INDEX IF NOT EXISTS idx_records_parent_id ON records(parent_id, created_at, id);
146
135
  CREATE INDEX IF NOT EXISTS idx_records_entity_id ON records(entity_id, created_at, id);
@@ -181,71 +170,6 @@ var applyRecordSchema = (exec, opts) => {
181
170
  exec.exec(RECORD_SCHEMA_SQL);
182
171
  exec.exec(FTS5_SCHEMA_SQL);
183
172
  };
184
- var SORT_FIELDS = NATIVE_SORT_FIELDS;
185
- var toBase64 = (text) => {
186
- const bytes = new TextEncoder().encode(text);
187
- let binary = "";
188
- for (const byte of bytes)
189
- binary += String.fromCharCode(byte);
190
- return btoa(binary);
191
- };
192
- var fromBase64 = (cursor) => {
193
- const binary = atob(cursor);
194
- const bytes = new Uint8Array(binary.length);
195
- for (let i = 0; i < binary.length; i++)
196
- bytes[i] = binary.charCodeAt(i);
197
- return new TextDecoder().decode(bytes);
198
- };
199
- 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 }));
200
- var decodeCursor = (cursor) => {
201
- const malformed = () => new StackBadRequestError(`Invalid cursor: malformed "${cursor}"`);
202
- let parsed;
203
- try {
204
- parsed = JSON.parse(fromBase64(cursor));
205
- } catch {
206
- throw malformed();
207
- }
208
- if (!parsed || typeof parsed !== "object")
209
- throw malformed();
210
- const { k, f, i, v } = parsed;
211
- if (typeof f !== "string" || !f || typeof i !== "string")
212
- throw malformed();
213
- if (k === "absent")
214
- return { kind: "absent", field: f, id: i };
215
- if (k === "text") {
216
- if (typeof v !== "string")
217
- throw malformed();
218
- return { kind: "text", field: f, value: v, id: i };
219
- }
220
- if (k === "num" || k === "native") {
221
- if (typeof v !== "number" || !isFinite(v)) {
222
- throw new StackBadRequestError(`Invalid cursor: non-numeric sort value`);
223
- }
224
- if (k === "native") {
225
- if (!SORT_FIELDS.includes(f)) {
226
- throw new StackBadRequestError(`Invalid cursor: unknown sort field "${f}"`);
227
- }
228
- return { kind: "native", field: f, value: v, id: i };
229
- }
230
- return { kind: "num", field: f, value: v, id: i };
231
- }
232
- throw malformed();
233
- };
234
- var getSortField = (query) => query.sort?.field ?? "createdAt";
235
- var getSortColumn = (field) => field === "createdAt" ? "created_at" : field === "updatedAt" ? "updated_at" : "version";
236
- var makeCursor = (record, sort, kindOf) => {
237
- const contentField = sort?.contentField;
238
- if (contentField === void 0) {
239
- const field = getSortField({ sort });
240
- const value = field === "updatedAt" ? record.updatedAt.getTime() : field === "version" ? record.version : record.createdAt.getTime();
241
- return encodeCursor({ kind: "native", field, value, id: record.id });
242
- }
243
- const kind = kindOf?.(record, contentField);
244
- const entry = kind ? contentSortEntry(kind, record.content[contentField]) : null;
245
- if (!entry)
246
- return encodeCursor({ kind: "absent", field: contentField, id: record.id });
247
- 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 });
248
- };
249
173
 
250
174
  // ../sqlite-shared/dist/fts5.js
251
175
  var OUTSIDE_PHRASE_DISALLOWED = /[^\p{L}\p{N}\p{M}_\s()"]/gu;
@@ -259,7 +183,7 @@ var sanitizeFts5Query = (query, maxDepth = 2) => {
259
183
  clean = clean.replace(/\bNEAR\s*\(/gi, "(");
260
184
  clean = clean.replace(/"[^"]*"|[^"]+/g, (span) => span.startsWith('"') ? span : span.replace(OUTSIDE_PHRASE_DISALLOWED, " "));
261
185
  clean = collapseWhitespace(clean);
262
- clean = clean.replace(/(?:^|\(\s*)NOT\s+/gi, (m) => m.replace(/NOT\s+/i, ""));
186
+ clean = clean.replace(/"[^"]*"?|(^|\(\s*)NOT\s+/gi, (m, lead) => lead ?? m);
263
187
  let currentDepth = 0;
264
188
  let inPhrase = false;
265
189
  let result = "";
@@ -326,27 +250,101 @@ var restoreImplicitAnd = (query) => withPhrasesMasked(query, (masked) => masked.
326
250
  const next = /^\s*([\p{L}\p{N}_]+)/u.exec(whole.slice(offset + match.length));
327
251
  return next && isOperator(next[1]) ? match : ") AND ";
328
252
  }).replace(new RegExp(String.raw`(?<!${OPERAND_CHAR})(${OPERAND_CHAR}+)\s*(?=\()`, "gu"), (match, word) => isOperator(word) ? match : `${word} AND `));
329
- var fts5Strategy = {
330
- /** Index a record that has no FTS entry yet (used right after an INSERT). */
331
- insert(exec, recordId, content) {
332
- exec.run(`INSERT INTO records_fts(rowid, content) SELECT rowid, ? FROM records WHERE id = ?`, [
333
- content,
334
- recordId
335
- ]);
336
- },
337
- /**
338
- * Remove a record's FTS entry. MUST be called before the records row's
339
- * content changes or the row is deleted — see the note above.
340
- */
341
- remove(exec, recordId) {
342
- const row = exec.get("SELECT rowid, content FROM records WHERE id = ?", [recordId]);
343
- if (!row)
344
- return;
345
- exec.run(`INSERT INTO records_fts(records_fts, rowid, content) VALUES('delete', ?, ?)`, [
346
- row.rowid,
347
- row.content
348
- ]);
253
+
254
+ // ../sqlite-shared/dist/executor.js
255
+ var isForeignKeyViolation = (err) => err instanceof Error && err.message.includes("FOREIGN KEY constraint failed");
256
+ var isUniqueConstraintViolation = (err) => err instanceof Error && err.message.includes("UNIQUE constraint failed");
257
+
258
+ // ../sqlite-shared/dist/config.js
259
+ var CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`;
260
+ var insertConfigRecord = (exec, entityId, timezone) => {
261
+ const now = Date.now();
262
+ exec.run(`INSERT INTO records (id, type_id, created_at, updated_at, content, version)
263
+ VALUES ('_config', '_config@1', ?, ?, ?, 1)`, [now, now, JSON.stringify({ entityId, timezone })]);
264
+ return { entityId, timezone };
265
+ };
266
+ var tryReadStackConfig = (exec) => {
267
+ const row = exec.get(CONFIG_ROW_SQL);
268
+ if (!row)
269
+ return null;
270
+ const content = JSON.parse(row.content);
271
+ return { entityId: content.entityId, timezone: content.timezone };
272
+ };
273
+ var toBase64 = (text) => {
274
+ const bytes = new TextEncoder().encode(text);
275
+ let binary = "";
276
+ for (const byte of bytes)
277
+ binary += String.fromCharCode(byte);
278
+ return btoa(binary);
279
+ };
280
+ var fromBase64 = (cursor) => {
281
+ const binary = atob(cursor);
282
+ const bytes = new Uint8Array(binary.length);
283
+ for (let i = 0; i < binary.length; i++)
284
+ bytes[i] = binary.charCodeAt(i);
285
+ return new TextDecoder().decode(bytes);
286
+ };
287
+ 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 }));
288
+ var decodeCursor = (cursor) => {
289
+ const malformed = () => new StackBadRequestError(`Invalid cursor: malformed "${cursor}"`);
290
+ let parsed;
291
+ try {
292
+ parsed = JSON.parse(fromBase64(cursor));
293
+ } catch {
294
+ throw malformed();
295
+ }
296
+ if (!parsed || typeof parsed !== "object")
297
+ throw malformed();
298
+ const { k, f, i, v } = parsed;
299
+ if (typeof f !== "string" || !f || typeof i !== "string")
300
+ throw malformed();
301
+ if (k === "absent")
302
+ return { kind: "absent", field: f, id: i };
303
+ if (k === "text") {
304
+ if (typeof v !== "string")
305
+ throw malformed();
306
+ return { kind: "text", field: f, value: v, id: i };
307
+ }
308
+ if (k === "num" || k === "native") {
309
+ if (typeof v !== "number" || !isFinite(v)) {
310
+ throw new StackBadRequestError(`Invalid cursor: non-numeric sort value`);
311
+ }
312
+ if (k === "native") {
313
+ if (!NATIVE_SORT_FIELDS.includes(f)) {
314
+ throw new StackBadRequestError(`Invalid cursor: unknown sort field "${f}"`);
315
+ }
316
+ return { kind: "native", field: f, value: v, id: i };
317
+ }
318
+ return { kind: "num", field: f, value: v, id: i };
319
+ }
320
+ throw malformed();
321
+ };
322
+ var byTypeDescriptor = (byType) => JSON.stringify(Object.entries(byType).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
323
+ var getSortField = (query) => query.sort?.field ?? "createdAt";
324
+ var getSortColumn = (field) => field === "createdAt" ? "created_at" : field === "updatedAt" ? "updated_at" : "version";
325
+ var makeCursor = (record, sort, kindOf) => {
326
+ const byType = sort?.contentFieldByType;
327
+ if (byType) {
328
+ const field = byTypeDescriptor(byType);
329
+ const key = Object.hasOwn(byType, record.typeId) ? byType[record.typeId] : void 0;
330
+ const kind2 = key?.field === void 0 ? void 0 : kindOf?.(record, key.field);
331
+ const entry2 = key?.field !== void 0 ? kind2 && contentSortEntry(kind2, record.content[key.field]) : key && constantSortEntry(key.value ?? null);
332
+ return encodeEntry(field, entry2 || null, record.id);
349
333
  }
334
+ const contentField = sort?.contentField;
335
+ if (contentField === void 0) {
336
+ const field = getSortField({ sort });
337
+ const value = field === "updatedAt" ? record.updatedAt.getTime() : field === "version" ? record.version : record.createdAt.getTime();
338
+ return encodeCursor({ kind: "native", field, value, id: record.id });
339
+ }
340
+ const kind = kindOf?.(record, contentField);
341
+ const entry = kind ? contentSortEntry(kind, record.content[contentField]) : null;
342
+ return encodeEntry(contentField, entry, record.id);
343
+ };
344
+ var encodeEntry = (field, entry, id) => {
345
+ if (!entry)
346
+ return encodeCursor({ kind: "absent", field, id });
347
+ return entry.kind === "num" ? encodeCursor({ kind: "num", field, value: entry.num, id }) : encodeCursor({ kind: "text", field, value: entry.text, id });
350
348
  };
351
349
 
352
350
  // ../sqlite-shared/dist/query.js
@@ -393,10 +391,31 @@ var contentCursor = (cursor, field) => {
393
391
  throw cursorMismatch(cursor, field);
394
392
  return cursor;
395
393
  };
394
+ var contentConditions = (predicate, params, nextAlias) => {
395
+ const conditions = [];
396
+ for (const [key, value] of Object.entries(predicate.content ?? {})) {
397
+ const segments = parseContentFilterKey(key);
398
+ if (value === null || value === void 0) {
399
+ const isNull = contentPathExists(segments, (a) => `${a}.value IS NULL`, params, nextAlias);
400
+ const anyValue = contentPathExists(segments, (a) => `${a}.value IS NOT NULL`, params, nextAlias);
401
+ conditions.push(`(${isNull} OR NOT ${anyValue})`);
402
+ } else {
403
+ const match = contentPathExists(segments, equalsLeaf, params, nextAlias);
404
+ params.push(value);
405
+ conditions.push(match);
406
+ }
407
+ }
408
+ for (const key of predicate.contentPresent ?? []) {
409
+ conditions.push(contentPathExists(parseContentFilterKey(key), (a) => `${a}.value IS NOT NULL`, params, nextAlias));
410
+ }
411
+ return conditions;
412
+ };
396
413
  var recordConditions = (query) => {
397
414
  const conditions = ["r.id != '_config'"];
398
415
  const params = [];
399
416
  const f = query.filter ?? {};
417
+ let aliasSeq = 0;
418
+ const nextContentAlias = () => `cp${aliasSeq++}`;
400
419
  const inFilter = (column, value) => {
401
420
  if (value === void 0)
402
421
  return;
@@ -485,28 +504,15 @@ var recordConditions = (query) => {
485
504
  }
486
505
  conditions.push(hasAssociation("relationship", clauses));
487
506
  }
488
- if (f.content) {
489
- let aliasSeq = 0;
490
- const nextAlias = () => `cp${aliasSeq++}`;
491
- for (const [key, value] of Object.entries(f.content)) {
492
- const segments = parseContentFilterKey(key);
493
- if (value === null || value === void 0) {
494
- const isNull = contentPathExists(segments, (a) => `${a}.value IS NULL`, params, nextAlias);
495
- const anyValue = contentPathExists(segments, (a) => `${a}.value IS NOT NULL`, params, nextAlias);
496
- conditions.push(`(${isNull} OR NOT ${anyValue})`);
497
- } else {
498
- const match = contentPathExists(segments, equalsLeaf, params, nextAlias);
499
- params.push(value);
500
- conditions.push(match);
501
- }
502
- }
507
+ if (f.content || f.contentPresent?.length) {
508
+ conditions.push(...contentConditions(f, params, nextContentAlias));
503
509
  }
504
- if (f.contentPresent?.length) {
505
- let aliasSeq = 0;
506
- const nextAlias = () => `pp${aliasSeq++}`;
507
- for (const key of f.contentPresent) {
508
- conditions.push(contentPathExists(parseContentFilterKey(key), (a) => `${a}.value IS NOT NULL`, params, nextAlias));
509
- }
510
+ if (f.contentByType) {
511
+ const arms = Object.entries(f.contentByType).map(([typeId, arm]) => {
512
+ params.push(typeId);
513
+ return ["r.type_id = ?", ...contentConditions(arm, params, nextContentAlias)].join(" AND ");
514
+ });
515
+ conditions.push(`(${arms.map((arm) => `(${arm})`).join(" OR ")})`);
510
516
  }
511
517
  if (f.search) {
512
518
  const sanitized = sanitizeFts5Query(f.search);
@@ -536,12 +542,16 @@ var buildQueryPlan = (query) => {
536
542
  const dir = sqlDirection(query);
537
543
  const op = dir === "ASC" ? ">" : "<";
538
544
  const decoded = query.cursor ? decodeCursor(query.cursor) : null;
545
+ if (query.sort?.contentFieldByType) {
546
+ return byTypePlan(query, query.sort.contentFieldByType, dir, op, decoded);
547
+ }
539
548
  const contentField = query.sort?.contentField;
540
549
  if (contentField === void 0) {
541
550
  const { conditions, params } = recordConditions(query);
542
- const col = getSortColumn(getSortField(query));
551
+ const field = getSortField(query);
552
+ const col = getSortColumn(field);
543
553
  if (decoded) {
544
- const cursor2 = nativeCursor(decoded, getSortField(query));
554
+ const cursor2 = nativeCursor(decoded, field);
545
555
  conditions.push(`(r.${col} ${op} ? OR (r.${col} = ? AND r.id ${op} ?))`);
546
556
  params.push(cursor2.value, cursor2.value, cursor2.id);
547
557
  }
@@ -573,18 +583,154 @@ var buildQueryPlan = (query) => {
573
583
  }
574
584
  return pages;
575
585
  };
576
-
577
- // ../sqlite-shared/dist/mappers.js
586
+ var byTypePlan = (query, byType, dir, op, decoded) => {
587
+ const cursor = decoded ? contentCursor(decoded, byTypeDescriptor(byType)) : null;
588
+ const fields = [];
589
+ const constants = [];
590
+ for (const [typeId, key] of Object.entries(byType)) {
591
+ if (key.field !== void 0) {
592
+ fields.push([typeId, key.field]);
593
+ continue;
594
+ }
595
+ const entry = constantSortEntry(key.value ?? null);
596
+ if (entry)
597
+ constants.push([typeId, entry]);
598
+ }
599
+ const pages = [];
600
+ const fieldOf = (alias, record) => fields.map(() => `(${record}.type_id = ? AND ${alias}.field = ?)`).join(" OR ");
601
+ if (cursor?.kind !== "absent" && fields.length + constants.length > 0) {
602
+ const sources = [];
603
+ const sourceParams = [];
604
+ if (fields.length > 0) {
605
+ 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")}`);
606
+ for (const [typeId, field] of fields)
607
+ sourceParams.push(typeId, field);
608
+ }
609
+ for (const [typeId, entry] of constants) {
610
+ 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 = ?");
611
+ sourceParams.push(...entry.kind === "num" ? [0, entry.num, null, null] : [1, null, entry.key, entry.text], typeId);
612
+ }
613
+ const { conditions, params } = recordConditions(query);
614
+ if (cursor)
615
+ conditions.push(presentCursorCondition(cursor, op, params));
616
+ const from = `(${sources.join(" UNION ALL ")}) ${SORT_ALIAS} JOIN records r ON r.id = ${SORT_ALIAS}.record_id`;
617
+ 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}`;
618
+ pages.push(statement("r.*", from, conditions, [...sourceParams, ...params], ` ORDER BY ${order} LIMIT ?`));
619
+ }
620
+ {
621
+ const { conditions, params } = recordConditions(query);
622
+ if (fields.length > 0) {
623
+ conditions.push(`NOT EXISTS (SELECT 1 FROM content_index ${SORT_ALIAS} WHERE ${SORT_ALIAS}.record_id = r.id AND (${fieldOf(SORT_ALIAS, "r")}))`);
624
+ for (const [typeId, field] of fields)
625
+ params.push(typeId, field);
626
+ }
627
+ if (constants.length > 0) {
628
+ conditions.push(`r.type_id NOT IN (${constants.map(() => "?").join(",")})`);
629
+ params.push(...constants.map(([typeId]) => typeId));
630
+ }
631
+ if (cursor?.kind === "absent") {
632
+ conditions.push(`r.id ${op} ?`);
633
+ params.push(cursor.id);
634
+ }
635
+ pages.push(statement("r.*", "records r", conditions, params, ` ORDER BY r.id ${dir} LIMIT ?`));
636
+ }
637
+ return pages;
638
+ };
639
+ var SearchIndex = class {
640
+ exec;
641
+ constructor(exec) {
642
+ this.exec = exec;
643
+ }
644
+ /** Index a record that has no FTS entry yet (used right after an INSERT). */
645
+ insert(recordId, content) {
646
+ this.exec.run(`INSERT INTO records_fts(rowid, content) SELECT rowid, ? FROM records WHERE id = ?`, [content, recordId]);
647
+ }
648
+ /**
649
+ * Remove a record's FTS entry. MUST be called before the records row's
650
+ * content changes or the row is deleted — see the note above.
651
+ */
652
+ remove(recordId) {
653
+ const row = this.exec.get("SELECT rowid, content FROM records WHERE id = ?", [recordId]);
654
+ if (!row)
655
+ return;
656
+ this.exec.run(`INSERT INTO records_fts(records_fts, rowid, content) VALUES('delete', ?, ?)`, [
657
+ row.rowid,
658
+ row.content
659
+ ]);
660
+ }
661
+ };
662
+ var indexedFieldKinds = (schema) => {
663
+ const kinds = /* @__PURE__ */ new Map();
664
+ for (const [field, def] of Object.entries(schema)) {
665
+ if (!def.kind || def.kind === "array" || def.kind === "object")
666
+ continue;
667
+ kinds.set(field, def.kind);
668
+ }
669
+ return kinds;
670
+ };
671
+ var ContentIndex = class {
672
+ exec;
673
+ fieldsByType = /* @__PURE__ */ new Map();
674
+ constructor(exec) {
675
+ this.exec = exec;
676
+ }
677
+ /** Prime the cache from a type as it is saved. */
678
+ rememberType(type) {
679
+ this.fieldsByType.set(type.id, indexedFieldKinds(type.schema));
680
+ }
681
+ /**
682
+ * Replace a record's rows with what its content currently holds, on every
683
+ * write that can change content or typeId, so neither the ordering nor the
684
+ * file-reference lookup can drift from the record. A field holding no
685
+ * orderable value gets no row at all, which is what puts the record at the
686
+ * end of a sort on that field.
687
+ */
688
+ sync(recordId, typeId, content) {
689
+ this.remove(recordId);
690
+ for (const [field, kind] of this.fieldsOf(typeId)) {
691
+ const entry = contentSortEntry(kind, content[field]);
692
+ if (!entry)
693
+ continue;
694
+ const sql = `INSERT OR IGNORE INTO content_index
695
+ (record_id, field, value_rank, num_value, text_key, text_value, file_id)
696
+ VALUES (?, ?, ?, ?, ?, ?, ?)`;
697
+ if (entry.kind === "num") {
698
+ this.exec.run(sql, [recordId, field, 0, entry.num, null, null, null]);
699
+ } else {
700
+ const fileId = kind === "file-ref" ? entry.text : null;
701
+ this.exec.run(sql, [recordId, field, 1, null, entry.key, entry.text, fileId]);
702
+ }
703
+ }
704
+ }
705
+ remove(recordId) {
706
+ this.exec.run("DELETE FROM content_index WHERE record_id = ?", [recordId]);
707
+ }
708
+ /** The declared kind of a type's top-level `field`, if it has one. */
709
+ kindOf(typeId, field) {
710
+ return this.fieldsOf(typeId).get(field);
711
+ }
712
+ /**
713
+ * From the cache, falling back to a `types` lookup only for a typeId this
714
+ * process hasn't seen via rememberType() yet, e.g. right after open().
715
+ */
716
+ fieldsOf(typeId) {
717
+ const cached = this.fieldsByType.get(typeId);
718
+ if (cached)
719
+ return cached;
720
+ const typeRow = this.exec.get("SELECT schema FROM types WHERE id = ?", [
721
+ typeId
722
+ ]);
723
+ const fields = typeRow ? indexedFieldKinds(JSON.parse(typeRow.schema)) : /* @__PURE__ */ new Map();
724
+ this.fieldsByType.set(typeId, fields);
725
+ return fields;
726
+ }
727
+ };
578
728
  var toMs = (d) => d.getTime();
579
729
  var actorFromColumns = (subject, principal) => subject == null ? void 0 : {
580
730
  subjectId: subject,
581
731
  ...principal != null && { principalId: principal }
582
732
  };
583
733
  var fromMs = (ms) => new Date(ms);
584
- var partitionAssociations = (associations) => ({
585
- associations: associations.filter((a) => a.kind !== "permission" && a.kind !== "anyone"),
586
- permissions: associations.filter((a) => a.kind === "permission" || a.kind === "anyone")
587
- });
588
734
  var rowToRecord = (row, associations) => {
589
735
  const record = {
590
736
  id: row.id,
@@ -728,25 +874,7 @@ var rowToJournalEntry = (row) => {
728
874
  return e;
729
875
  };
730
876
 
731
- // ../sqlite-shared/dist/executor.js
732
- var isForeignKeyViolation = (err) => err instanceof Error && err.message.includes("FOREIGN KEY constraint failed");
733
- var isUniqueConstraintViolation = (err) => err instanceof Error && err.message.includes("UNIQUE constraint failed");
734
-
735
- // ../sqlite-shared/dist/config.js
736
- var CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`;
737
- var insertConfigRecord = (exec, entityId, timezone) => {
738
- const now = Date.now();
739
- exec.run(`INSERT INTO records (id, type_id, created_at, updated_at, content, version)
740
- VALUES ('_config', '_config@1', ?, ?, ?, 1)`, [now, now, JSON.stringify({ entityId, timezone })]);
741
- return { entityId, timezone };
742
- };
743
- var tryReadStackConfig = (exec) => {
744
- const row = exec.get(CONFIG_ROW_SQL);
745
- if (!row)
746
- return null;
747
- const content = JSON.parse(row.content);
748
- return { entityId: content.entityId, timezone: content.timezone };
749
- };
877
+ // ../sqlite-shared/dist/record-logic.js
750
878
  var asStackBadRequestError = (query, run) => {
751
879
  try {
752
880
  return run();
@@ -757,6 +885,7 @@ var asStackBadRequestError = (query, run) => {
757
885
  }
758
886
  };
759
887
  var versionConflict = (id, expected, actual) => new StackVersionConflictError(`Record "${id}" is at version ${actual}, expected ${expected}`, id, expected, actual);
888
+ var recordNotFound = (id) => new StackNotFoundError(`Record not found: "${id}"`);
760
889
  var VERSION_COLUMNS = [
761
890
  "type_id",
762
891
  "content",
@@ -775,44 +904,20 @@ var versionRowValues = (version) => [
775
904
  version.updatedBy?.subjectId ?? null,
776
905
  version.updatedBy?.principalId ?? null
777
906
  ];
778
- var indexedFieldKinds = (schema) => {
779
- const kinds = /* @__PURE__ */ new Map();
780
- for (const [field, def] of Object.entries(schema)) {
781
- if (!def.kind || def.kind === "array" || def.kind === "object")
782
- continue;
783
- kinds.set(field, def.kind);
784
- }
785
- return kinds;
786
- };
787
- var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
788
- deps;
789
- /**
790
- * Per-typeId cache of the fields content_index covers and their declared
791
- * kinds, so syncContentIndex() doesn't hit the `types` table and
792
- * re-parse schema JSON on every write. Populated eagerly in saveType()
793
- * and lazily via getIndexedFields().
794
- */
795
- indexedFieldsByType = /* @__PURE__ */ new Map();
796
- constructor(deps) {
797
- this.deps = deps;
798
- }
799
- get exec() {
800
- return this.deps.exec;
801
- }
802
- /**
803
- * Precondition check for a mutation that has already read the record —
804
- * either because it can't fold the check into its own statement
805
- * (purgeRecord: children reference the row, so the DELETE can't
806
- * carry it), or because it has to settle the precondition *before*
807
- * fts5Strategy.remove() runs, which needs the records row still holding
808
- * the old content. In the latter case versionedUpdate() re-checks it
809
- * inside the statement, and that is the enforcement; this is what lets
810
- * the work before it be skipped.
811
- */
812
- checkExpectedVersion(record, expectedVersion) {
813
- if (expectedVersion === void 0 || record.version === expectedVersion)
907
+ var SharedSqlRecordLogic = class {
908
+ exec;
909
+ searchIndex;
910
+ contentIndex;
911
+ constructor({ exec }) {
912
+ this.exec = exec;
913
+ this.searchIndex = new SearchIndex(exec);
914
+ this.contentIndex = new ContentIndex(exec);
915
+ }
916
+ /** Check `ifVersion` against a version read inside the caller's transaction. */
917
+ checkExpectedVersion(id, actual, expected) {
918
+ if (expected === void 0 || actual === expected)
814
919
  return;
815
- throw versionConflict(record.id, expectedVersion, record.version);
920
+ throw versionConflict(id, expected, actual);
816
921
  }
817
922
  /**
818
923
  * Called after a version-gated statement affected zero rows.
@@ -821,23 +926,31 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
821
926
  * the actual current version for the caller to act on).
822
927
  */
823
928
  throwVersionConflict(id, expectedVersion) {
824
- const row = this.exec.get("SELECT version FROM records WHERE id = ?", [
825
- id
826
- ]);
929
+ const version = this.currentVersion(id);
930
+ if (version === void 0)
931
+ throw recordNotFound(id);
932
+ throw versionConflict(id, expectedVersion, version);
933
+ }
934
+ currentVersion(id) {
935
+ return this.exec.get("SELECT version FROM records WHERE id = ?", [id])?.version;
936
+ }
937
+ /**
938
+ * What a mutation builds on, read inside its transaction so nothing can
939
+ * land between the read and the write: a patch applied to an earlier read
940
+ * would overwrite whatever changed since. Throws for a missing record or
941
+ * a failed `ifVersion`.
942
+ */
943
+ readForWrite(id, ifVersion) {
944
+ const row = this.exec.get("SELECT version, type_id, content FROM records WHERE id = ?", [id]);
827
945
  if (!row)
828
- throw new StackNotFoundError(`Record not found: "${id}"`);
829
- throw versionConflict(id, expectedVersion, row.version);
946
+ throw recordNotFound(id);
947
+ this.checkExpectedVersion(id, row.version, ifVersion);
948
+ return { typeId: row.type_id, content: JSON.parse(row.content) };
830
949
  }
831
950
  /**
832
- * The one shape every mutating UPDATE in this class has: whatever columns
833
- * the caller is changing, then the version bump and the actor/timestamp
834
- * stamp that every mutation owes, gated on the opt-in `ifVersion`
835
- * precondition and failing loudly when nothing matched.
836
- *
837
- * The guard rides in the WHERE clause even where the caller has already
838
- * checked the version against a record it read: that read is outside this
839
- * statement, so a writer slipping in between the two is caught here
840
- * rather than silently overwritten.
951
+ * Every version-bumping UPDATE. The `ifVersion` guard rides in the WHERE
952
+ * clause, so a write that reads nothing first (a soft delete, an
953
+ * undelete) enforces it with no separate read.
841
954
  */
842
955
  versionedUpdate(id, sets, values, opts, now = toMs(/* @__PURE__ */ new Date())) {
843
956
  const guarded = opts.ifVersion !== void 0;
@@ -853,15 +966,9 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
853
966
  this.throwVersionConflict(id, opts.ifVersion);
854
967
  }
855
968
  /**
856
- * Read a record back after its own mutation committed, for the caller to
857
- * return. Its absence is not a "not found" for the caller to handle — the
858
- * mutation just succeeded against it — so it fails as the invariant
859
- * breach it would be.
860
- *
861
- * Synchronous, via readRecord() rather than getRecord(), so the read
862
- * cannot be separated from the commit by anything: an async caller
863
- * returning this still hands back a record read in the same tick its
864
- * transaction committed. See readRecord() for why that matters.
969
+ * Read a record back after its own mutation committed. Absence here is an
970
+ * invariant breach, not a "not found". Synchronous, so nothing can run
971
+ * between the commit and the read — see readRecord().
865
972
  */
866
973
  reread(id, verb) {
867
974
  const updated = this.readRecord(id);
@@ -873,16 +980,10 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
873
980
  // Records
874
981
  // -------------------------------------------------------
875
982
  /**
876
- * A duplicate id hits the `records` PK — mapped to StackConflictError
877
- * instead of surfacing the raw engine exception, mirroring saveVersion's
878
- * collision mapping below.
879
- *
880
- * One transaction, because a record and the four tables that describe it
881
- * are one fact. A half-applied create is worse than a failed one: the
882
- * call raises, so the caller believes nothing landed, while the records
883
- * row survives to fail their retry on the PK — and the row it leaves is
884
- * invisible to `filter.search` and mis-ordered by `sort.contentField`
885
- * until something writes it again.
983
+ * One transaction, because a record and the tables that describe it are
984
+ * one fact: a half-applied create raises, so the caller believes nothing
985
+ * landed, while the surviving row fails their retry on the PK and is
986
+ * missing from both indexes. A duplicate id maps to StackConflictError.
886
987
  */
887
988
  async createRecord(record, opts = {}) {
888
989
  this.exec.transaction(() => {
@@ -916,8 +1017,8 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
916
1017
  const edges = [...record.associations ?? [], ...record.permissions ?? []];
917
1018
  if (edges.length)
918
1019
  this.insertAssociations(record.id, edges);
919
- fts5Strategy.insert(this.exec, record.id, JSON.stringify(record.content));
920
- this.syncContentIndex(record.id, record.typeId, record.content);
1020
+ this.searchIndex.insert(record.id, JSON.stringify(record.content));
1021
+ this.contentIndex.sync(record.id, record.typeId, record.content);
921
1022
  this.appendJournal(record.id, opts.journal);
922
1023
  });
923
1024
  return record;
@@ -926,53 +1027,24 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
926
1027
  return this.readRecord(id);
927
1028
  }
928
1029
  /**
929
- * The synchronous read behind getRecord(). Callers inside exec.transaction()
930
- * use this one: an `await` inside that callback yields the microtask queue
931
- * mid-transaction, and the next operation to run would try to open one of
932
- * its own — transaction() requires a synchronous callback for exactly this
933
- * reason (see SqlExecutor.transaction).
934
- *
935
- * The same synchrony is what lets the post-commit reads below report the
936
- * version their own mutation produced: reread() calls this directly, so
937
- * nothing interleaves between the commit and the read even though the
938
- * method returning it is async. A backend that made these reads genuinely
939
- * asynchronous would open that window, and a concurrent mutation could be
940
- * reported under this one's verb.
1030
+ * The synchronous read behind getRecord(), for use inside
1031
+ * exec.transaction(), whose callback can't await. reread() relies on the
1032
+ * same synchrony: nothing interleaves between a commit and its read, so a
1033
+ * concurrent mutation is never reported under this one's verb.
941
1034
  */
942
1035
  readRecord(id) {
943
1036
  const row = this.exec.get("SELECT * FROM records WHERE id = ?", [id]);
944
- if (!row)
945
- return null;
946
- const associations = this.getAssociationsForRecord(id);
947
- return rowToRecord(row, associations);
1037
+ return row ? this.hydrate(row) : null;
1038
+ }
1039
+ /** A records row with its association set attached. */
1040
+ hydrate(row) {
1041
+ return rowToRecord(row, this.getAssociationsForRecord(row.id));
948
1042
  }
949
1043
  /**
950
- * Apply a change set in one transaction: one UPDATE over the record row
951
- * with only the columns the change set names, the association set
952
- * replaced when it carries one, and the snapshot written alongside — so
953
- * one call is one version whatever it moved.
954
- *
955
- * `opts.bumpsVersion: false` is a change set that touches only
956
- * `associations`, `parentId` and/or `unlisted` — Stack computes this from
957
- * which ops the set actually moves. Such a write still lands its columns,
958
- * but leaves `version`, `updatedAt` and the actor stamps exactly as they
959
- * stood and takes no snapshot. `ifVersion`, if given, is re-checked
960
- * synchronously inside the transaction — the CAS guard the bumping path
961
- * gets from its UPDATE's WHERE clause, restated as a read because the
962
- * UPDATE here carries no version predicate. See
963
- * docs/spec/versioning.md § Version history.
964
- *
965
- * The ifVersion check for a bumping write is standalone rather than
966
- * folded into the UPDATE alone, because a content change needs
967
- * fts.remove() to run *before* the records-table content changes. The
968
- * guard is still in the UPDATE's WHERE, so a writer that slipped in
969
- * between the read and the write is caught there rather than overwriting.
1044
+ * One transaction, so one call is one version whatever it moved, and the
1045
+ * patch applies to the content as it stands when the write lands.
970
1046
  */
971
1047
  async mutateRecord(id, changes, opts = {}) {
972
- const existing = await this.getRecord(id);
973
- if (!existing)
974
- throw new StackNotFoundError(`Record not found: "${id}"`);
975
- this.checkExpectedVersion(existing, opts.ifVersion);
976
1048
  const now = toMs(/* @__PURE__ */ new Date());
977
1049
  const sets = [];
978
1050
  const values = [];
@@ -984,35 +1056,30 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
984
1056
  sets.push("unlisted_at = ?");
985
1057
  values.push(changes.unlisted ? now : null);
986
1058
  }
987
- if (opts.bumpsVersion === false) {
988
- this.exec.transaction(() => {
989
- const current = this.readRecord(id);
990
- if (!current)
991
- throw new StackNotFoundError(`Record not found: "${id}"`);
992
- this.checkExpectedVersion(current, opts.ifVersion);
1059
+ this.exec.transaction(() => {
1060
+ const current = this.readForWrite(id, opts.ifVersion);
1061
+ if (opts.bumpsVersion === false) {
993
1062
  if (sets.length > 0) {
994
1063
  this.exec.run(`UPDATE records SET ${sets.join(", ")} WHERE id = ?`, [...values, id]);
995
1064
  }
996
1065
  this.replaceAssociationHalves(id, changes);
997
1066
  this.appendJournal(id, opts.journal);
998
- });
999
- return this.reread(id, "mutateRecord");
1000
- }
1001
- const merged = changes.contentPatch ? applyMergePatch(existing.content, changes.contentPatch) : void 0;
1002
- if (merged !== void 0) {
1003
- sets.unshift("content = ?");
1004
- values.unshift(JSON.stringify(merged));
1005
- }
1006
- this.exec.transaction(() => {
1067
+ return;
1068
+ }
1069
+ const merged = changes.contentPatch ? applyMergePatch(current.content, changes.contentPatch) : void 0;
1070
+ if (merged !== void 0) {
1071
+ sets.unshift("content = ?");
1072
+ values.unshift(JSON.stringify(merged));
1073
+ }
1007
1074
  if (opts.snapshot)
1008
1075
  this.snapshotBeforeMutation(id, opts.snapshot);
1009
1076
  if (merged !== void 0)
1010
- fts5Strategy.remove(this.exec, id);
1077
+ this.searchIndex.remove(id);
1011
1078
  this.versionedUpdate(id, sets, values, opts, now);
1012
1079
  this.replaceAssociationHalves(id, changes);
1013
1080
  if (merged !== void 0) {
1014
- fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
1015
- this.syncContentIndex(id, existing.typeId, merged);
1081
+ this.searchIndex.insert(id, JSON.stringify(merged));
1082
+ this.contentIndex.sync(id, current.typeId, merged);
1016
1083
  }
1017
1084
  this.appendJournal(id, opts.journal);
1018
1085
  });
@@ -1032,27 +1099,23 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1032
1099
  return this.reread(id, "deleteRecord");
1033
1100
  }
1034
1101
  /**
1035
- * Deletes a record's FTS entry, associations, versions, file-refs, sort
1036
- * index rows, and row, returning the record as it stood — the last copy that will ever
1037
- * exist, read here so that nothing can overtake it between the read and
1038
- * the deletes. Null when there was no such record. No write() call —
1039
- * callers batch it. ifVersion is checked first so a lost CAS race
1040
- * leaves nothing touched (children reference the row, so the check can't
1041
- * fold into the final DELETE).
1102
+ * Delete a record and everything that refers to it, returning the record
1103
+ * as it stood (null if there was none). Read here, inside the caller's
1104
+ * transaction, so nothing can overtake the last copy that will ever exist.
1042
1105
  */
1043
1106
  purgeRecord(id, ifVersion) {
1044
1107
  const purged = this.readRecord(id);
1045
1108
  if (!purged) {
1046
1109
  if (ifVersion !== void 0)
1047
- throw new StackNotFoundError(`Record not found: "${id}"`);
1110
+ throw recordNotFound(id);
1048
1111
  return null;
1049
1112
  }
1050
- this.checkExpectedVersion(purged, ifVersion);
1051
- fts5Strategy.remove(this.exec, id);
1113
+ this.checkExpectedVersion(id, purged.version, ifVersion);
1114
+ this.searchIndex.remove(id);
1052
1115
  this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
1053
1116
  this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
1054
1117
  this.exec.run("DELETE FROM journal WHERE record_id = ?", [id]);
1055
- this.exec.run("DELETE FROM content_index WHERE record_id = ?", [id]);
1118
+ this.contentIndex.remove(id);
1056
1119
  this.exec.run("DELETE FROM records WHERE id = ?", [id]);
1057
1120
  return purged;
1058
1121
  }
@@ -1066,14 +1129,11 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1066
1129
  return this.reread(id, "undelete");
1067
1130
  }
1068
1131
  async restoreVersion(id, version, opts = {}) {
1069
- const existing = await this.getRecord(id);
1070
- if (!existing)
1071
- throw new StackNotFoundError(`Record not found: "${id}"`);
1072
- this.checkExpectedVersion(existing, opts.ifVersion);
1073
- const target = await this.getVersion(id, version);
1074
- if (!target)
1075
- throw new StackNotFoundError(`Version not found: "${id}"@${version}`);
1076
1132
  this.exec.transaction(() => {
1133
+ this.readForWrite(id, opts.ifVersion);
1134
+ const target = this.readVersion(id, version);
1135
+ if (!target)
1136
+ throw new StackNotFoundError(`Version not found: "${id}"@${version}`);
1077
1137
  if (opts.snapshot)
1078
1138
  this.snapshotBeforeMutation(id, opts.snapshot);
1079
1139
  this.rewriteContent(id, target.typeId, target.content, opts);
@@ -1082,11 +1142,8 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1082
1142
  return this.reread(id, "restoreVersion");
1083
1143
  }
1084
1144
  async commitMigration(id, toTypeId, content, opts = {}) {
1085
- const existing = await this.getRecord(id);
1086
- if (!existing)
1087
- throw new StackNotFoundError(`Record not found: "${id}"`);
1088
- this.checkExpectedVersion(existing, opts.ifVersion);
1089
1145
  this.exec.transaction(() => {
1146
+ this.readForWrite(id, opts.ifVersion);
1090
1147
  if (opts.snapshot)
1091
1148
  this.snapshotBeforeMutation(id, opts.snapshot);
1092
1149
  this.rewriteContent(id, toTypeId, content, opts);
@@ -1095,21 +1152,16 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1095
1152
  return this.reread(id, "commitMigration");
1096
1153
  }
1097
1154
  /**
1098
- * Replace a record's type and content wholesale — what restoreVersion and
1099
- * commitMigration each do, differing only in where the content came from.
1100
- *
1101
- * The order is the constraint: fts5Strategy.remove() has to read the old
1102
- * content off the records row, so it runs before the UPDATE, and the
1103
- * re-index runs after. Every caller must already sit inside
1104
- * exec.transaction() — a failure between the two halves would otherwise
1105
- * leave the record unindexed.
1155
+ * Replace a record's type and content wholesale. Unindex, write, re-index,
1156
+ * in that order (docs/spec/adapters.md § The search index is unindexed
1157
+ * before the row moves); callers must be inside exec.transaction().
1106
1158
  */
1107
1159
  rewriteContent(id, typeId, content, opts) {
1108
1160
  const json = JSON.stringify(content);
1109
- fts5Strategy.remove(this.exec, id);
1161
+ this.searchIndex.remove(id);
1110
1162
  this.versionedUpdate(id, ["type_id = ?", "content = ?"], [typeId, json], opts);
1111
- fts5Strategy.insert(this.exec, id, json);
1112
- this.syncContentIndex(id, typeId, content);
1163
+ this.searchIndex.insert(id, json);
1164
+ this.contentIndex.sync(id, typeId, content);
1113
1165
  }
1114
1166
  async queryRecords(query) {
1115
1167
  const limit = query.limit ?? 50;
@@ -1124,20 +1176,14 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1124
1176
  }
1125
1177
  const hasMore = rows.length > limit;
1126
1178
  const page = hasMore ? rows.slice(0, limit) : rows;
1127
- const records = page.map((row) => {
1128
- const associations = this.getAssociationsForRecord(row.id);
1129
- return rowToRecord(row, associations);
1130
- });
1179
+ const records = page.map((row) => this.hydrate(row));
1131
1180
  const lastRecord = records[records.length - 1];
1132
- const cursor = hasMore && lastRecord ? makeCursor(lastRecord, query.sort, (record, field) => this.sortFieldKind(record, field)) : null;
1181
+ const cursor = hasMore && lastRecord ? makeCursor(lastRecord, query.sort, (record, field) => this.contentIndex.kindOf(record.typeId, field)) : null;
1133
1182
  return { records, cursor };
1134
1183
  }
1135
1184
  /**
1136
- * Atomically verify fileId is unreferenced, then purge its
1137
- * metadata records across every typeId in `metadataTypeIds` (see
1138
- * deleteUnreferencedAttachmentRecords on the adapter contract). A real
1139
- * SQL transaction of synchronous calls — no `await` between the check
1140
- * and the deletes, so nothing interleaves.
1185
+ * The reference check and the purges share one synchronous transaction,
1186
+ * so nothing can add a reference between the two.
1141
1187
  */
1142
1188
  async deleteUnreferencedAttachmentRecords(fileId, metadataTypeIds) {
1143
1189
  return this.exec.transaction(() => {
@@ -1176,6 +1222,9 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1176
1222
  return rows.map(rowToVersion);
1177
1223
  }
1178
1224
  async getVersion(id, version) {
1225
+ return this.readVersion(id, version);
1226
+ }
1227
+ readVersion(id, version) {
1179
1228
  const row = this.exec.get("SELECT * FROM versions WHERE record_id = ? AND version = ?", [id, version]);
1180
1229
  return row ? rowToVersion(row) : null;
1181
1230
  }
@@ -1229,10 +1278,7 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1229
1278
  } catch (err) {
1230
1279
  if (!(err instanceof StackConflictError))
1231
1280
  throw err;
1232
- const row = this.exec.get("SELECT version FROM records WHERE id = ?", [
1233
- id
1234
- ]);
1235
- if (!row || row.version !== version.version)
1281
+ if (this.currentVersion(id) !== version.version)
1236
1282
  throw err;
1237
1283
  this.overwriteVersionRow(id, version);
1238
1284
  }
@@ -1241,21 +1287,17 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1241
1287
  // Change journal
1242
1288
  // -------------------------------------------------------
1243
1289
  /**
1244
- * Append one entry, stamping the record-derived half off the row as it
1245
- * now stands — so every caller must already sit inside the same
1246
- * transaction as the write it describes, after that write has landed.
1247
- *
1248
- * `seq` is allocated here, from the log's own max, rather than computed
1249
- * by the caller. That is what spares this table the collision healing
1250
- * versions needs: nothing outside this statement ever holds a seq it
1251
- * expects to be free. See docs/spec/journal.md § Ordering.
1290
+ * Append one entry, stamped from the row as it now stands, so callers run
1291
+ * this inside their write's transaction, after the write. `seq` is
1292
+ * allocated in the INSERT itself, so no caller ever holds one it expects
1293
+ * to be free. See docs/spec/journal.md § Ordering.
1252
1294
  */
1253
1295
  appendJournal(recordId, entry) {
1254
1296
  if (!entry)
1255
1297
  return;
1256
1298
  const row = this.exec.get("SELECT version, type_id, parent_id FROM records WHERE id = ?", [recordId]);
1257
1299
  if (!row)
1258
- throw new StackNotFoundError(`Record not found: "${recordId}"`);
1300
+ throw recordNotFound(recordId);
1259
1301
  this.exec.run(`INSERT INTO journal
1260
1302
  (record_id, seq, at, kind, ops, version, type_id, parent_id,
1261
1303
  previous_parent_id, actor, associations)
@@ -1279,7 +1321,7 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1279
1321
  }
1280
1322
  async getJournal(id, query = {}) {
1281
1323
  if (!this.exec.get("SELECT 1 FROM records WHERE id = ?", [id])) {
1282
- throw new StackNotFoundError(`Record not found: "${id}"`);
1324
+ throw recordNotFound(id);
1283
1325
  }
1284
1326
  const conditions = ["record_id = ?"];
1285
1327
  const values = [id];
@@ -1306,8 +1348,7 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1306
1348
  type.migratesFrom ?? null,
1307
1349
  toMs(type.createdAt)
1308
1350
  ]);
1309
- const schema = type.schema;
1310
- this.indexedFieldsByType.set(type.id, indexedFieldKinds(schema));
1351
+ this.contentIndex.rememberType(type);
1311
1352
  }
1312
1353
  async getType(id) {
1313
1354
  const row = this.exec.get("SELECT * FROM types WHERE id = ?", [id]);
@@ -1322,15 +1363,14 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1322
1363
  // -------------------------------------------------------
1323
1364
  /**
1324
1365
  * Never bumps `version`/`updatedAt` and never snapshots — see
1325
- * StackRecordAdapter.amendAssociations(). The records row is untouched
1326
- * entirely; only the associations table changes, in one transaction:
1327
- * removes first, then adds.
1366
+ * StackRecordAdapter.amendAssociations(). Removes apply before adds, so
1367
+ * removing and re-adding one identity in a call leaves it present.
1328
1368
  */
1329
1369
  async amendAssociations(recordId, changes, opts = {}) {
1330
1370
  assertOneSurface(changes);
1331
1371
  this.exec.transaction(() => {
1332
- if (!this.readRecord(recordId))
1333
- throw new StackNotFoundError(`Record not found: "${recordId}"`);
1372
+ if (this.currentVersion(recordId) === void 0)
1373
+ throw recordNotFound(recordId);
1334
1374
  for (const change of changes) {
1335
1375
  if (change.op !== "remove")
1336
1376
  continue;
@@ -1353,43 +1393,33 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1353
1393
  });
1354
1394
  return this.reread(recordId, "amendAssociations");
1355
1395
  }
1356
- /** The kinds each change-set key replaces, and nothing else. */
1357
- static DATA_KINDS = ["tag", "attachment", "relationship"];
1358
- static AUTHORITY_KINDS = ["permission", "anyone"];
1359
1396
  /**
1360
- * Apply a change set's two association keys, each replacing only within
1361
- * its own half of the partition. That partition is the whole reason a
1362
- * tag write cannot drop an ACL: the `associations` key deletes data
1363
- * kinds alone, so the authority rows beside them are never in scope.
1364
- * Caller-transactional, like every write below.
1397
+ * Each key replaces only within its own half of the partition, which is
1398
+ * why a tag write cannot drop an ACL. Caller-transactional.
1365
1399
  * See docs/spec/access-control.md § Record-level permissions.
1366
1400
  */
1367
1401
  replaceAssociationHalves(recordId, changes) {
1368
1402
  if (changes.associations !== void 0) {
1369
- this.replaceAssociations(recordId, changes.associations, _SharedSqlRecordLogic.DATA_KINDS);
1403
+ this.replaceAssociations(recordId, changes.associations, "data");
1370
1404
  }
1371
1405
  if (changes.permissions !== void 0) {
1372
- this.replaceAssociations(recordId, changes.permissions, _SharedSqlRecordLogic.AUTHORITY_KINDS);
1406
+ this.replaceAssociations(recordId, changes.permissions, "authority");
1373
1407
  }
1374
1408
  }
1375
1409
  /**
1376
1410
  * Swap one half of a record's association set wholesale — the "replace,
1377
- * don't merge" semantics a change set's association list carries.
1411
+ * don't merge" semantics a change set's association list carries. The data
1412
+ * half is "not an authority kind", so core's list is the only one.
1378
1413
  */
1379
- replaceAssociations(recordId, associations, kinds) {
1380
- this.exec.run(`DELETE FROM associations WHERE record_id = ? AND kind IN (${kinds.map(() => "?").join(", ")})`, [recordId, ...kinds]);
1414
+ replaceAssociations(recordId, associations, half) {
1415
+ 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]);
1381
1416
  if (associations.length)
1382
1417
  this.insertAssociations(recordId, associations);
1383
1418
  }
1384
1419
  /**
1385
- * Upserts on association identity: the primary key decides which row a
1386
- * write lands on, and `attachment_record_id` — outside it — is whatever
1387
- * the incoming association says, so re-pointing one costs no second row.
1388
- *
1389
- * FK enforcement (PRAGMA_FOREIGN_KEYS_ON) means inserting an
1390
- * association against a record that doesn't exist throws — mapped
1391
- * here to StackNotFoundError so amendAssociations() on a nonexistent record
1392
- * fails loudly instead of silently creating an orphan row.
1420
+ * Upserts on association identity, so re-pointing `attachment_record_id`
1421
+ * (outside the key) costs no second row. A missing record trips the
1422
+ * foreign key, reported as StackNotFoundError rather than an orphan row.
1393
1423
  */
1394
1424
  insertAssociations(recordId, associations) {
1395
1425
  for (const assoc of associations) {
@@ -1407,7 +1437,7 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1407
1437
  ]);
1408
1438
  } catch (err) {
1409
1439
  if (isForeignKeyViolation(err)) {
1410
- throw new StackNotFoundError(`Record not found: "${recordId}"`);
1440
+ throw recordNotFound(recordId);
1411
1441
  }
1412
1442
  throw err;
1413
1443
  }
@@ -1417,51 +1447,6 @@ var SharedSqlRecordLogic = class _SharedSqlRecordLogic {
1417
1447
  const rows = this.exec.all("SELECT * FROM associations WHERE record_id = ?", [recordId]);
1418
1448
  return rows.map(rowToAssociation);
1419
1449
  }
1420
- /**
1421
- * The fields content_index covers for typeId, from the in-memory cache
1422
- * — falling back to a `types` lookup (and populating the cache) only for
1423
- * a typeId this process hasn't seen via saveType() yet, e.g. right after
1424
- * open().
1425
- */
1426
- getIndexedFields(typeId) {
1427
- const cached = this.indexedFieldsByType.get(typeId);
1428
- if (cached)
1429
- return cached;
1430
- const typeRow = this.exec.get("SELECT schema FROM types WHERE id = ?", [
1431
- typeId
1432
- ]);
1433
- const fields = typeRow ? indexedFieldKinds(JSON.parse(typeRow.schema)) : /* @__PURE__ */ new Map();
1434
- this.indexedFieldsByType.set(typeId, fields);
1435
- return fields;
1436
- }
1437
- /**
1438
- * Replace a record's content_index rows with what its content currently
1439
- * holds, on every write that can change content or typeId, so neither
1440
- * the ordering nor the file-reference lookup can drift from the record.
1441
- * A field holding no orderable value gets no row at all, which is what
1442
- * puts the record at the end of a sort on that field.
1443
- */
1444
- syncContentIndex(recordId, typeId, content) {
1445
- this.exec.run("DELETE FROM content_index WHERE record_id = ?", [recordId]);
1446
- for (const [field, kind] of this.getIndexedFields(typeId)) {
1447
- const entry = contentSortEntry(kind, content[field]);
1448
- if (!entry)
1449
- continue;
1450
- const sql = `INSERT OR IGNORE INTO content_index
1451
- (record_id, field, value_rank, num_value, text_key, text_value, file_id)
1452
- VALUES (?, ?, ?, ?, ?, ?, ?)`;
1453
- if (entry.kind === "num") {
1454
- this.exec.run(sql, [recordId, field, 0, entry.num, null, null, null]);
1455
- } else {
1456
- const fileId = kind === "file-ref" ? entry.text : null;
1457
- this.exec.run(sql, [recordId, field, 1, null, entry.key, entry.text, fileId]);
1458
- }
1459
- }
1460
- }
1461
- /** The declared kind of a record's top-level `field`, if it has one. */
1462
- sortFieldKind(record, field) {
1463
- return this.getIndexedFields(record.typeId).get(field);
1464
- }
1465
1450
  };
1466
1451
 
1467
1452
  // ../sqlite-shared/dist/record-adapter.js
@@ -1469,11 +1454,15 @@ var SQLITE_RECORD_CAPABILITIES = {
1469
1454
  filter: {
1470
1455
  content: "path",
1471
1456
  contentPresent: true,
1457
+ // Inside SQLite's limits on expression depth and compound selects; see
1458
+ // docs/spec/adapters.md § Adapter capabilities.
1459
+ contentByType: 64,
1472
1460
  search: true
1473
1461
  },
1474
1462
  sort: {
1475
1463
  fields: ["createdAt", "updatedAt", "version"],
1476
- contentField: true
1464
+ contentField: true,
1465
+ contentFieldByType: 64
1477
1466
  },
1478
1467
  limits: {
1479
1468
  attachmentBytes: null,