@haverstack/record-adapter-sqlite 0.18.0 → 0.20.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
@@ -32,6 +32,10 @@ var RECORD_SCHEMA_SQL = `
32
32
  -- qualifier: related_stack for a record in another stack, related_ns for
33
33
  -- a foreign namespace. All four are in the primary key, so two targets
34
34
  -- differing only by namespace are two associations.
35
+ --
36
+ -- The primary key leads with record_id, so its implicit index already
37
+ -- serves every by-record read and delete; the indexes below exist only
38
+ -- for the filter directions it cannot answer.
35
39
  CREATE TABLE IF NOT EXISTS associations (
36
40
  record_id TEXT NOT NULL REFERENCES records(id),
37
41
  kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship')),
@@ -44,6 +48,9 @@ var RECORD_SCHEMA_SQL = `
44
48
  PRIMARY KEY (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack)
45
49
  ) STRICT;
46
50
 
51
+ -- parent_id holds the container the record sat in, NULL for the root --
52
+ -- the same encoding records.parent_id uses, since a snapshot states
53
+ -- where the record was rather than an instruction to apply.
47
54
  CREATE TABLE IF NOT EXISTS versions (
48
55
  record_id TEXT NOT NULL REFERENCES records(id),
49
56
  version INTEGER NOT NULL,
@@ -53,6 +60,7 @@ var RECORD_SCHEMA_SQL = `
53
60
  entity_id TEXT,
54
61
  updated_by TEXT,
55
62
  updated_via TEXT,
63
+ parent_id TEXT,
56
64
  associations TEXT CHECK (associations IS NULL OR json_valid(associations)),
57
65
  permissions TEXT CHECK (permissions IS NULL OR json_valid(permissions)),
58
66
  PRIMARY KEY (record_id, version)
@@ -69,51 +77,58 @@ var RECORD_SCHEMA_SQL = `
69
77
  created_at INTEGER NOT NULL
70
78
  ) STRICT;
71
79
 
