@haverstack/record-adapter-do-sqlite 0.10.0 → 0.12.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')),
@@ -62,57 +66,65 @@ var RECORD_SCHEMA_SQL = `
62
66
  created_at INTEGER NOT NULL
63
67
  ) STRICT;
64
68
 
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 (
69
+ -- One row per top-level scalar content field a record holds a value at:
70
+ -- what it orders as, and the file it references. See
71
+ -- docs/spec/adapters.md \xA7 Adapter backends and
72
+ -- docs/spec/data-model.md \xA7 Sorting by a content field.
73
+ --
74
+ -- value_rank is 0 for a numeric value and 1 for a text one \u2014 the same
75
+ -- split as "num_value IS NULL", but non-null, so one index can carry
76
+ -- the whole ordering. file_id is set only for a 'file-ref' field.
77
+ CREATE TABLE IF NOT EXISTS content_index (
84
78
  record_id TEXT NOT NULL REFERENCES records(id),
85
79
  field TEXT NOT NULL,
80
+ value_rank INTEGER NOT NULL CHECK (value_rank IN (0, 1)),
86
81
  num_value REAL,
87
82
  text_key TEXT,
88
83
  text_value TEXT,
84
+ file_id TEXT,
89
85
  PRIMARY KEY (record_id, field)
90
86
  ) STRICT;
91
87
 
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);
88
+ -- Indexes. Each filter column continues into (created_at, id) \u2014 the
89
+ -- default ordering and its tiebreak \u2014 because these columns are
90
+ -- low-cardinality by design, and a narrow index on one leaves the
91
+ -- planner sorting the whole matching set before it can honor LIMIT.
92
+ --
93
+ -- deleted_at and unlisted_at deliberately have none: nothing searches
94
+ -- for the rows they mark, and an index on a column every query tests
95
+ -- for IS NULL only lures the planner off the one answering ORDER BY.
96
+ CREATE INDEX IF NOT EXISTS idx_records_type_id ON records(type_id, created_at, id);
97
+ CREATE INDEX IF NOT EXISTS idx_records_parent_id ON records(parent_id, created_at, id);
98
+ CREATE INDEX IF NOT EXISTS idx_records_entity_id ON records(entity_id, created_at, id);
99
+ CREATE INDEX IF NOT EXISTS idx_records_app_id ON records(app_id, created_at, id);
100
+ CREATE INDEX IF NOT EXISTS idx_records_principal_id ON records(principal_id, created_at, id);
101
+ CREATE INDEX IF NOT EXISTS idx_records_created_at ON records(created_at, id);
102
+ CREATE INDEX IF NOT EXISTS idx_records_updated_at ON records(updated_at, id);
103
+ CREATE INDEX IF NOT EXISTS idx_records_version ON records(version, id);
104
+
105
+ -- Each association index ends with record_id so the semi-joins in
106
+ -- query.ts read the matching record ids out of the index alone.
107
+ -- idx_assoc_related carries related_ns ahead of related_id because a
108
+ -- record- or entity-scoped target always stores '' there, which
109
+ -- buildQueryPlan states explicitly to keep the prefix contiguous.
110
+ CREATE INDEX IF NOT EXISTS idx_assoc_kind_label ON associations(kind, label, record_id);
111
+ CREATE INDEX IF NOT EXISTS idx_assoc_kind_file_id ON associations(kind, file_id, record_id);
112
+ CREATE INDEX IF NOT EXISTS idx_assoc_related ON associations(kind, related_scope, related_ns, related_id, record_id);
113
+ CREATE INDEX IF NOT EXISTS idx_types_base_id ON types(base_id, version);
114
+
115
+ -- The whole content ordering, in index order, for one field at a time:
116
+ -- a content-sorted page is an ordered walk of a slice of this index.
117
+ CREATE INDEX IF NOT EXISTS idx_content_index_sort
118
+ ON content_index(field, value_rank, num_value, text_key, text_value, record_id);
119
+ CREATE INDEX IF NOT EXISTS idx_content_index_file
120
+ ON content_index(file_id, record_id) WHERE file_id IS NOT NULL;
110
121
  `;
