@haverstack/record-adapter-do-sqlite 0.11.0 → 0.13.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
@@ -25,6 +25,10 @@ var RECORD_SCHEMA_SQL = `
25
25
  -- qualifier: related_stack for a record in another stack, related_ns for
26
26
  -- a foreign namespace. All four are in the primary key, so two targets
27
27
  -- differing only by namespace are two associations.
28
+ --
29
+ -- The primary key leads with record_id, so its implicit index already
30
+ -- serves every by-record read and delete; the indexes below exist only
31
+ -- for the filter directions it cannot answer.
28
32
  CREATE TABLE IF NOT EXISTS associations (
29
33
  record_id TEXT NOT NULL REFERENCES records(id),
30
34
  kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship')),
@@ -37,6 +41,9 @@ var RECORD_SCHEMA_SQL = `
37
41
  PRIMARY KEY (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack)
38
42
  ) STRICT;
39
43
 
44
+ -- parent_id holds the container the record sat in, NULL for the root --
45
+ -- the same encoding records.parent_id uses, since a snapshot states
46
+ -- where the record was rather than an instruction to apply.
40
47
  CREATE TABLE IF NOT EXISTS versions (
41
48
  record_id TEXT NOT NULL REFERENCES records(id),
42
49
  version INTEGER NOT NULL,
@@ -46,6 +53,7 @@ var RECORD_SCHEMA_SQL = `
46
53
  entity_id TEXT,
47
54
  updated_by TEXT,
48
55
  updated_via TEXT,
56
+ parent_id TEXT,
49
57
  associations TEXT CHECK (associations IS NULL OR json_valid(associations)),
50
58
  permissions TEXT CHECK (permissions IS NULL OR json_valid(permissions)),
51
59
  PRIMARY KEY (record_id, version)
@@ -62,57 +70,65 @@ var RECORD_SCHEMA_SQL = `
62
70
  created_at INTEGER NOT NULL
63
71
  ) STRICT;
64
72
 