72
- -- One row per top-level file-ref content field on a record, kept in sync
73
- -- on every content/typeId write. Lets the attachmentFileId query filter
74
- -- and deleteAttachment()'s reference check see content-held file
75
- -- references, not just attachment associations.
76
- CREATE TABLE IF NOT EXISTS file_refs (
77
- record_id TEXT NOT NULL REFERENCES records(id),
78
- field TEXT NOT NULL,
79
- file_id TEXT NOT NULL,
80
- PRIMARY KEY (record_id, field)
81
- ) STRICT;
82
-
83
- -- One row per top-level scalar content field on a record, rewritten
84
- -- beside file_refs on every content/typeId write. Exactly one of
85
- -- num_value / text_key+text_value is set, by the field's declared kind:
86
- -- what a field orders as must not vary with the value a given record
87
- -- happens to hold. text_key is the folded form ordering compares
88
- -- (@haverstack/core's contentSortKey), text_value the stored one, which
89
- -- breaks a tie between two values that fold together.
90
- CREATE TABLE IF NOT EXISTS content_sort (
80
+ -- One row per top-level scalar content field a record holds a value at:
81
+ -- what it orders as, and the file it references. See
82
+ -- docs/spec/adapters.md \xA7 Adapter backends and
83
+ -- docs/spec/data-model.md \xA7 Sorting by a content field.
84
+ --
85
+ -- value_rank is 0 for a numeric value and 1 for a text one \u2014 the same
86
+ -- split as "num_value IS NULL", but non-null, so one index can carry
87
+ -- the whole ordering. file_id is set only for a 'file-ref' field.
88
+ CREATE TABLE IF NOT EXISTS content_index (
91
89
  record_id TEXT NOT NULL REFERENCES records(id),
92
90
  field TEXT NOT NULL,
91
+ value_rank INTEGER NOT NULL CHECK (value_rank IN (0, 1)),
93
92
  num_value REAL,
94
93
  text_key TEXT,
95
94
  text_value TEXT,
95
+ file_id TEXT,
96
96
  PRIMARY KEY (record_id, field)
97
97
  ) STRICT;
98
98
 
99
- -- Indexes
100
- CREATE INDEX IF NOT EXISTS idx_records_type_id ON records(type_id);
101
- CREATE INDEX IF NOT EXISTS idx_records_parent_id ON records(parent_id);
102
- CREATE INDEX IF NOT EXISTS idx_records_entity_id ON records(entity_id);
103
- CREATE INDEX IF NOT EXISTS idx_records_app_id ON records(app_id);
104
- CREATE INDEX IF NOT EXISTS idx_records_principal_id ON records(principal_id);
105
- CREATE INDEX IF NOT EXISTS idx_records_deleted_at ON records(deleted_at);
106
- CREATE INDEX IF NOT EXISTS idx_records_unlisted_at ON records(unlisted_at);
107
- CREATE INDEX IF NOT EXISTS idx_records_created_at ON records(created_at);
108
- CREATE INDEX IF NOT EXISTS idx_records_updated_at ON records(updated_at);
109
- CREATE INDEX IF NOT EXISTS idx_assoc_record_id ON associations(record_id);
110
- CREATE INDEX IF NOT EXISTS idx_assoc_kind_label ON associations(kind, label);
111
- CREATE INDEX IF NOT EXISTS idx_assoc_kind_file_id ON associations(kind, file_id);
112
- CREATE INDEX IF NOT EXISTS idx_assoc_related ON associations(related_scope, related_ns, related_id);
113
- CREATE INDEX IF NOT EXISTS idx_types_base_id ON types(base_id);
114
- CREATE INDEX IF NOT EXISTS idx_file_refs_file_id ON file_refs(file_id);
115
- CREATE INDEX IF NOT EXISTS idx_content_sort_num ON content_sort(field, num_value, record_id);
116
- CREATE INDEX IF NOT EXISTS idx_content_sort_text ON content_sort(field, text_key, text_value, record_id);
99
+ -- Indexes. Each filter column continues into (created_at, id) \u2014 the
100
+ -- default ordering and its tiebreak \u2014 because these columns are
101
+ -- low-cardinality by design, and a narrow index on one leaves the
102
+ -- planner sorting the whole matching set before it can honor LIMIT.
103
+ --
104
+ -- deleted_at and unlisted_at deliberately have none: nothing searches
105
+ -- for the rows they mark, and an index on a column every query tests
106
+ -- for IS NULL only lures the planner off the one answering ORDER BY.
107
+ CREATE INDEX IF NOT EXISTS idx_records_type_id ON records(type_id, created_at, id);
108
+ CREATE INDEX IF NOT EXISTS idx_records_parent_id ON records(parent_id, created_at, id);
109
+ CREATE INDEX IF NOT EXISTS idx_records_entity_id ON records(entity_id, created_at, id);
110
+ CREATE INDEX IF NOT EXISTS idx_records_app_id ON records(app_id, created_at, id);
111
+ CREATE INDEX IF NOT EXISTS idx_records_principal_id ON records(principal_id, created_at, id);
112
+ CREATE INDEX IF NOT EXISTS idx_records_created_at ON records(created_at, id);
113
+ CREATE INDEX IF NOT EXISTS idx_records_updated_at ON records(updated_at, id);
114
+ CREATE INDEX IF NOT EXISTS idx_records_version ON records(version, id);
115
+
116
+ -- Each association index ends with record_id so the semi-joins in
117
+ -- query.ts read the matching record ids out of the index alone.
118
+ -- idx_assoc_related carries related_ns ahead of related_id because a
119
+ -- record- or entity-scoped target always stores '' there, which
120
+ -- buildQueryPlan states explicitly to keep the prefix contiguous.
121
+ CREATE INDEX IF NOT EXISTS idx_assoc_kind_label ON associations(kind, label, record_id);
122
+ CREATE INDEX IF NOT EXISTS idx_assoc_kind_file_id ON associations(kind, file_id, record_id);
123
+ CREATE INDEX IF NOT EXISTS idx_assoc_related ON associations(kind, related_scope, related_ns, related_id, record_id);
124
+ CREATE INDEX IF NOT EXISTS idx_types_base_id ON types(base_id, version);
125
+
126
+ -- The whole content ordering, in index order, for one field at a time:
127
+ -- a content-sorted page is an ordered walk of a slice of this index.
128
+ CREATE INDEX IF NOT EXISTS idx_content_index_sort
129
+ ON content_index(field, value_rank, num_value, text_key, text_value, record_id);
130
+ CREATE INDEX IF NOT EXISTS idx_content_index_file
131
+ ON content_index(file_id, record_id) WHERE file_id IS NOT NULL;
117
132
  `;
118
133
  var TOKENS_SCHEMA_SQL = `
119
134
  CREATE TABLE IF NOT EXISTS tokens (
@@ -125,14 +140,13 @@ var TOKENS_SCHEMA_SQL = `
125
140
  created_at INTEGER NOT NULL,
126
141
  expires_at INTEGER
127
142
  ) STRICT;
128
-
129
- CREATE INDEX IF NOT EXISTS idx_tokens_hash ON tokens(token_hash);
130
143
  `;
131
144
  var FTS5_SCHEMA_SQL = `
132
145
  CREATE VIRTUAL TABLE IF NOT EXISTS records_fts USING fts5(
133
146
  content,
134
147
  content='records',
135
- content_rowid='rowid'
148
+ content_rowid='rowid',
149
+ columnsize=0
136
150
  );
137
151
  `;
138
152
  var PRAGMA_FOREIGN_KEYS_ON = `PRAGMA foreign_keys = ON;`;
@@ -205,14 +219,16 @@ var makeCursor = (record, sort, kindOf) => {
205
219
 
206
220
  // ../sqlite-shared/dist/fts5.js
207
221
  var OUTSIDE_PHRASE_DISALLOWED = /[^\p{L}\p{N}\p{M}_\s()"]/gu;
222
+ var collapseWhitespace = (query) => query.replace(/\s+/g, " ");
208
223
  var sanitizeFts5Query = (query, maxDepth = 2) => {
209
224
  if (!query)
210
225
  return "";
211
226
  let clean = query.replace(/[\p{Cc}\p{Cf}]/gu, " ");
212
227
  clean = clean.replace(/\*/g, "");
213
- clean = clean.replace(/\bNEAR\s*\(\s*([^,)]*)(?:,[^)]*)?\)/gi, "$1");
228
+ clean = clean.replace(/\bNEAR\s*\(([^,()]*)(?:,[^()]*)?\)/gi, "$1");
214
229
  clean = clean.replace(/\bNEAR\s*\(/gi, "(");
215
230
  clean = clean.replace(/"[^"]*"|[^"]+/g, (span) => span.startsWith('"') ? span : span.replace(OUTSIDE_PHRASE_DISALLOWED, " "));
231
+ clean = collapseWhitespace(clean);
216
232
  clean = clean.replace(/(?:^|\(\s*)NOT\s+/gi, (m) => m.replace(/NOT\s+/i, ""));
217
233
  let currentDepth = 0;
218
234
  let inPhrase = false;
@@ -246,12 +262,11 @@ var sanitizeFts5Query = (query, maxDepth = 2) => {
246
262
  let prev;
247
263
  do {
248
264
  prev = result;
249
- result = result.replace(/""/g, " ");
250
- result = result.replace(/\(\s*\)/g, " ");
265
+ result = result.replace(/(?:""|\(\s*\))+/g, " ");
251
266
  result = dropDanglingOperators(result);
252
267
  result = restoreImplicitAnd(result);
253
268
  } while (result !== prev);
254
- return result.replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim();
269
+ return collapseWhitespace(result).replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim();
255
270
  };
256
271
  var OPERATOR = String.raw`(?:AND|OR|NOT)`;
257
272
  var PHRASE_HOLE = "\uE000";
@@ -262,7 +277,7 @@ var dropDanglingOperators = (query) => withPhrasesMasked(query, (masked) => {
262
277
  prev = out;
263
278
  out = out.replace(new RegExp(String.raw`\b(${OPERATOR})\b(?:\s+\b${OPERATOR}\b)+`, "gi"), "$1");
264
279
  out = out.replace(new RegExp(String.raw`(^|\()\s*\b${OPERATOR}\b\s*`, "gi"), "$1");
265
- out = out.replace(new RegExp(String.raw`\s*\b${OPERATOR}\b\s*($|\))`, "gi"), "$1");
280
+ out = out.replace(new RegExp(String.raw`\b${OPERATOR}\b\s*($|\))`, "gi"), "$1");
266
281
  } while (out !== prev);
267
282
  return out;
268
283
  });
@@ -280,7 +295,7 @@ var isOperator = (word) => /^(?:AND|OR|NOT)$/i.test(word);
280
295
  var restoreImplicitAnd = (query) => withPhrasesMasked(query, (masked) => masked.replace(new RegExp(String.raw`\)\s*(?=${OPERAND_CHAR}|\()`, "gu"), (match, offset, whole) => {
281
296
  const next = /^\s*([\p{L}\p{N}_]+)/u.exec(whole.slice(offset + match.length));
282
297
  return next && isOperator(next[1]) ? match : ") AND ";
283
- }).replace(new RegExp(String.raw`(${OPERAND_CHAR}+)\s*(?=\()`, "gu"), (match, word) => isOperator(word) ? match : `${word} AND `));
298
+ }).replace(new RegExp(String.raw`(?<!${OPERAND_CHAR})(${OPERAND_CHAR}+)\s*(?=\()`, "gu"), (match, word) => isOperator(word) ? match : `${word} AND `));
284
299
  var fts5Strategy = {
285
300
  /** Index a record that has no FTS entry yet (used right after an INSERT). */
286
301
  insert(exec, recordId, content) {
@@ -305,6 +320,10 @@ var fts5Strategy = {
305
320
  };
306
321
 
307
322
  // ../sqlite-shared/dist/query.js
323
+ var atBudget = (page, rows) => ({
324
+ sql: page.sql,
325
+ params: [...page.params, rows]
326
+ });
308
327
  var sqlDirection = (query) => {
309
328
  const dir = query.sort?.direction ?? "desc";
310
329
  if (dir !== "asc" && dir !== "desc") {
@@ -333,49 +352,18 @@ var contentPathExists = (segments, leaf, params, nextAlias) => {
333
352
  };
334
353
  var equalsLeaf = (alias) => `${alias}.type NOT IN ('object', 'array') AND ${alias}.value = ?`;
335
354
  var SORT_ALIAS = "cs";
336
- var buildFromClause = (query) => {
337
- const field = query.sort?.contentField;
338
- if (field === void 0)
339
- return { sql: "records r", params: [] };
340
- return {
341
- sql: `records r LEFT JOIN content_sort ${SORT_ALIAS} ON ${SORT_ALIAS}.record_id = r.id AND ${SORT_ALIAS}.field = ?`,
342
- params: [field]
343
- };
344
- };
345
- var assertCursorMatchesSort = (cursor, query) => {
346
- const contentField = query.sort?.contentField;
347
- if (cursor.kind === "native") {
348
- const sortField = getSortField(query);
349
- if (contentField !== void 0 || cursor.field !== sortField) {
350
- throw new StackQueryError(`Cursor sort field "${cursor.field}" does not match query sort field "${contentField ?? sortField}"`);
351
- }
352
- return;
353
- }
354
- if (contentField === void 0 || cursor.field !== contentField) {
355
- throw new StackQueryError(`Cursor sort field "${cursor.field}" does not match query sort field "${contentField ?? getSortField(query)}"`);
356
- }
355
+ var cursorMismatch = (cursor, field) => new StackQueryError(`Cursor sort field "${cursor.field}" does not match query sort field "${field}"`);
356
+ var nativeCursor = (cursor, field) => {
357
+ if (cursor.kind !== "native" || cursor.field !== field)
358
+ throw cursorMismatch(cursor, field);
359
+ return cursor;
357
360
  };
358
- var contentCursorCondition = (cursor, op, params) => {
359
- const ascending = op === ">";
360
- const numRank = ascending ? 0 : 1;
361
- const textRank = ascending ? 1 : 0;
362
- const rank = `CASE WHEN ${SORT_ALIAS}.record_id IS NULL THEN 2 WHEN ${SORT_ALIAS}.num_value IS NULL THEN ${textRank} ELSE ${numRank} END`;
363
- const cursorRank = cursor.kind === "absent" ? 2 : cursor.kind === "num" ? numRank : textRank;
364
- let tail;
365
- if (cursor.kind === "num") {
366
- tail = `(${SORT_ALIAS}.num_value ${op} ? OR (${SORT_ALIAS}.num_value = ? AND r.id ${op} ?))`;
367
- params.push(cursor.value, cursor.value, cursor.id);
368
- } else if (cursor.kind === "text") {
369
- const key = contentSortKey(cursor.value);
370
- 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} ?))))`;
371
- params.push(key, key, cursor.value, cursor.value, cursor.id);
372
- } else {
373
- tail = `r.id ${op} ?`;
374
- params.push(cursor.id);
375
- }
376
- return `((${rank}) > ${cursorRank} OR ((${rank}) = ${cursorRank} AND ${tail}))`;
361
+ var contentCursor = (cursor, field) => {
362
+ if (cursor.kind === "native" || cursor.field !== field)
363
+ throw cursorMismatch(cursor, field);
364
+ return cursor;
377
365
  };
378
- var buildWhereClause = (query) => {
366
+ var recordConditions = (query) => {
379
367
  const conditions = ["r.id != '_config'"];
380
368
  const params = [];
381
369
  const f = query.filter ?? {};
@@ -441,7 +429,7 @@ var buildWhereClause = (query) => {
441
429
  }
442
430
  if (f.attachmentFileId) {
443
431
  conditions.push(`(r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'attachment' AND a.file_id = ?)
444
- OR r.id IN (SELECT fr.record_id FROM file_refs fr WHERE fr.file_id = ?))`);
432
+ OR r.id IN (SELECT ci.record_id FROM content_index ci WHERE ci.file_id = ?))`);
445
433
  params.push(f.attachmentFileId, f.attachmentFileId);