111
122
  var FTS5_SCHEMA_SQL = `
112
123
  CREATE VIRTUAL TABLE IF NOT EXISTS records_fts USING fts5(
113
124
  content,
114
125
  content='records',
115
- content_rowid='rowid'
126
+ content_rowid='rowid',
127
+ columnsize=0
116
128
  );
117
129
  `;
118
130
  var PRAGMA_FOREIGN_KEYS_ON = `PRAGMA foreign_keys = ON;`;
@@ -184,14 +196,16 @@ var makeCursor = (record, sort, kindOf) => {
184
196
 
185
197
  // ../sqlite-shared/dist/fts5.js
186
198
  var OUTSIDE_PHRASE_DISALLOWED = /[^\p{L}\p{N}\p{M}_\s()"]/gu;
199
+ var collapseWhitespace = (query) => query.replace(/\s+/g, " ");
187
200
  var sanitizeFts5Query = (query, maxDepth = 2) => {
188
201
  if (!query)
189
202
  return "";
190
203
  let clean = query.replace(/[\p{Cc}\p{Cf}]/gu, " ");
191
204
  clean = clean.replace(/\*/g, "");
192
- clean = clean.replace(/\bNEAR\s*\(\s*([^,)]*)(?:,[^)]*)?\)/gi, "$1");
205
+ clean = clean.replace(/\bNEAR\s*\(([^,()]*)(?:,[^()]*)?\)/gi, "$1");
193
206
  clean = clean.replace(/\bNEAR\s*\(/gi, "(");
194
207
  clean = clean.replace(/"[^"]*"|[^"]+/g, (span) => span.startsWith('"') ? span : span.replace(OUTSIDE_PHRASE_DISALLOWED, " "));
208
+ clean = collapseWhitespace(clean);
195
209
  clean = clean.replace(/(?:^|\(\s*)NOT\s+/gi, (m) => m.replace(/NOT\s+/i, ""));
196
210
  let currentDepth = 0;
197
211
  let inPhrase = false;
@@ -225,12 +239,11 @@ var sanitizeFts5Query = (query, maxDepth = 2) => {
225
239
  let prev;
226
240
  do {
227
241
  prev = result;
228
- result = result.replace(/""/g, " ");
229
- result = result.replace(/\(\s*\)/g, " ");
242
+ result = result.replace(/(?:""|\(\s*\))+/g, " ");
230
243
  result = dropDanglingOperators(result);
231
244
  result = restoreImplicitAnd(result);
232
245
  } while (result !== prev);
233
- return result.replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim();
246
+ return collapseWhitespace(result).replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim();
234
247
  };
235
248
  var OPERATOR = String.raw`(?:AND|OR|NOT)`;
236
249
  var PHRASE_HOLE = "\uE000";
@@ -241,7 +254,7 @@ var dropDanglingOperators = (query) => withPhrasesMasked(query, (masked) => {
241
254
  prev = out;
242
255
  out = out.replace(new RegExp(String.raw`\b(${OPERATOR})\b(?:\s+\b${OPERATOR}\b)+`, "gi"), "$1");
243
256
  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");
257
+ out = out.replace(new RegExp(String.raw`\b${OPERATOR}\b\s*($|\))`, "gi"), "$1");
245
258
  } while (out !== prev);
246
259
  return out;
247
260
  });