65
- -- One row per top-level file-ref content field on a record, kept in sync
66
- -- on every content/typeId write. Lets the attachmentFileId query filter
67
- -- and deleteAttachment()'s reference check see content-held file
68
- -- references, not just attachment associations.
69
- CREATE TABLE IF NOT EXISTS file_refs (
70
- record_id TEXT NOT NULL REFERENCES records(id),
71
- field TEXT NOT NULL,
72
- file_id TEXT NOT NULL,
73
- PRIMARY KEY (record_id, field)
74
- ) STRICT;
75
-
76
- -- One row per top-level scalar content field on a record, rewritten
77
- -- beside file_refs on every content/typeId write. Exactly one of
78
- -- num_value / text_key+text_value is set, by the field's declared kind:
79
- -- what a field orders as must not vary with the value a given record
80
- -- happens to hold. text_key is the folded form ordering compares
81
- -- (@haverstack/core's contentSortKey), text_value the stored one, which
82
- -- breaks a tie between two values that fold together.
83
- CREATE TABLE IF NOT EXISTS content_sort (
73
+ -- One row per top-level scalar content field a record holds a value at:
74
+ -- what it orders as, and the file it references. See
75
+ -- docs/spec/adapters.md \xA7 Adapter backends and
76
+ -- docs/spec/data-model.md \xA7 Sorting by a content field.
77
+ --
78
+ -- value_rank is 0 for a numeric value and 1 for a text one \u2014 the same
79
+ -- split as "num_value IS NULL", but non-null, so one index can carry
80
+ -- the whole ordering. file_id is set only for a 'file-ref' field.
81
+ CREATE TABLE IF NOT EXISTS content_index (
84
82
  record_id TEXT NOT NULL REFERENCES records(id),
85
83
  field TEXT NOT NULL,
84
+ value_rank INTEGER NOT NULL CHECK (value_rank IN (0, 1)),
86
85
  num_value REAL,
87
86
  text_key TEXT,
88
87
  text_value TEXT,
88
+ file_id TEXT,
89
89
  PRIMARY KEY (record_id, field)
90
90
  ) STRICT;
91
91
 
92
- -- Indexes
93
- CREATE INDEX IF NOT EXISTS idx_records_type_id ON records(type_id);
94
- CREATE INDEX IF NOT EXISTS idx_records_parent_id ON records(parent_id);
95
- CREATE INDEX IF NOT EXISTS idx_records_entity_id ON records(entity_id);
96
- CREATE INDEX IF NOT EXISTS idx_records_app_id ON records(app_id);
97
- CREATE INDEX IF NOT EXISTS idx_records_principal_id ON records(principal_id);
98
- CREATE INDEX IF NOT EXISTS idx_records_deleted_at ON records(deleted_at);
99
- CREATE INDEX IF NOT EXISTS idx_records_unlisted_at ON records(unlisted_at);
100
- CREATE INDEX IF NOT EXISTS idx_records_created_at ON records(created_at);
101
- CREATE INDEX IF NOT EXISTS idx_records_updated_at ON records(updated_at);
102
- CREATE INDEX IF NOT EXISTS idx_assoc_record_id ON associations(record_id);
103
- CREATE INDEX IF NOT EXISTS idx_assoc_kind_label ON associations(kind, label);
104
- CREATE INDEX IF NOT EXISTS idx_assoc_kind_file_id ON associations(kind, file_id);
105
- CREATE INDEX IF NOT EXISTS idx_assoc_related ON associations(related_scope, related_ns, related_id);
106
- CREATE INDEX IF NOT EXISTS idx_types_base_id ON types(base_id);
107
- CREATE INDEX IF NOT EXISTS idx_file_refs_file_id ON file_refs(file_id);
108
- CREATE INDEX IF NOT EXISTS idx_content_sort_num ON content_sort(field, num_value, record_id);
109
- CREATE INDEX IF NOT EXISTS idx_content_sort_text ON content_sort(field, text_key, text_value, record_id);
92
+ -- Indexes. Each filter column continues into (created_at, id) \u2014 the
93
+ -- default ordering and its tiebreak \u2014 because these columns are
94
+ -- low-cardinality by design, and a narrow index on one leaves the
95
+ -- planner sorting the whole matching set before it can honor LIMIT.
96
+ --
97
+ -- deleted_at and unlisted_at deliberately have none: nothing searches
98
+ -- for the rows they mark, and an index on a column every query tests
99
+ -- for IS NULL only lures the planner off the one answering ORDER BY.
100
+ CREATE INDEX IF NOT EXISTS idx_records_type_id ON records(type_id, created_at, id);
101
+ CREATE INDEX IF NOT EXISTS idx_records_parent_id ON records(parent_id, created_at, id);
102
+ CREATE INDEX IF NOT EXISTS idx_records_entity_id ON records(entity_id, created_at, id);
103
+ CREATE INDEX IF NOT EXISTS idx_records_app_id ON records(app_id, created_at, id);
104
+ CREATE INDEX IF NOT EXISTS idx_records_principal_id ON records(principal_id, created_at, id);
105
+ CREATE INDEX IF NOT EXISTS idx_records_created_at ON records(created_at, id);
106
+ CREATE INDEX IF NOT EXISTS idx_records_updated_at ON records(updated_at, id);
107
+ CREATE INDEX IF NOT EXISTS idx_records_version ON records(version, id);
108
+
109
+ -- Each association index ends with record_id so the semi-joins in
110
+ -- query.ts read the matching record ids out of the index alone.
111
+ -- idx_assoc_related carries related_ns ahead of related_id because a
112
+ -- record- or entity-scoped target always stores '' there, which
113
+ -- buildQueryPlan states explicitly to keep the prefix contiguous.
114
+ CREATE INDEX IF NOT EXISTS idx_assoc_kind_label ON associations(kind, label, record_id);
115
+ CREATE INDEX IF NOT EXISTS idx_assoc_kind_file_id ON associations(kind, file_id, record_id);
116
+ CREATE INDEX IF NOT EXISTS idx_assoc_related ON associations(kind, related_scope, related_ns, related_id, record_id);
117
+ CREATE INDEX IF NOT EXISTS idx_types_base_id ON types(base_id, version);
118
+
119
+ -- The whole content ordering, in index order, for one field at a time:
120
+ -- a content-sorted page is an ordered walk of a slice of this index.
121
+ CREATE INDEX IF NOT EXISTS idx_content_index_sort
122
+ ON content_index(field, value_rank, num_value, text_key, text_value, record_id);
123
+ CREATE INDEX IF NOT EXISTS idx_content_index_file
124
+ ON content_index(file_id, record_id) WHERE file_id IS NOT NULL;
110
125
  `;
111
126
  var FTS5_SCHEMA_SQL = `
112
127
  CREATE VIRTUAL TABLE IF NOT EXISTS records_fts USING fts5(
113
128
  content,
114
129
  content='records',
115
- content_rowid='rowid'
130
+ content_rowid='rowid',
131
+ columnsize=0
116
132
  );
117
133
  `;
118
134
  var PRAGMA_FOREIGN_KEYS_ON = `PRAGMA foreign_keys = ON;`;
@@ -184,14 +200,16 @@ var makeCursor = (record, sort, kindOf) => {
184
200
 
185
201
  // ../sqlite-shared/dist/fts5.js
186
202
  var OUTSIDE_PHRASE_DISALLOWED = /[^\p{L}\p{N}\p{M}_\s()"]/gu;
203
+ var collapseWhitespace = (query) => query.replace(/\s+/g, " ");
187
204
  var sanitizeFts5Query = (query, maxDepth = 2) => {
188
205
  if (!query)
189
206
  return "";
190
207
  let clean = query.replace(/[\p{Cc}\p{Cf}]/gu, " ");
191
208
  clean = clean.replace(/\*/g, "");
192
- clean = clean.replace(/\bNEAR\s*\(\s*([^,)]*)(?:,[^)]*)?\)/gi, "$1");
209
+ clean = clean.replace(/\bNEAR\s*\(([^,()]*)(?:,[^()]*)?\)/gi, "$1");
193
210
  clean = clean.replace(/\bNEAR\s*\(/gi, "(");
194
211
  clean = clean.replace(/"[^"]*"|[^"]+/g, (span) => span.startsWith('"') ? span : span.replace(OUTSIDE_PHRASE_DISALLOWED, " "));
212
+ clean = collapseWhitespace(clean);
195
213
  clean = clean.replace(/(?:^|\(\s*)NOT\s+/gi, (m) => m.replace(/NOT\s+/i, ""));
196
214
  let currentDepth = 0;
197
215
  let inPhrase = false;
@@ -225,12 +243,11 @@ var sanitizeFts5Query = (query, maxDepth = 2) => {
225
243
  let prev;
226
244
  do {
227
245
  prev = result;
228
- result = result.replace(/""/g, " ");
229
- result = result.replace(/\(\s*\)/g, " ");
246
+ result = result.replace(/(?:""|\(\s*\))+/g, " ");
230
247
  result = dropDanglingOperators(result);
231
248
  result = restoreImplicitAnd(result);
232
249
  } while (result !== prev);
233
- return result.replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim();
250
+ return collapseWhitespace(result).replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim();
234
251
  };
235
252
  var OPERATOR = String.raw`(?:AND|OR|NOT)`;
236
253
  var PHRASE_HOLE = "\uE000";
@@ -241,7 +258,7 @@ var dropDanglingOperators = (query) => withPhrasesMasked(query, (masked) => {
241
258
  prev = out;
242
259
  out = out.replace(new RegExp(String.raw`\b(${OPERATOR})\b(?:\s+\b${OPERATOR}\b)+`, "gi"), "$1");
243
260
  out = out.replace(new RegExp(String.raw`(^|\()\s*\b${OPERATOR}\b\s*`, "gi"), "$1");
244
- out = out.replace(new RegExp(String.raw`\s*\b${OPERATOR}\b\s*($|\))`, "gi"), "$1");
261
+ out = out.replace(new RegExp(String.raw`\b${OPERATOR}\b\s*($|\))`, "gi"), "$1");
245
262
  } while (out !== prev);
246
263
  return out;
247
264
  });
@@ -259,7 +276,7 @@ var isOperator = (word) => /^(?:AND|OR|NOT)$/i.test(word);
259
276
  var restoreImplicitAnd = (query) => withPhrasesMasked(query, (masked) => masked.replace(new RegExp(String.raw`\)\s*(?=${OPERAND_CHAR}|\()`, "gu"), (match, offset, whole) => {
260
277
  const next = /^\s*([\p{L}\p{N}_]+)/u.exec(whole.slice(offset + match.length));
261
278
  return next && isOperator(next[1]) ? match : ") AND ";
262
- }).replace(new RegExp(String.raw`(${OPERAND_CHAR}+)\s*(?=\()`, "gu"), (match, word) => isOperator(word) ? match : `${word} AND `));
279
+ }).replace(new RegExp(String.raw`(?<!${OPERAND_CHAR})(${OPERAND_CHAR}+)\s*(?=\()`, "gu"), (match, word) => isOperator(word) ? match : `${word} AND `));
263
280
  var fts5Strategy = {
264
281
  /** Index a record that has no FTS entry yet (used right after an INSERT). */
265
282
  insert(exec, recordId, content) {
@@ -284,6 +301,10 @@ var fts5Strategy = {
284
301
  };
285
302
 
286
303
  // ../sqlite-shared/dist/query.js
304
+ var atBudget = (page, rows) => ({
305
+ sql: page.sql,
306
+ params: [...page.params, rows]
307
+ });
287
308
  var sqlDirection = (query) => {
288
309
  const dir = query.sort?.direction ?? "desc";
289
310
  if (dir !== "asc" && dir !== "desc") {
@@ -312,49 +333,18 @@ var contentPathExists = (segments, leaf, params, nextAlias) => {
312
333
  };
313
334
  var equalsLeaf = (alias) => `${alias}.type NOT IN ('object', 'array') AND ${alias}.value = ?`;
314
335
  var SORT_ALIAS = "cs";
315
- var buildFromClause = (query) => {
316
- const field = query.sort?.contentField;
317
- if (field === void 0)
318
- return { sql: "records r", params: [] };
319
- return {
320
- sql: `records r LEFT JOIN content_sort ${SORT_ALIAS} ON ${SORT_ALIAS}.record_id = r.id AND ${SORT_ALIAS}.field = ?`,
321
- params: [field]
322
- };
336
+ var cursorMismatch = (cursor, field) => new StackQueryError(`Cursor sort field "${cursor.field}" does not match query sort field "${field}"`);
337
+ var nativeCursor = (cursor, field) => {
338
+ if (cursor.kind !== "native" || cursor.field !== field)
339
+ throw cursorMismatch(cursor, field);
340
+ return cursor;
323
341
  };
324
- var assertCursorMatchesSort = (cursor, query) => {
325
- const contentField = query.sort?.contentField;
326
- if (cursor.kind === "native") {
327
- const sortField = getSortField(query);
328
- if (contentField !== void 0 || cursor.field !== sortField) {
329
- throw new StackQueryError(`Cursor sort field "${cursor.field}" does not match query sort field "${contentField ?? sortField}"`);
330
- }
331
- return;
332
- }
333
- if (contentField === void 0 || cursor.field !== contentField) {
334
- throw new StackQueryError(`Cursor sort field "${cursor.field}" does not match query sort field "${contentField ?? getSortField(query)}"`);
335
- }
342
+ var contentCursor = (cursor, field) => {
343
+ if (cursor.kind === "native" || cursor.field !== field)
344
+ throw cursorMismatch(cursor, field);
345
+ return cursor;
336
346
  };
337
- var contentCursorCondition = (cursor, op, params) => {
338
- const ascending = op === ">";
339
- const numRank = ascending ? 0 : 1;
340
- const textRank = ascending ? 1 : 0;
341
- const rank = `CASE WHEN ${SORT_ALIAS}.record_id IS NULL THEN 2 WHEN ${SORT_ALIAS}.num_value IS NULL THEN ${textRank} ELSE ${numRank} END`;
342
- const cursorRank = cursor.kind === "absent" ? 2 : cursor.kind === "num" ? numRank : textRank;
343
- let tail;
344
- if (cursor.kind === "num") {
345
- tail = `(${SORT_ALIAS}.num_value ${op} ? OR (${SORT_ALIAS}.num_value = ? AND r.id ${op} ?))`;
346
- params.push(cursor.value, cursor.value, cursor.id);
347
- } else if (cursor.kind === "text") {
348
- const key = contentSortKey(cursor.value);
349
- tail = `(${SORT_ALIAS}.text_key ${op} ? OR (${SORT_ALIAS}.text_key = ? AND (${SORT_ALIAS}.text_value ${op} ? OR (${SORT_ALIAS}.text_value = ? AND r.id ${op} ?))))`;
350
- params.push(key, key, cursor.value, cursor.value, cursor.id);
351
- } else {
352
- tail = `r.id ${op} ?`;
353
- params.push(cursor.id);
354
- }
355
- return `((${rank}) > ${cursorRank} OR ((${rank}) = ${cursorRank} AND ${tail}))`;
356
- };
357
- var buildWhereClause = (query) => {
347
+ var recordConditions = (query) => {
358
348
  const conditions = ["r.id != '_config'"];
359
349
  const params = [];
360
350
  const f = query.filter ?? {};
@@ -420,7 +410,7 @@ var buildWhereClause = (query) => {
420
410
  }
421
411
  if (f.attachmentFileId) {
422
412
  conditions.push(`(r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'attachment' AND a.file_id = ?)
423
- OR r.id IN (SELECT fr.record_id FROM file_refs fr WHERE fr.file_id = ?))`);
413
+ OR r.id IN (SELECT ci.record_id FROM content_index ci WHERE ci.file_id = ?))`);
424
414
  params.push(f.attachmentFileId, f.attachmentFileId);
425
415
  }