446
434
  }
447
435
  if (f.relatedTo) {
@@ -455,11 +443,11 @@ var buildWhereClause = (query) => {
455
443
  clauses.push("a.related_scope = ?");
456
444
  params.push(target.scope);
457
445
  if (target.scope === "record") {
458
- clauses.push("a.related_id = ?", "a.related_stack = ?");
459
- params.push(target.recordId, target.stackUrl ?? "");
446
+ clauses.push("a.related_ns = ?", "a.related_id = ?", "a.related_stack = ?");
447
+ params.push("", target.recordId, target.stackUrl ?? "");
460
448
  } else if (target.scope === "entity") {
461
- clauses.push("a.related_id = ?");
462
- params.push(target.entityId);
449
+ clauses.push("a.related_ns = ?", "a.related_id = ?");
450
+ params.push("", target.entityId);
463
451
  } else {
464
452
  clauses.push("a.related_ns = ?");
465
453
  params.push(target.ns);
@@ -503,29 +491,61 @@ var buildWhereClause = (query) => {
503
491
  conditions.push("0");
504
492
  }
505
493
  }
506
- if (query.cursor) {
507
- const cursor = decodeCursor(query.cursor);
508
- assertCursorMatchesSort(cursor, query);
509
- const op = sqlDirection(query) === "ASC" ? ">" : "<";
510
- if (cursor.kind === "native") {
511
- const col = getSortColumn(cursor.field);
512
- conditions.push(`(r.${col} ${op} ? OR (r.${col} = ? AND r.id ${op} ?))`);
513
- params.push(cursor.value, cursor.value, cursor.id);
514
- } else {
515
- conditions.push(contentCursorCondition(cursor, op, params));
516
- }
494
+ return { conditions, params };
495
+ };
496
+ var presentCursorCondition = (cursor, op, params) => {
497
+ if (cursor.kind === "num") {
498
+ params.push(cursor.value, cursor.value, cursor.id);
499
+ 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} ?))))`;
517
500
  }
518
- return {
519
- sql: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "",
520
- params
521
- };
501
+ const key = contentSortKey(cursor.value);
502
+ params.push(key, key, cursor.value, cursor.value, cursor.id);
503
+ 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} ?))))))`;
522
504
  };