@@ -259,7 +272,7 @@ var isOperator = (word) => /^(?:AND|OR|NOT)$/i.test(word);
259
272
  var restoreImplicitAnd = (query) => withPhrasesMasked(query, (masked) => masked.replace(new RegExp(String.raw`\)\s*(?=${OPERAND_CHAR}|\()`, "gu"), (match, offset, whole) => {
260
273
  const next = /^\s*([\p{L}\p{N}_]+)/u.exec(whole.slice(offset + match.length));
261
274
  return next && isOperator(next[1]) ? match : ") AND ";
262
- }).replace(new RegExp(String.raw`(${OPERAND_CHAR}+)\s*(?=\()`, "gu"), (match, word) => isOperator(word) ? match : `${word} AND `));
275
+ }).replace(new RegExp(String.raw`(?<!${OPERAND_CHAR})(${OPERAND_CHAR}+)\s*(?=\()`, "gu"), (match, word) => isOperator(word) ? match : `${word} AND `));
263
276
  var fts5Strategy = {
264
277
  /** Index a record that has no FTS entry yet (used right after an INSERT). */
265
278
  insert(exec, recordId, content) {
@@ -284,6 +297,10 @@ var fts5Strategy = {
284
297
  };
285
298
 
286
299
  // ../sqlite-shared/dist/query.js
300
+ var atBudget = (page, rows) => ({
301
+ sql: page.sql,
302
+ params: [...page.params, rows]
303
+ });
287
304
  var sqlDirection = (query) => {
288
305
  const dir = query.sort?.direction ?? "desc";
289
306
  if (dir !== "asc" && dir !== "desc") {
@@ -312,49 +329,18 @@ var contentPathExists = (segments, leaf, params, nextAlias) => {
312
329
  };
313
330
  var equalsLeaf = (alias) => `${alias}.type NOT IN ('object', 'array') AND ${alias}.value = ?`;
314
331
  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
- };
323
- };
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
- }
332
+ var cursorMismatch = (cursor, field) => new StackQueryError(`Cursor sort field "${cursor.field}" does not match query sort field "${field}"`);
333
+ var nativeCursor = (cursor, field) => {
334
+ if (cursor.kind !== "native" || cursor.field !== field)
335
+ throw cursorMismatch(cursor, field);
336
+ return cursor;
336
337
  };
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}))`;
338
+ var contentCursor = (cursor, field) => {
339
+ if (cursor.kind === "native" || cursor.field !== field)
340
+ throw cursorMismatch(cursor, field);
341
+ return cursor;
356
342
  };
357
- var buildWhereClause = (query) => {
343
+ var recordConditions = (query) => {
358
344
  const conditions = ["r.id != '_config'"];
359
345
  const params = [];
360
346
  const f = query.filter ?? {};
@@ -420,7 +406,7 @@ var buildWhereClause = (query) => {
420
406
  }
421
407
  if (f.attachmentFileId) {
422
408
  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 = ?))`);