426
416
  if (f.relatedTo) {
@@ -434,11 +424,11 @@ var buildWhereClause = (query) => {
434
424
  clauses.push("a.related_scope = ?");
435
425
  params.push(target.scope);
436
426
  if (target.scope === "record") {
437
- clauses.push("a.related_id = ?", "a.related_stack = ?");
438
- params.push(target.recordId, target.stackUrl ?? "");
427
+ clauses.push("a.related_ns = ?", "a.related_id = ?", "a.related_stack = ?");
428
+ params.push("", target.recordId, target.stackUrl ?? "");
439
429
  } else if (target.scope === "entity") {
440
- clauses.push("a.related_id = ?");
441
- params.push(target.entityId);
430
+ clauses.push("a.related_ns = ?", "a.related_id = ?");
431
+ params.push("", target.entityId);
442
432
  } else {
443
433
  clauses.push("a.related_ns = ?");
444
434
  params.push(target.ns);
@@ -482,29 +472,61 @@ var buildWhereClause = (query) => {
482
472
  conditions.push("0");
483
473
  }
484
474
  }
485
- if (query.cursor) {
486
- const cursor = decodeCursor(query.cursor);
487
- assertCursorMatchesSort(cursor, query);
488
- const op = sqlDirection(query) === "ASC" ? ">" : "<";
489
- if (cursor.kind === "native") {
490
- const col = getSortColumn(cursor.field);
491
- conditions.push(`(r.${col} ${op} ? OR (r.${col} = ? AND r.id ${op} ?))`);
492
- params.push(cursor.value, cursor.value, cursor.id);
493
- } else {
494
- conditions.push(contentCursorCondition(cursor, op, params));
495
- }
475
+ return { conditions, params };
476
+ };
477
+ var presentCursorCondition = (cursor, op, params) => {
478
+ if (cursor.kind === "num") {
479
+ params.push(cursor.value, cursor.value, cursor.id);
480
+ return `(${SORT_ALIAS}.value_rank ${op} 0 OR (${SORT_ALIAS}.value_rank = 0 AND (${SORT_ALIAS}.num_value ${op} ? OR (${SORT_ALIAS}.num_value = ? AND ${SORT_ALIAS}.record_id ${op} ?))))`;
496
481
  }
497
- return {
498
- sql: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "",
499
- params
500
- };
482
+ const key = contentSortKey(cursor.value);
483
+ params.push(key, key, cursor.value, cursor.value, cursor.id);
484
+ return `(${SORT_ALIAS}.value_rank ${op} 1 OR (${SORT_ALIAS}.value_rank = 1 AND (${SORT_ALIAS}.text_key ${op} ? OR (${SORT_ALIAS}.text_key = ? AND (${SORT_ALIAS}.text_value ${op} ? OR (${SORT_ALIAS}.text_value = ? AND ${SORT_ALIAS}.record_id ${op} ?))))))`;
501
485
  };
502
- var buildOrderClause = (query) => {
486
+ var statement = (select, from, conditions, params, tail = "") => ({
487
+ sql: `SELECT ${select} FROM ${from} WHERE ${conditions.join(" AND ")}${tail}`,
488
+ params
489
+ });
490
+ var buildQueryPlan = (query) => {
503
491
  const dir = sqlDirection(query);
504
- if (query.sort?.contentField === void 0) {
505
- return `ORDER BY r.${getSortColumn(getSortField(query))} ${dir}, r.id ${dir}`;
492
+ const op = dir === "ASC" ? ">" : "<";
493
+ const decoded = query.cursor ? decodeCursor(query.cursor) : null;
494
+ const contentField = query.sort?.contentField;
495
+ if (contentField === void 0) {
496
+ const { conditions, params } = recordConditions(query);
497
+ const col = getSortColumn(getSortField(query));
498
+ if (decoded) {
499
+ const cursor2 = nativeCursor(decoded, getSortField(query));
500
+ conditions.push(`(r.${col} ${op} ? OR (r.${col} = ? AND r.id ${op} ?))`);
501
+ params.push(cursor2.value, cursor2.value, cursor2.id);
502
+ }
503
+ return [
504
+ statement("r.*", "records r", conditions, params, ` ORDER BY r.${col} ${dir}, r.id ${dir} LIMIT ?`)
505
+ ];
506
+ }
507
+ const cursor = decoded ? contentCursor(decoded, contentField) : null;
508
+ const pages = [];
509
+ if (cursor?.kind !== "absent") {
510
+ const { conditions, params } = recordConditions(query);
511
+ const from = `content_index ${SORT_ALIAS} JOIN records r ON r.id = ${SORT_ALIAS}.record_id`;
512
+ conditions.push(`${SORT_ALIAS}.field = ?`);
513
+ params.push(contentField);
514
+ if (cursor)
515
+ conditions.push(presentCursorCondition(cursor, op, params));
516
+ 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}`;
517
+ pages.push(statement("r.*", from, conditions, params, ` ORDER BY ${order} LIMIT ?`));
518
+ }
519
+ {
520
+ const { conditions, params } = recordConditions(query);
521
+ conditions.push(`NOT EXISTS (SELECT 1 FROM content_index ${SORT_ALIAS} WHERE ${SORT_ALIAS}.record_id = r.id AND ${SORT_ALIAS}.field = ?)`);
522
+ params.push(contentField);
523
+ if (cursor?.kind === "absent") {
524
+ conditions.push(`r.id ${op} ?`);
525
+ params.push(cursor.id);
526
+ }
527
+ pages.push(statement("r.*", "records r", conditions, params, ` ORDER BY r.id ${dir} LIMIT ?`));
506
528
  }
507
- return `ORDER BY (${SORT_ALIAS}.record_id IS NULL) ASC, (${SORT_ALIAS}.num_value IS NULL) ${dir}, ${SORT_ALIAS}.num_value ${dir}, ${SORT_ALIAS}.text_key ${dir}, ${SORT_ALIAS}.text_value ${dir}, r.id ${dir}`;
529
+ return pages;
508
530
  };
509
531
 
510
532
  // ../sqlite-shared/dist/mappers.js
@@ -519,23 +541,23 @@ var rowToRecord = (row, associations) => {
519
541
  content: JSON.parse(row.content),
520
542
  version: row.version
521
543
  };
522
- if (row.parent_id)
544
+ if (row.parent_id != null)
523
545
  record.parentId = row.parent_id;
524
- if (row.entity_id)
546
+ if (row.entity_id != null)
525
547
  record.entityId = row.entity_id;
526
- if (row.app_id)
548
+ if (row.app_id != null)
527
549
  record.appId = row.app_id;
528
- if (row.principal_id)
550
+ if (row.principal_id != null)
529
551
  record.principalId = row.principal_id;
530
- if (row.updated_by)
552
+ if (row.updated_by != null)
531
553
  record.updatedBy = row.updated_by;
532
- if (row.updated_via)
554
+ if (row.updated_via != null)
533
555
  record.updatedVia = row.updated_via;
534
- if (row.deleted_at)
556
+ if (row.deleted_at != null)
535
557
  record.deletedAt = fromMs(row.deleted_at);
536
- if (row.unlisted_at)
558
+ if (row.unlisted_at != null)
537
559
  record.unlistedAt = fromMs(row.unlisted_at);
538
- if (row.permissions)
560
+ if (row.permissions != null)
539
561
  record.permissions = JSON.parse(row.permissions);
540
562
  if (associations.length)
541
563
  record.associations = associations;
@@ -601,15 +623,17 @@ var rowToVersion = (row) => {
601
623
  content: JSON.parse(row.content),
602
624
  updatedAt: fromMs(row.updated_at)
603
625
  };
604
- if (row.entity_id)
626
+ if (row.entity_id != null)
605
627
  v.entityId = row.entity_id;
606
- if (row.updated_by)
628
+ if (row.updated_by != null)
607
629
  v.updatedBy = row.updated_by;
608
- if (row.updated_via)
630
+ if (row.updated_via != null)
609
631
  v.updatedVia = row.updated_via;
610
- if (row.associations)
632
+ if (row.parent_id != null)
633
+ v.parentId = row.parent_id;
634
+ if (row.associations != null)
611
635
  v.associations = JSON.parse(row.associations);
612
- if (row.permissions)
636
+ if (row.permissions != null)
613
637
  v.permissions = JSON.parse(row.permissions);
614
638
  return v;
615
639
  };
@@ -640,8 +664,7 @@ var asStackQueryError = (query, run) => {
640
664
  throw new StackQueryError(`Search text could not be parsed: ${err instanceof Error ? err.message : String(err)}`);
641
665
  }
642
666
  };
643
- var fileRefFieldNames = (schema) => Object.keys(schema).filter((field) => schema[field].kind === "file-ref");
644
- var sortableFieldKinds = (schema) => {
667
+ var indexedFieldKinds = (schema) => {
645
668
  const kinds = /* @__PURE__ */ new Map();
646
669
  for (const [field, def] of Object.entries(schema)) {
647
670
  if (!def.kind || def.kind === "array" || def.kind === "object")
@@ -653,13 +676,12 @@ var sortableFieldKinds = (schema) => {
653
676
  var SharedSqlRecordLogic = class {
654
677
  deps;
655
678
  /**
656
- * Per-typeId cache of file-ref field names, so syncFileRefs() doesn't
657
- * hit the `types` table and re-parse schema JSON on every write.
658
- * Populated eagerly in saveType() and lazily via getFileRefFields().
679
+ * Per-typeId cache of the fields content_index covers and their declared
680
+ * kinds, so syncContentIndex() doesn't hit the `types` table and
681
+ * re-parse schema JSON on every write. Populated eagerly in saveType()
682
+ * and lazily via getIndexedFields().
659
683
  */
660
- fileRefFieldsByType = /* @__PURE__ */ new Map();
661
- /** Per-typeId cache of sortable field kinds, as fileRefFieldsByType is. */
662
- sortFieldsByType = /* @__PURE__ */ new Map();
684
+ indexedFieldsByType = /* @__PURE__ */ new Map();
663
685
  constructor(deps) {
664
686
  this.deps = deps;
665
687
  }
@@ -740,8 +762,7 @@ var SharedSqlRecordLogic = class {
740
762
  this.insertAssociations(record.id, record.associations);
741
763
  }
742
764
  fts5Strategy.insert(this.exec, record.id, JSON.stringify(record.content));
743
- this.syncFileRefs(record.id, record.typeId, record.content);
744
- this.syncContentSort(record.id, record.typeId, record.content);
765
+ this.syncContentIndex(record.id, record.typeId, record.content);
745
766
  return record;
746
767
  }
747
768
  async getRecord(id) {
@@ -786,8 +807,7 @@ var SharedSqlRecordLogic = class {
786
807
  id
787
808
  ]);
788
809
  fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
789
- this.syncFileRefs(id, existing.typeId, merged);
790
- this.syncContentSort(id, existing.typeId, merged);
810
+ this.syncContentIndex(id, existing.typeId, merged);
791
811
  });
792
812
  const updated = await this.getRecord(id);
793
813
  if (!updated)
@@ -842,8 +862,7 @@ var SharedSqlRecordLogic = class {
842
862
  fts5Strategy.remove(this.exec, id);
843
863
  this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
844
864
  this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
845
- this.exec.run("DELETE FROM file_refs WHERE record_id = ?", [id]);
846
- this.exec.run("DELETE FROM content_sort WHERE record_id = ?", [id]);
865
+ this.exec.run("DELETE FROM content_index WHERE record_id = ?", [id]);
847
866
  this.exec.run("DELETE FROM records WHERE id = ?", [id]);
848
867
  return purged;
849
868
  }
@@ -904,6 +923,27 @@ var SharedSqlRecordLogic = class {
904
923
  throw new Error(`Record not found after setUnlisted: "${id}"`);
905
924
  return updated;
906
925
  }
926
+ async setParent(id, parentId, opts = {}) {
927
+ this.exec.transaction(() => {
928
+ if (opts.snapshot)
929
+ this.snapshotBeforeMutation(id, opts.snapshot);
930
+ const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
931
+ const changed = this.exec.run(`UPDATE records SET parent_id = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
932
+ parentId,
933
+ toMs(/* @__PURE__ */ new Date()),
934
+ opts.updatedBy ?? null,
935
+ opts.updatedVia ?? null,
936
+ id,
937
+ ...verParams
938
+ ]);
939
+ if (changed === 0)
940
+ this.throwVersionConflict(id, opts.expectedVersion);
941
+ });
942
+ const updated = await this.getRecord(id);
943
+ if (!updated)
944
+ throw new Error(`Record not found after setParent: "${id}"`);
945
+ return updated;
946
+ }
907
947
  async restoreVersion(id, version, opts = {}) {
908
948
  const existing = await this.getRecord(id);
909
949
  if (!existing)
@@ -916,12 +956,13 @@ var SharedSqlRecordLogic = class {
916
956
  if (opts.snapshot)
917
957
  this.snapshotBeforeMutation(id, opts.snapshot);
918
958
  fts5Strategy.remove(this.exec, id);
919
- this.exec.run(`UPDATE records SET type_id = ?, content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?`, [
959
+ this.exec.run(`UPDATE records SET type_id = ?, content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ?, parent_id = ? WHERE id = ?`, [
920
960
  target.typeId,
921
961
  JSON.stringify(target.content),
922
962
  toMs(/* @__PURE__ */ new Date()),
923
963
  opts.updatedBy ?? null,
924
964
  opts.updatedVia ?? null,
965
+ target.parentId ?? null,
925
966
  id
926
967
  ]);
927
968
  if (target.associations !== void 0) {
@@ -930,8 +971,7 @@ var SharedSqlRecordLogic = class {
930
971
  this.insertAssociations(id, target.associations);
931
972
  }
932
973
  fts5Strategy.insert(this.exec, id, JSON.stringify(target.content));
933
- this.syncFileRefs(id, target.typeId, target.content);
934
- this.syncContentSort(id, target.typeId, target.content);
974
+ this.syncContentIndex(id, target.typeId, target.content);
935
975
  });
936
976
  const updated = await this.getRecord(id);
937
977
  if (!updated)
@@ -956,8 +996,7 @@ var SharedSqlRecordLogic = class {
956
996
  id
957
997
  ]);
958
998
  fts5Strategy.insert(this.exec, id, JSON.stringify(content));
959
- this.syncFileRefs(id, toTypeId, content);
960
- this.syncContentSort(id, toTypeId, content);
999
+ this.syncContentIndex(id, toTypeId, content);
961
1000
  });