523
- var buildOrderClause = (query) => {
505
+ var statement = (select, from, conditions, params, tail = "") => ({
506
+ sql: `SELECT ${select} FROM ${from} WHERE ${conditions.join(" AND ")}${tail}`,
507
+ params
508
+ });
509
+ var buildQueryPlan = (query) => {
524
510
  const dir = sqlDirection(query);
525
- if (query.sort?.contentField === void 0) {
526
- return `ORDER BY r.${getSortColumn(getSortField(query))} ${dir}, r.id ${dir}`;
511
+ const op = dir === "ASC" ? ">" : "<";
512
+ const decoded = query.cursor ? decodeCursor(query.cursor) : null;
513
+ const contentField = query.sort?.contentField;
514
+ if (contentField === void 0) {
515
+ const { conditions, params } = recordConditions(query);
516
+ const col = getSortColumn(getSortField(query));
517
+ if (decoded) {
518
+ const cursor2 = nativeCursor(decoded, getSortField(query));
519
+ conditions.push(`(r.${col} ${op} ? OR (r.${col} = ? AND r.id ${op} ?))`);
520
+ params.push(cursor2.value, cursor2.value, cursor2.id);
521
+ }
522
+ return [
523
+ statement("r.*", "records r", conditions, params, ` ORDER BY r.${col} ${dir}, r.id ${dir} LIMIT ?`)
524
+ ];
525
+ }
526
+ const cursor = decoded ? contentCursor(decoded, contentField) : null;
527
+ const pages = [];
528
+ if (cursor?.kind !== "absent") {
529
+ const { conditions, params } = recordConditions(query);
530
+ const from = `content_index ${SORT_ALIAS} JOIN records r ON r.id = ${SORT_ALIAS}.record_id`;
531
+ conditions.push(`${SORT_ALIAS}.field = ?`);
532
+ params.push(contentField);
533
+ if (cursor)
534
+ conditions.push(presentCursorCondition(cursor, op, params));
535
+ 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}`;
536
+ pages.push(statement("r.*", from, conditions, params, ` ORDER BY ${order} LIMIT ?`));
537
+ }
538
+ {
539
+ const { conditions, params } = recordConditions(query);
540
+ conditions.push(`NOT EXISTS (SELECT 1 FROM content_index ${SORT_ALIAS} WHERE ${SORT_ALIAS}.record_id = r.id AND ${SORT_ALIAS}.field = ?)`);
541
+ params.push(contentField);
542
+ if (cursor?.kind === "absent") {
543
+ conditions.push(`r.id ${op} ?`);
544
+ params.push(cursor.id);
545
+ }
546
+ pages.push(statement("r.*", "records r", conditions, params, ` ORDER BY r.id ${dir} LIMIT ?`));
527
547
  }
528
- 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}`;
548
+ return pages;
529
549
  };
530
550
 
531
551
  // ../sqlite-shared/dist/mappers.js
@@ -540,23 +560,23 @@ var rowToRecord = (row, associations) => {
540
560
  content: JSON.parse(row.content),
541
561
  version: row.version
542
562
  };
543
- if (row.parent_id)
563
+ if (row.parent_id != null)
544
564
  record.parentId = row.parent_id;
545
- if (row.entity_id)
565
+ if (row.entity_id != null)
546
566
  record.entityId = row.entity_id;
547
- if (row.app_id)
567
+ if (row.app_id != null)
548
568
  record.appId = row.app_id;
549
- if (row.principal_id)
569
+ if (row.principal_id != null)
550
570
  record.principalId = row.principal_id;
551
- if (row.updated_by)
571
+ if (row.updated_by != null)
552
572
  record.updatedBy = row.updated_by;
553
- if (row.updated_via)
573
+ if (row.updated_via != null)
554
574
  record.updatedVia = row.updated_via;
555
- if (row.deleted_at)
575
+ if (row.deleted_at != null)
556
576
  record.deletedAt = fromMs(row.deleted_at);
557
- if (row.unlisted_at)
577
+ if (row.unlisted_at != null)
558
578
  record.unlistedAt = fromMs(row.unlisted_at);
559
- if (row.permissions)
579
+ if (row.permissions != null)
560
580
  record.permissions = JSON.parse(row.permissions);
561
581
  if (associations.length)
562
582
  record.associations = associations;
@@ -622,15 +642,17 @@ var rowToVersion = (row) => {
622
642
  content: JSON.parse(row.content),
623
643
  updatedAt: fromMs(row.updated_at)
624
644
  };
625
- if (row.entity_id)
645
+ if (row.entity_id != null)
626
646
  v.entityId = row.entity_id;
627
- if (row.updated_by)
647
+ if (row.updated_by != null)
628
648
  v.updatedBy = row.updated_by;
629
- if (row.updated_via)
649
+ if (row.updated_via != null)
630
650
  v.updatedVia = row.updated_via;
631
- if (row.associations)
651
+ if (row.parent_id != null)
652
+ v.parentId = row.parent_id;
653
+ if (row.associations != null)
632
654
  v.associations = JSON.parse(row.associations);
633
- if (row.permissions)
655
+ if (row.permissions != null)
634
656
  v.permissions = JSON.parse(row.permissions);
635
657
  return v;
636
658
  };
@@ -701,8 +723,7 @@ var asStackQueryError = (query, run) => {
701
723
  throw new StackQueryError(`Search text could not be parsed: ${err instanceof Error ? err.message : String(err)}`);
702
724
  }
703
725
  };