409
+ OR r.id IN (SELECT ci.record_id FROM content_index ci WHERE ci.file_id = ?))`);
424
410
  params.push(f.attachmentFileId, f.attachmentFileId);
425
411
  }
426
412
  if (f.relatedTo) {
@@ -434,11 +420,11 @@ var buildWhereClause = (query) => {
434
420
  clauses.push("a.related_scope = ?");
435
421
  params.push(target.scope);
436
422
  if (target.scope === "record") {
437
- clauses.push("a.related_id = ?", "a.related_stack = ?");
438
- params.push(target.recordId, target.stackUrl ?? "");
423
+ clauses.push("a.related_ns = ?", "a.related_id = ?", "a.related_stack = ?");
424
+ params.push("", target.recordId, target.stackUrl ?? "");
439
425
  } else if (target.scope === "entity") {
440
- clauses.push("a.related_id = ?");
441
- params.push(target.entityId);
426
+ clauses.push("a.related_ns = ?", "a.related_id = ?");
427
+ params.push("", target.entityId);
442
428
  } else {
443
429
  clauses.push("a.related_ns = ?");
444
430
  params.push(target.ns);
@@ -482,29 +468,61 @@ var buildWhereClause = (query) => {
482
468
  conditions.push("0");
483
469
  }
484
470
  }
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
- }
471
+ return { conditions, params };
472
+ };
473
+ var presentCursorCondition = (cursor, op, params) => {
474
+ if (cursor.kind === "num") {
475
+ params.push(cursor.value, cursor.value, cursor.id);
476
+ 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
477
  }
497
- return {
498
- sql: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "",
499
- params
500
- };
478
+ const key = contentSortKey(cursor.value);
479
+ params.push(key, key, cursor.value, cursor.value, cursor.id);
480
+ 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
481
  };
502
- var buildOrderClause = (query) => {
482
+ var statement = (select, from, conditions, params, tail = "") => ({
483
+ sql: `SELECT ${select} FROM ${from} WHERE ${conditions.join(" AND ")}${tail}`,
484
+ params
485
+ });
486
+ var buildQueryPlan = (query) => {
503
487
  const dir = sqlDirection(query);
504
- if (query.sort?.contentField === void 0) {
505
- return `ORDER BY r.${getSortColumn(getSortField(query))} ${dir}, r.id ${dir}`;
488
+ const op = dir === "ASC" ? ">" : "<";
489
+ const decoded = query.cursor ? decodeCursor(query.cursor) : null;
490
+ const contentField = query.sort?.contentField;
491
+ if (contentField === void 0) {
492
+ const { conditions, params } = recordConditions(query);
493
+ const col = getSortColumn(getSortField(query));
494
+ if (decoded) {
495
+ const cursor2 = nativeCursor(decoded, getSortField(query));
496
+ conditions.push(`(r.${col} ${op} ? OR (r.${col} = ? AND r.id ${op} ?))`);
497
+ params.push(cursor2.value, cursor2.value, cursor2.id);
498
+ }
499
+ return [
500
+ statement("r.*", "records r", conditions, params, ` ORDER BY r.${col} ${dir}, r.id ${dir} LIMIT ?`)
501
+ ];
502
+ }
503
+ const cursor = decoded ? contentCursor(decoded, contentField) : null;
504
+ const pages = [];
505
+ if (cursor?.kind !== "absent") {
506
+ const { conditions, params } = recordConditions(query);
507
+ const from = `content_index ${SORT_ALIAS} JOIN records r ON r.id = ${SORT_ALIAS}.record_id`;
508
+ conditions.push(`${SORT_ALIAS}.field = ?`);
509
+ params.push(contentField);
510
+ if (cursor)
511
+ conditions.push(presentCursorCondition(cursor, op, params));
512
+ 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}`;
513
+ pages.push(statement("r.*", from, conditions, params, ` ORDER BY ${order} LIMIT ?`));
514
+ }
515
+ {
516
+ const { conditions, params } = recordConditions(query);
517
+ conditions.push(`NOT EXISTS (SELECT 1 FROM content_index ${SORT_ALIAS} WHERE ${SORT_ALIAS}.record_id = r.id AND ${SORT_ALIAS}.field = ?)`);
518
+ params.push(contentField);
519
+ if (cursor?.kind === "absent") {
520
+ conditions.push(`r.id ${op} ?`);
521
+ params.push(cursor.id);
522
+ }
523
+ pages.push(statement("r.*", "records r", conditions, params, ` ORDER BY r.id ${dir} LIMIT ?`));
506
524
  }
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}`;
525
+ return pages;
508
526
  };
509
527
 
510
528
  // ../sqlite-shared/dist/mappers.js
@@ -640,8 +658,7 @@ var asStackQueryError = (query, run) => {
640
658
  throw new StackQueryError(`Search text could not be parsed: ${err instanceof Error ? err.message : String(err)}`);
641
659
  }
642
660
  };
643
- var fileRefFieldNames = (schema) => Object.keys(schema).filter((field) => schema[field].kind === "file-ref");
644
- var sortableFieldKinds = (schema) => {
661
+ var indexedFieldKinds = (schema) => {
645
662
  const kinds = /* @__PURE__ */ new Map();
646
663
  for (const [field, def] of Object.entries(schema)) {
647
664
  if (!def.kind || def.kind === "array" || def.kind === "object")
@@ -653,13 +670,12 @@ var sortableFieldKinds = (schema) => {
653
670
  var SharedSqlRecordLogic = class {
654
671
  deps;
655
672
  /**
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().
673
+ * Per-typeId cache of the fields content_index covers and their declared
674
+ * kinds, so syncContentIndex() doesn't hit the `types` table and
675
+ * re-parse schema JSON on every write. Populated eagerly in saveType()
676
+ * and lazily via getIndexedFields().
659
677
  */
660
- fileRefFieldsByType = /* @__PURE__ */ new Map();
661
- /** Per-typeId cache of sortable field kinds, as fileRefFieldsByType is. */
662
- sortFieldsByType = /* @__PURE__ */ new Map();
678
+ indexedFieldsByType = /* @__PURE__ */ new Map();
663
679
  constructor(deps) {
664
680
  this.deps = deps;
665
681
  }
@@ -740,8 +756,7 @@ var SharedSqlRecordLogic = class {
740
756
  this.insertAssociations(record.id, record.associations);
741
757
  }
742
758
  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);
759
+ this.syncContentIndex(record.id, record.typeId, record.content);
745
760
  return record;
746
761
  }
747
762
  async getRecord(id) {
@@ -786,8 +801,7 @@ var SharedSqlRecordLogic = class {
786
801
  id
787
802
  ]);
788
803
  fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
789
- this.syncFileRefs(id, existing.typeId, merged);
790
- this.syncContentSort(id, existing.typeId, merged);
804
+ this.syncContentIndex(id, existing.typeId, merged);
791
805
  });
792
806
  const updated = await this.getRecord(id);
793
807
  if (!updated)
@@ -842,8 +856,7 @@ var SharedSqlRecordLogic = class {
842
856
  fts5Strategy.remove(this.exec, id);
843
857
  this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
844
858
  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]);
859
+ this.exec.run("DELETE FROM content_index WHERE record_id = ?", [id]);
847
860
  this.exec.run("DELETE FROM records WHERE id = ?", [id]);
848
861
  return purged;
849
862
  }
@@ -930,8 +943,7 @@ var SharedSqlRecordLogic = class {
930
943
  this.insertAssociations(id, target.associations);
931
944
  }
932
945
  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);
946
+ this.syncContentIndex(id, target.typeId, target.content);
935
947
  });
936
948
  const updated = await this.getRecord(id);
937
949
  if (!updated)
@@ -956,8 +968,7 @@ var SharedSqlRecordLogic = class {
956
968
  id
957
969
  ]);
958
970
  fts5Strategy.insert(this.exec, id, JSON.stringify(content));
959
- this.syncFileRefs(id, toTypeId, content);
960
- this.syncContentSort(id, toTypeId, content);
971
+ this.syncContentIndex(id, toTypeId, content);
961
972
  });
962
973
  const updated = await this.getRecord(id);
963
974
  if (!updated)
@@ -965,26 +976,25 @@ var SharedSqlRecordLogic = class {
965
976
  return updated;
966
977
  }
967
978
  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
979
  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
- ]));
980
+ const fetch = limit + 1;
981
+ const rows = [];
982
+ for (const page2 of buildQueryPlan(query)) {
983
+ const budget = fetch - rows.length;
984
+ if (budget <= 0)
985
+ break;
986
+ const statement2 = atBudget(page2, budget);
987
+ rows.push(...asStackQueryError(query, () => this.exec.all(statement2.sql, statement2.params)));
988
+ }
977
989
  const hasMore = rows.length > limit;
978
990
  const page = hasMore ? rows.slice(0, limit) : rows;
979
991
  const records = page.map((row) => {
980
992
  const associations = this.getAssociationsForRecord(row.id);
981
993
  return rowToRecord(row, associations);
982
994
  });
983
- const countRows = asStackQueryError(query, () => this.exec.all(`SELECT COUNT(*) as total FROM ${from} ${where}`, params));
984
- const total = countRows[0]?.total ?? 0;
985
995
  const lastRecord = records[records.length - 1];
986
996
  const cursor = hasMore && lastRecord ? makeCursor(lastRecord, query.sort, (record, field) => this.sortFieldKind(record, field)) : null;
987
- return { records, cursor, total };
997
+ return { records, cursor };
988
998
  }
989
999
  /**
990
1000
  * Atomically verify fileId is unreferenced, then hard-delete its
@@ -997,7 +1007,7 @@ var SharedSqlRecordLogic = class {
997
1007
  return this.exec.transaction(() => {
998
1008
  const referenced = this.exec.all(`SELECT 1 as found FROM associations WHERE kind = 'attachment' AND file_id = ?