962
1001
  const updated = await this.getRecord(id);
963
1002
  if (!updated)
@@ -965,26 +1004,25 @@ var SharedSqlRecordLogic = class {
965
1004
  return updated;
966
1005
  }
967
1006
  async queryRecords(query) {
968
- const { sql: from, params: fromParams } = buildFromClause(query);
969
- const { sql: where, params: whereParams } = buildWhereClause(query);
970
- const params = [...fromParams, ...whereParams];
971
- const order = buildOrderClause(query);
972
1007
  const limit = query.limit ?? 50;
973
- const rows = asStackQueryError(query, () => this.exec.all(`SELECT r.* FROM ${from} ${where} ${order} LIMIT ?`, [
974
- ...params,
975
- limit + 1
976
- ]));
1008
+ const fetch = limit + 1;
1009
+ const rows = [];
1010
+ for (const page2 of buildQueryPlan(query)) {
1011
+ const budget = fetch - rows.length;
1012
+ if (budget <= 0)
1013
+ break;
1014
+ const statement2 = atBudget(page2, budget);
1015
+ rows.push(...asStackQueryError(query, () => this.exec.all(statement2.sql, statement2.params)));
1016
+ }
977
1017
  const hasMore = rows.length > limit;
978
1018
  const page = hasMore ? rows.slice(0, limit) : rows;
979
1019
  const records = page.map((row) => {
980
1020
  const associations = this.getAssociationsForRecord(row.id);
981
1021
  return rowToRecord(row, associations);
982
1022
  });
983
- const countRows = asStackQueryError(query, () => this.exec.all(`SELECT COUNT(*) as total FROM ${from} ${where}`, params));
984
- const total = countRows[0]?.total ?? 0;
985
1023
  const lastRecord = records[records.length - 1];
986
1024
  const cursor = hasMore && lastRecord ? makeCursor(lastRecord, query.sort, (record, field) => this.sortFieldKind(record, field)) : null;
987
- return { records, cursor, total };
1025
+ return { records, cursor };
988
1026
  }