704
- var fileRefFieldNames = (schema) => Object.keys(schema).filter((field) => schema[field].kind === "file-ref");
705
- var sortableFieldKinds = (schema) => {
726
+ var indexedFieldKinds = (schema) => {
706
727
  const kinds = /* @__PURE__ */ new Map();
707
728
  for (const [field, def] of Object.entries(schema)) {
708
729
  if (!def.kind || def.kind === "array" || def.kind === "object")
@@ -714,13 +735,12 @@ var sortableFieldKinds = (schema) => {
714
735
  var SharedSqlRecordLogic = class {
715
736
  deps;
716
737
  /**
717
- * Per-typeId cache of file-ref field names, so syncFileRefs() doesn't
718
- * hit the `types` table and re-parse schema JSON on every write.
719
- * Populated eagerly in saveType() and lazily via getFileRefFields().
738
+ * Per-typeId cache of the fields content_index covers and their declared
739
+ * kinds, so syncContentIndex() doesn't hit the `types` table and
740
+ * re-parse schema JSON on every write. Populated eagerly in saveType()
741
+ * and lazily via getIndexedFields().
720
742
  */
721
- fileRefFieldsByType = /* @__PURE__ */ new Map();
722
- /** Per-typeId cache of sortable field kinds, as fileRefFieldsByType is. */
723
- sortFieldsByType = /* @__PURE__ */ new Map();
743
+ indexedFieldsByType = /* @__PURE__ */ new Map();
724
744
  constructor(deps) {
725
745
  this.deps = deps;
726
746
  }
@@ -801,8 +821,7 @@ var SharedSqlRecordLogic = class {
801
821
  this.insertAssociations(record.id, record.associations);
802
822
  }
803
823
  fts5Strategy.insert(this.exec, record.id, JSON.stringify(record.content));
804
- this.syncFileRefs(record.id, record.typeId, record.content);
805
- this.syncContentSort(record.id, record.typeId, record.content);
824
+ this.syncContentIndex(record.id, record.typeId, record.content);
806
825
  return record;
807
826
  }
808
827
  async getRecord(id) {
@@ -847,8 +866,7 @@ var SharedSqlRecordLogic = class {
847
866
  id
848
867
  ]);
849
868
  fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
850
- this.syncFileRefs(id, existing.typeId, merged);
851
- this.syncContentSort(id, existing.typeId, merged);
869
+ this.syncContentIndex(id, existing.typeId, merged);
852
870
  });
853
871
  const updated = await this.getRecord(id);
854
872
  if (!updated)
@@ -903,8 +921,7 @@ var SharedSqlRecordLogic = class {
903
921
  fts5Strategy.remove(this.exec, id);
904
922
  this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
905
923
  this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
906
- this.exec.run("DELETE FROM file_refs WHERE record_id = ?", [id]);
907
- this.exec.run("DELETE FROM content_sort WHERE record_id = ?", [id]);
924
+ this.exec.run("DELETE FROM content_index WHERE record_id = ?", [id]);
908
925
  this.exec.run("DELETE FROM records WHERE id = ?", [id]);
909
926
  return purged;
910
927
  }
@@ -965,6 +982,27 @@ var SharedSqlRecordLogic = class {
965
982
  throw new Error(`Record not found after setUnlisted: "${id}"`);
966
983
  return updated;
967
984
  }
985
+ async setParent(id, parentId, opts = {}) {
986
+ this.exec.transaction(() => {
987
+ if (opts.snapshot)
988
+ this.snapshotBeforeMutation(id, opts.snapshot);
989
+ const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
990
+ const changed = this.exec.run(`UPDATE records SET parent_id = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
991
+ parentId,
992
+ toMs(/* @__PURE__ */ new Date()),
993
+ opts.updatedBy ?? null,
994
+ opts.updatedVia ?? null,
995
+ id,
996
+ ...verParams
997
+ ]);
998
+ if (changed === 0)
999
+ this.throwVersionConflict(id, opts.expectedVersion);
1000
+ });
1001
+ const updated = await this.getRecord(id);
1002
+ if (!updated)
1003
+ throw new Error(`Record not found after setParent: "${id}"`);
1004
+ return updated;
1005
+ }
968
1006
  async restoreVersion(id, version, opts = {}) {
969
1007
  const existing = await this.getRecord(id);
970
1008
  if (!existing)
@@ -977,12 +1015,13 @@ var SharedSqlRecordLogic = class {
977
1015
  if (opts.snapshot)
978
1016
  this.snapshotBeforeMutation(id, opts.snapshot);
979
1017
  fts5Strategy.remove(this.exec, id);
980
- this.exec.run(`UPDATE records SET type_id = ?, content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?`, [
1018
+ this.exec.run(`UPDATE records SET type_id = ?, content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ?, parent_id = ? WHERE id = ?`, [
981
1019
  target.typeId,
982
1020
  JSON.stringify(target.content),
983
1021
  toMs(/* @__PURE__ */ new Date()),
984
1022
  opts.updatedBy ?? null,
985
1023
  opts.updatedVia ?? null,
1024
+ target.parentId ?? null,
986
1025
  id
987
1026
  ]);
988
1027
  if (target.associations !== void 0) {
@@ -991,8 +1030,7 @@ var SharedSqlRecordLogic = class {
991
1030
  this.insertAssociations(id, target.associations);
992
1031
  }
993
1032
  fts5Strategy.insert(this.exec, id, JSON.stringify(target.content));
994
- this.syncFileRefs(id, target.typeId, target.content);
995
- this.syncContentSort(id, target.typeId, target.content);
1033
+ this.syncContentIndex(id, target.typeId, target.content);
996
1034
  });
997
1035
  const updated = await this.getRecord(id);
998
1036
  if (!updated)
@@ -1017,8 +1055,7 @@ var SharedSqlRecordLogic = class {
1017
1055
  id
1018
1056
  ]);
1019
1057
  fts5Strategy.insert(this.exec, id, JSON.stringify(content));
1020
- this.syncFileRefs(id, toTypeId, content);
1021
- this.syncContentSort(id, toTypeId, content);
1058
+ this.syncContentIndex(id, toTypeId, content);
1022
1059
  });
1023
1060
  const updated = await this.getRecord(id);
1024
1061
  if (!updated)
@@ -1026,26 +1063,25 @@ var SharedSqlRecordLogic = class {
1026
1063
  return updated;
1027
1064
  }
1028
1065
  async queryRecords(query) {
1029
- const { sql: from, params: fromParams } = buildFromClause(query);
1030
- const { sql: where, params: whereParams } = buildWhereClause(query);
1031
- const params = [...fromParams, ...whereParams];
1032
- const order = buildOrderClause(query);
1033
1066
  const limit = query.limit ?? 50;
1034
- const rows = asStackQueryError(query, () => this.exec.all(`SELECT r.* FROM ${from} ${where} ${order} LIMIT ?`, [
1035
- ...params,
1036
- limit + 1
1037
- ]));
1067
+ const fetch = limit + 1;
1068
+ const rows = [];
1069
+ for (const page2 of buildQueryPlan(query)) {
1070
+ const budget = fetch - rows.length;
1071
+ if (budget <= 0)
1072
+ break;
1073
+ const statement2 = atBudget(page2, budget);
1074
+ rows.push(...asStackQueryError(query, () => this.exec.all(statement2.sql, statement2.params)));
1075
+ }
1038
1076
  const hasMore = rows.length > limit;
1039
1077
  const page = hasMore ? rows.slice(0, limit) : rows;
1040
1078
  const records = page.map((row) => {
1041
1079
  const associations = this.getAssociationsForRecord(row.id);
1042
1080
  return rowToRecord(row, associations);
1043
1081
  });
1044
- const countRows = asStackQueryError(query, () => this.exec.all(`SELECT COUNT(*) as total FROM ${from} ${where}`, params));
1045
- const total = countRows[0]?.total ?? 0;
1046
1082
  const lastRecord = records[records.length - 1];
1047
1083
  const cursor = hasMore && lastRecord ? makeCursor(lastRecord, query.sort, (record, field) => this.sortFieldKind(record, field)) : null;
1048
- return { records, cursor, total };
1084
+ return { records, cursor };
1049
1085
  }
1050
1086
  /**
1051
1087
  * Atomically verify fileId is unreferenced, then hard-delete its
@@ -1058,7 +1094,7 @@ var SharedSqlRecordLogic = class {
1058
1094
  return this.exec.transaction(() => {
1059
1095
  const referenced = this.exec.all(`SELECT 1 as found FROM associations WHERE kind = 'attachment' AND file_id = ?
1060
1096
  UNION ALL
1061
- SELECT 1 FROM file_refs WHERE file_id = ?
1097
+ SELECT 1 FROM content_index WHERE file_id = ?
1062
1098
  LIMIT 1`, [fileId, fileId]);
1063
1099
  if (referenced.length) {
1064
1100
  throw new StackConflictError("Attachment is still referenced by one or more records");
@@ -1098,8 +1134,8 @@ var SharedSqlRecordLogic = class {
1098
1134
  try {
1099
1135
  this.exec.run(`INSERT INTO versions
1100
1136
  (record_id, version, type_id, content, updated_at, entity_id,
1101
- updated_by, updated_via, associations, permissions)
1102
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1137
+ updated_by, updated_via, parent_id, associations, permissions)
1138
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1103
1139
  id,
1104
1140
  version.version,
1105
1141
  version.typeId,
@@ -1108,6 +1144,7 @@ var SharedSqlRecordLogic = class {
1108
1144
  version.entityId ?? null,
1109
1145
  version.updatedBy ?? null,
1110
1146
  version.updatedVia ?? null,
1147
+ version.parentId ?? null,
1111
1148
  version.associations ? JSON.stringify(version.associations) : null,
1112
1149
  version.permissions ? JSON.stringify(version.permissions) : null
1113
1150
  ]);
@@ -1118,14 +1155,24 @@ var SharedSqlRecordLogic = class {
1118
1155
  throw err;
1119
1156
  }
1120
1157
  }
1158
+ /**
1159
+ * Replaces every column insertVersionRow writes, so an overwritten row is
1160
+ * indistinguishable from a freshly inserted one — a column left out here
1161
+ * would keep the replaced row's value and surface later as a restore
1162
+ * putting back something the snapshot never said.
1163
+ */
1121
1164
  overwriteVersionRow(id, version) {
1122
1165
  this.exec.run(`UPDATE versions
1123
- SET type_id = ?, content = ?, updated_at = ?, entity_id = ?, associations = ?, permissions = ?
1166
+ SET type_id = ?, content = ?, updated_at = ?, entity_id = ?,
1167
+ updated_by = ?, updated_via = ?, parent_id = ?, associations = ?, permissions = ?
1124
1168
  WHERE record_id = ? AND version = ?`, [
1125
1169
  version.typeId,
1126
1170
  JSON.stringify(version.content),
1127
1171
  toMs(version.updatedAt),
1128
1172
  version.entityId ?? null,
1173
+ version.updatedBy ?? null,
1174
+ version.updatedVia ?? null,
1175
+ version.parentId ?? null,
1129
1176
  version.associations ? JSON.stringify(version.associations) : null,
1130
1177
  version.permissions ? JSON.stringify(version.permissions) : null,
1131
1178
  id,
@@ -1178,8 +1225,7 @@ var SharedSqlRecordLogic = class {
1178
1225
  toMs(type.createdAt)
1179
1226
  ]);
1180
1227
  const schema = type.schema;
1181
- this.fileRefFieldsByType.set(type.id, fileRefFieldNames(schema));
1182
- this.sortFieldsByType.set(type.id, sortableFieldKinds(schema));
1228
+ this.indexedFieldsByType.set(type.id, indexedFieldKinds(schema));
1183
1229
  }
1184
1230
  async getType(id) {
1185
1231
  const rows = this.exec.all("SELECT * FROM types WHERE id = ?", [id]);
@@ -1255,67 +1301,49 @@ var SharedSqlRecordLogic = class {
1255
1301
  return rows.map(rowToAssociation);
1256
1302
  }
1257
1303
  /**
1258
- * File-ref field names for typeId, from the in-memory cache — falling
1259
- * back to a `types` lookup (and populating the cache) only for a typeId
1260
- * this process hasn't seen via saveType() yet, e.g. right after open().
1261
- */
1262
- getFileRefFields(typeId) {
1263
- const cached = this.fileRefFieldsByType.get(typeId);
1264
- if (cached)
1265
- return cached;
1266
- const typeRow = this.exec.get("SELECT schema FROM types WHERE id = ?", [
1267
- typeId
1268
- ]);
1269
- const fields = typeRow ? fileRefFieldNames(JSON.parse(typeRow.schema)) : [];
1270
- this.fileRefFieldsByType.set(typeId, fields);
1271
- return fields;
1272
- }
1273
- /**
1274
- * Replace a record's file_refs rows with whatever its content currently
1275
- * holds in top-level file-ref fields, on every write that can change
1276
- * content or typeId, so the index never drifts. Only top-level scalars
1277
- * are indexed; the schema lookup is cached (fileRefFieldsByType).
1304
+ * The fields content_index covers for typeId, from the in-memory cache
1305
+ * — falling back to a `types` lookup (and populating the cache) only for
1306
+ * a typeId this process hasn't seen via saveType() yet, e.g. right after
1307
+ * open().
1278
1308
  */
1279
- getSortFields(typeId) {
1280
- const cached = this.sortFieldsByType.get(typeId);
1309
+ getIndexedFields(typeId) {
1310
+ const cached = this.indexedFieldsByType.get(typeId);
1281
1311
  if (cached)
1282
1312
  return cached;
1283
1313
  const typeRow = this.exec.get("SELECT schema FROM types WHERE id = ?", [
1284
1314
  typeId
1285
1315
  ]);
1286
- const fields = typeRow ? sortableFieldKinds(JSON.parse(typeRow.schema)) : /* @__PURE__ */ new Map();
1287
- this.sortFieldsByType.set(typeId, fields);
1316
+ const fields = typeRow ? indexedFieldKinds(JSON.parse(typeRow.schema)) : /* @__PURE__ */ new Map();
1317
+ this.indexedFieldsByType.set(typeId, fields);
1288
1318
  return fields;
1289
1319
  }
1290
1320
  /**
1291
- * Replace a record's content_sort rows with what its content currently
1292
- * holds, on every write that can change content or typeId. A field
1293
- * holding no orderable value gets no row at all, which is what puts the
1294
- * record at the end of a sort on that field.
1321
+ * Replace a record's content_index rows with what its content currently
1322
+ * holds, on every write that can change content or typeId, so neither
1323
+ * the ordering nor the file-reference lookup can drift from the record.
1324
+ * A field holding no orderable value gets no row at all, which is what
1325
+ * puts the record at the end of a sort on that field.
1295
1326
  */
1296
- syncContentSort(recordId, typeId, content) {
1297
- this.exec.run("DELETE FROM content_sort WHERE record_id = ?", [recordId]);
1298
- for (const [field, kind] of this.getSortFields(typeId)) {
1327
+ syncContentIndex(recordId, typeId, content) {
1328
+ this.exec.run("DELETE FROM content_index WHERE record_id = ?", [recordId]);
1329
+ for (const [field, kind] of this.getIndexedFields(typeId)) {
1299
1330
  const entry = contentSortEntry(kind, content[field]);
1300
1331
  if (!entry)
1301
1332
  continue;
1302
- this.exec.run(`INSERT OR IGNORE INTO content_sort (record_id, field, num_value, text_key, text_value)
1303
- VALUES (?, ?, ?, ?, ?)`, entry.kind === "num" ? [recordId, field, entry.num, null, null] : [recordId, field, null, entry.key, entry.text]);
1333
+ const sql = `INSERT OR IGNORE INTO content_index
1334
+ (record_id, field, value_rank, num_value, text_key, text_value, file_id)
1335
+ VALUES (?, ?, ?, ?, ?, ?, ?)`;
1336
+ if (entry.kind === "num") {
1337
+ this.exec.run(sql, [recordId, field, 0, entry.num, null, null, null]);
1338
+ } else {
1339
+ const fileId = kind === "file-ref" ? entry.text : null;
1340
+ this.exec.run(sql, [recordId, field, 1, null, entry.key, entry.text, fileId]);
1341
+ }
1304
1342
  }
1305
1343
  }
1306
1344
  /** The declared kind of a record's top-level `field`, if it has one. */
1307
1345
  sortFieldKind(record, field) {
1308
- return this.getSortFields(record.typeId).get(field);
1309
- }
1310
- syncFileRefs(recordId, typeId, content) {
1311
- this.exec.run("DELETE FROM file_refs WHERE record_id = ?", [recordId]);
1312
- const fields = this.getFileRefFields(typeId);
1313
- for (const field of fields) {
1314
- const value = content[field];
1315
- if (typeof value === "string") {
1316
- this.exec.run("INSERT OR IGNORE INTO file_refs (record_id, field, file_id) VALUES (?, ?, ?)", [recordId, field, value]);
1317
- }
1318
- }
1346
+ return this.getIndexedFields(record.typeId).get(field);
1319
1347
  }
1320
1348
  };
1321
1349
  var SharedTokenLogic = class {
@@ -1536,6 +1564,9 @@ var NativeSQLiteRecordAdapter = class _NativeSQLiteRecordAdapter {
1536
1564
  setUnlisted(id, unlisted, opts) {
1537
1565
  return this.record.setUnlisted(id, unlisted, opts);
1538
1566
  }
1567
+ setParent(id, parentId, opts) {
1568
+ return this.record.setParent(id, parentId, opts);
1569
+ }
1539
1570
  restoreVersion(id, version, opts) {
1540
1571
  return this.record.restoreVersion(id, version, opts);
1541
1572
  }