999
1009
  UNION ALL
1000
- SELECT 1 FROM file_refs WHERE file_id = ?
1010
+ SELECT 1 FROM content_index WHERE file_id = ?
1001
1011
  LIMIT 1`, [fileId, fileId]);
1002
1012
  if (referenced.length) {
1003
1013
  throw new StackConflictError("Attachment is still referenced by one or more records");
@@ -1117,8 +1127,7 @@ var SharedSqlRecordLogic = class {
1117
1127
  toMs(type.createdAt)
1118
1128
  ]);
1119
1129
  const schema = type.schema;
1120
- this.fileRefFieldsByType.set(type.id, fileRefFieldNames(schema));
1121
- this.sortFieldsByType.set(type.id, sortableFieldKinds(schema));
1130
+ this.indexedFieldsByType.set(type.id, indexedFieldKinds(schema));
1122
1131
  }
1123
1132
  async getType(id) {
1124
1133
  const rows = this.exec.all("SELECT * FROM types WHERE id = ?", [id]);
@@ -1194,67 +1203,49 @@ var SharedSqlRecordLogic = class {
1194
1203
  return rows.map(rowToAssociation);
1195
1204
  }
1196
1205
  /**
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).
1206
+ * The fields content_index covers for typeId, from the in-memory cache
1207
+ * — falling back to a `types` lookup (and populating the cache) only for
1208
+ * a typeId this process hasn't seen via saveType() yet, e.g. right after
1209
+ * open().
1217
1210
  */
1218
- getSortFields(typeId) {
1219
- const cached = this.sortFieldsByType.get(typeId);
1211
+ getIndexedFields(typeId) {
1212
+ const cached = this.indexedFieldsByType.get(typeId);
1220
1213
  if (cached)
1221
1214
  return cached;
1222
1215
  const typeRow = this.exec.get("SELECT schema FROM types WHERE id = ?", [
1223
1216
  typeId
1224
1217
  ]);
1225
- const fields = typeRow ? sortableFieldKinds(JSON.parse(typeRow.schema)) : /* @__PURE__ */ new Map();
1226
- this.sortFieldsByType.set(typeId, fields);
1218
+ const fields = typeRow ? indexedFieldKinds(JSON.parse(typeRow.schema)) : /* @__PURE__ */ new Map();
1219
+ this.indexedFieldsByType.set(typeId, fields);
1227
1220
  return fields;
1228
1221
  }
1229
1222
  /**
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.
1223
+ * Replace a record's content_index rows with what its content currently
1224
+ * holds, on every write that can change content or typeId, so neither
1225
+ * the ordering nor the file-reference lookup can drift from the record.
1226
+ * A field holding no orderable value gets no row at all, which is what
1227
+ * puts the record at the end of a sort on that field.
1234
1228
  */
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)) {
1229
+ syncContentIndex(recordId, typeId, content) {
1230
+ this.exec.run("DELETE FROM content_index WHERE record_id = ?", [recordId]);
1231
+ for (const [field, kind] of this.getIndexedFields(typeId)) {
1238
1232
  const entry = contentSortEntry(kind, content[field]);
1239
1233
  if (!entry)
1240
1234
  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]);
1235
+ const sql = `INSERT OR IGNORE INTO content_index
1236
+ (record_id, field, value_rank, num_value, text_key, text_value, file_id)
1237
+ VALUES (?, ?, ?, ?, ?, ?, ?)`;
1238
+ if (entry.kind === "num") {
1239
+ this.exec.run(sql, [recordId, field, 0, entry.num, null, null, null]);
1240
+ } else {
1241
+ const fileId = kind === "file-ref" ? entry.text : null;
1242
+ this.exec.run(sql, [recordId, field, 1, null, entry.key, entry.text, fileId]);
1243
+ }
1243
1244
  }
1244
1245
  }
1245
1246
  /** The declared kind of a record's top-level `field`, if it has one. */
1246
1247
  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
- }
1248
+ return this.getIndexedFields(record.typeId).get(field);
1258
1249
  }
1259
1250
  };
1260
1251
 
@@ -1294,14 +1285,19 @@ var DoSQLiteRecordAdapter = class _DoSQLiteRecordAdapter {
1294
1285
  }
1295
1286
  exec;
1296
1287
  capabilities = {
1297
- fullTextSearch: true,
1298
- contentFieldQuery: true,
1299
- nestedContentQuery: true,
1300
- contentPresenceQuery: true,
1301
- contentFieldSort: true,
1302
- sortableFields: ["createdAt", "updatedAt", "version"],
1303
- maxAttachmentBytes: null,
1304
- maxContentBytes: null
1288
+ filter: {
1289
+ content: "path",
1290
+ contentPresent: true,
1291
+ search: true
1292
+ },
1293
+ sort: {
1294
+ fields: ["createdAt", "updatedAt", "version"],
1295
+ contentField: true
1296
+ },
1297
+ limits: {
1298
+ attachmentBytes: null,
1299
+ contentBytes: null
1300
+ }
1305
1301
  };
1306
1302
  ownerEntityId;
1307
1303
  timezone;