989
1027
  /**
990
1028
  * Atomically verify fileId is unreferenced, then hard-delete its
@@ -997,7 +1035,7 @@ var SharedSqlRecordLogic = class {
997
1035
  return this.exec.transaction(() => {
998
1036
  const referenced = this.exec.all(`SELECT 1 as found FROM associations WHERE kind = 'attachment' AND file_id = ?
999
1037
  UNION ALL
1000
- SELECT 1 FROM file_refs WHERE file_id = ?
1038
+ SELECT 1 FROM content_index WHERE file_id = ?
1001
1039
  LIMIT 1`, [fileId, fileId]);
1002
1040
  if (referenced.length) {
1003
1041
  throw new StackConflictError("Attachment is still referenced by one or more records");
@@ -1037,8 +1075,8 @@ var SharedSqlRecordLogic = class {
1037
1075
  try {
1038
1076
  this.exec.run(`INSERT INTO versions
1039
1077
  (record_id, version, type_id, content, updated_at, entity_id,
1040
- updated_by, updated_via, associations, permissions)
1041
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1078
+ updated_by, updated_via, parent_id, associations, permissions)
1079
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1042
1080
  id,
1043
1081
  version.version,
1044
1082
  version.typeId,
@@ -1047,6 +1085,7 @@ var SharedSqlRecordLogic = class {
1047
1085
  version.entityId ?? null,
1048
1086
  version.updatedBy ?? null,
1049
1087
  version.updatedVia ?? null,
1088
+ version.parentId ?? null,
1050
1089
  version.associations ? JSON.stringify(version.associations) : null,
1051
1090
  version.permissions ? JSON.stringify(version.permissions) : null
1052
1091
  ]);
@@ -1057,14 +1096,24 @@ var SharedSqlRecordLogic = class {
1057
1096
  throw err;
1058
1097
  }
1059
1098
  }
1099
+ /**
1100
+ * Replaces every column insertVersionRow writes, so an overwritten row is
1101
+ * indistinguishable from a freshly inserted one — a column left out here
1102
+ * would keep the replaced row's value and surface later as a restore
1103
+ * putting back something the snapshot never said.
1104
+ */
1060
1105
  overwriteVersionRow(id, version) {
1061
1106
  this.exec.run(`UPDATE versions
1062
- SET type_id = ?, content = ?, updated_at = ?, entity_id = ?, associations = ?, permissions = ?
1107
+ SET type_id = ?, content = ?, updated_at = ?, entity_id = ?,
1108
+ updated_by = ?, updated_via = ?, parent_id = ?, associations = ?, permissions = ?
1063
1109
  WHERE record_id = ? AND version = ?`, [
1064
1110
  version.typeId,
1065
1111
  JSON.stringify(version.content),
1066
1112
  toMs(version.updatedAt),
1067
1113
  version.entityId ?? null,
1114
+ version.updatedBy ?? null,
1115
+ version.updatedVia ?? null,
1116
+ version.parentId ?? null,
1068
1117
  version.associations ? JSON.stringify(version.associations) : null,
1069
1118
  version.permissions ? JSON.stringify(version.permissions) : null,
1070
1119
  id,
@@ -1117,8 +1166,7 @@ var SharedSqlRecordLogic = class {
1117
1166
  toMs(type.createdAt)
1118
1167
  ]);
1119
1168
  const schema = type.schema;
1120
- this.fileRefFieldsByType.set(type.id, fileRefFieldNames(schema));
1121
- this.sortFieldsByType.set(type.id, sortableFieldKinds(schema));
1169
+ this.indexedFieldsByType.set(type.id, indexedFieldKinds(schema));
1122
1170
  }
1123
1171
  async getType(id) {
1124
1172
  const rows = this.exec.all("SELECT * FROM types WHERE id = ?", [id]);
@@ -1194,67 +1242,49 @@ var SharedSqlRecordLogic = class {
1194
1242
  return rows.map(rowToAssociation);
1195
1243
  }
1196
1244
  /**
1197
- * File-ref field names for typeId, from the in-memory cache — falling
1198
- * back to a `types` lookup (and populating the cache) only for a typeId
1199
- * this process hasn't seen via saveType() yet, e.g. right after open().
1200
- */
1201
- getFileRefFields(typeId) {
1202
- const cached = this.fileRefFieldsByType.get(typeId);
1203
- if (cached)
1204
- return cached;
1205
- const typeRow = this.exec.get("SELECT schema FROM types WHERE id = ?", [
1206
- typeId
1207
- ]);
1208
- const fields = typeRow ? fileRefFieldNames(JSON.parse(typeRow.schema)) : [];
1209
- this.fileRefFieldsByType.set(typeId, fields);
1210
- return fields;
1211
- }
1212
- /**
1213
- * Replace a record's file_refs rows with whatever its content currently
1214
- * holds in top-level file-ref fields, on every write that can change
1215
- * content or typeId, so the index never drifts. Only top-level scalars
1216
- * are indexed; the schema lookup is cached (fileRefFieldsByType).
1245
+ * The fields content_index covers for typeId, from the in-memory cache
1246
+ * — falling back to a `types` lookup (and populating the cache) only for
1247
+ * a typeId this process hasn't seen via saveType() yet, e.g. right after
1248
+ * open().
1217
1249
  */
1218
- getSortFields(typeId) {
1219
- const cached = this.sortFieldsByType.get(typeId);
1250
+ getIndexedFields(typeId) {
1251
+ const cached = this.indexedFieldsByType.get(typeId);
1220
1252
  if (cached)
1221
1253
  return cached;
1222
1254
  const typeRow = this.exec.get("SELECT schema FROM types WHERE id = ?", [
1223
1255
  typeId
1224
1256
  ]);
1225
- const fields = typeRow ? sortableFieldKinds(JSON.parse(typeRow.schema)) : /* @__PURE__ */ new Map();
1226
- this.sortFieldsByType.set(typeId, fields);
1257
+ const fields = typeRow ? indexedFieldKinds(JSON.parse(typeRow.schema)) : /* @__PURE__ */ new Map();
1258
+ this.indexedFieldsByType.set(typeId, fields);
1227
1259
  return fields;
1228
1260
  }
1229
1261
  /**
1230
- * Replace a record's content_sort rows with what its content currently
1231
- * holds, on every write that can change content or typeId. A field
1232
- * holding no orderable value gets no row at all, which is what puts the
1233
- * record at the end of a sort on that field.
1262
+ * Replace a record's content_index rows with what its content currently
1263
+ * holds, on every write that can change content or typeId, so neither
1264
+ * the ordering nor the file-reference lookup can drift from the record.
1265
+ * A field holding no orderable value gets no row at all, which is what
1266
+ * puts the record at the end of a sort on that field.
1234
1267
  */
1235
- syncContentSort(recordId, typeId, content) {
1236
- this.exec.run("DELETE FROM content_sort WHERE record_id = ?", [recordId]);
1237
- for (const [field, kind] of this.getSortFields(typeId)) {
1268
+ syncContentIndex(recordId, typeId, content) {
1269
+ this.exec.run("DELETE FROM content_index WHERE record_id = ?", [recordId]);
1270
+ for (const [field, kind] of this.getIndexedFields(typeId)) {
1238
1271
  const entry = contentSortEntry(kind, content[field]);
1239
1272
  if (!entry)
1240
1273
  continue;
1241
- this.exec.run(`INSERT OR IGNORE INTO content_sort (record_id, field, num_value, text_key, text_value)
1242
- VALUES (?, ?, ?, ?, ?)`, entry.kind === "num" ? [recordId, field, entry.num, null, null] : [recordId, field, null, entry.key, entry.text]);
1274
+ const sql = `INSERT OR IGNORE INTO content_index
1275
+ (record_id, field, value_rank, num_value, text_key, text_value, file_id)
1276
+ VALUES (?, ?, ?, ?, ?, ?, ?)`;
1277
+ if (entry.kind === "num") {
1278
+ this.exec.run(sql, [recordId, field, 0, entry.num, null, null, null]);
1279
+ } else {
1280
+ const fileId = kind === "file-ref" ? entry.text : null;
1281
+ this.exec.run(sql, [recordId, field, 1, null, entry.key, entry.text, fileId]);
1282
+ }
1243
1283
  }
1244
1284
  }
1245
1285
  /** The declared kind of a record's top-level `field`, if it has one. */
1246
1286
  sortFieldKind(record, field) {
1247
- return this.getSortFields(record.typeId).get(field);
1248
- }
1249
- syncFileRefs(recordId, typeId, content) {
1250
- this.exec.run("DELETE FROM file_refs WHERE record_id = ?", [recordId]);
1251
- const fields = this.getFileRefFields(typeId);
1252
- for (const field of fields) {
1253
- const value = content[field];
1254
- if (typeof value === "string") {
1255
- this.exec.run("INSERT OR IGNORE INTO file_refs (record_id, field, file_id) VALUES (?, ?, ?)", [recordId, field, value]);
1256
- }
1257
- }
1287
+ return this.getIndexedFields(record.typeId).get(field);
1258
1288
  }
1259
1289
  };
1260
1290
 
@@ -1364,6 +1394,9 @@ var DoSQLiteRecordAdapter = class _DoSQLiteRecordAdapter {
1364
1394
  setUnlisted(id, unlisted, opts) {
1365
1395
  return this.record.setUnlisted(id, unlisted, opts);
1366
1396
  }
1397
+ setParent(id, parentId, opts) {
1398
+ return this.record.setParent(id, parentId, opts);
1399
+ }
1367
1400
  restoreVersion(id, version, opts) {
1368
1401
  return this.record.restoreVersion(id, version, opts);
1369
1402
  }