@haverstack/record-adapter-sqlite 0.17.0 → 0.19.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')),
@@ -69,51 +73,58 @@ var RECORD_SCHEMA_SQL = `
69
73
  created_at INTEGER NOT NULL
70
74
  ) STRICT;
71
75
 
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 (
76
+ -- One row per top-level scalar content field a record holds a value at:
77
+ -- what it orders as, and the file it references. See
78
+ -- docs/spec/adapters.md \xA7 Adapter backends and
79
+ -- docs/spec/data-model.md \xA7 Sorting by a content field.
80
+ --
81
+ -- value_rank is 0 for a numeric value and 1 for a text one \u2014 the same
82
+ -- split as "num_value IS NULL", but non-null, so one index can carry
83
+ -- the whole ordering. file_id is set only for a 'file-ref' field.
84
+ CREATE TABLE IF NOT EXISTS content_index (
91
85
  record_id TEXT NOT NULL REFERENCES records(id),
92
86
  field TEXT NOT NULL,
87
+ value_rank INTEGER NOT NULL CHECK (value_rank IN (0, 1)),
93
88
  num_value REAL,
94
89
  text_key TEXT,
95
90
  text_value TEXT,
91
+ file_id TEXT,
96
92
  PRIMARY KEY (record_id, field)
97
93
  ) STRICT;
98
94
 
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);
95
+ -- Indexes. Each filter column continues into (created_at, id) \u2014 the
96
+ -- default ordering and its tiebreak \u2014 because these columns are
97
+ -- low-cardinality by design, and a narrow index on one leaves the
98
+ -- planner sorting the whole matching set before it can honor LIMIT.
99
+ --
100
+ -- deleted_at and unlisted_at deliberately have none: nothing searches
101
+ -- for the rows they mark, and an index on a column every query tests
102
+ -- for IS NULL only lures the planner off the one answering ORDER BY.
103
+ CREATE INDEX IF NOT EXISTS idx_records_type_id ON records(type_id, created_at, id);
104
+ CREATE INDEX IF NOT EXISTS idx_records_parent_id ON records(parent_id, created_at, id);
105
+ CREATE INDEX IF NOT EXISTS idx_records_entity_id ON records(entity_id, created_at, id);
106
+ CREATE INDEX IF NOT EXISTS idx_records_app_id ON records(app_id, created_at, id);
107
+ CREATE INDEX IF NOT EXISTS idx_records_principal_id ON records(principal_id, created_at, id);
108
+ CREATE INDEX IF NOT EXISTS idx_records_created_at ON records(created_at, id);
109
+ CREATE INDEX IF NOT EXISTS idx_records_updated_at ON records(updated_at, id);
110
+ CREATE INDEX IF NOT EXISTS idx_records_version ON records(version, id);
111
+
112
+ -- Each association index ends with record_id so the semi-joins in
113
+ -- query.ts read the matching record ids out of the index alone.
114
+ -- idx_assoc_related carries related_ns ahead of related_id because a
115
+ -- record- or entity-scoped target always stores '' there, which
116
+ -- buildQueryPlan states explicitly to keep the prefix contiguous.
117
+ CREATE INDEX IF NOT EXISTS idx_assoc_kind_label ON associations(kind, label, record_id);
118
+ CREATE INDEX IF NOT EXISTS idx_assoc_kind_file_id ON associations(kind, file_id, record_id);
119
+ CREATE INDEX IF NOT EXISTS idx_assoc_related ON associations(kind, related_scope, related_ns, related_id, record_id);
120
+ CREATE INDEX IF NOT EXISTS idx_types_base_id ON types(base_id, version);
121
+
122
+ -- The whole content ordering, in index order, for one field at a time:
123
+ -- a content-sorted page is an ordered walk of a slice of this index.
124
+ CREATE INDEX IF NOT EXISTS idx_content_index_sort
125
+ ON content_index(field, value_rank, num_value, text_key, text_value, record_id);
126
+ CREATE INDEX IF NOT EXISTS idx_content_index_file
127
+ ON content_index(file_id, record_id) WHERE file_id IS NOT NULL;
117
128
  `;
118
129
  var TOKENS_SCHEMA_SQL = `
119
130
  CREATE TABLE IF NOT EXISTS tokens (
@@ -125,14 +136,13 @@ var TOKENS_SCHEMA_SQL = `
125
136
  created_at INTEGER NOT NULL,
126
137
  expires_at INTEGER
127
138
  ) STRICT;
128
-
129
- CREATE INDEX IF NOT EXISTS idx_tokens_hash ON tokens(token_hash);
130
139
  `;
131
140
  var FTS5_SCHEMA_SQL = `
132
141
  CREATE VIRTUAL TABLE IF NOT EXISTS records_fts USING fts5(
133
142
  content,
134
143
  content='records',
135
- content_rowid='rowid'
144
+ content_rowid='rowid',
145
+ columnsize=0
136
146
  );
137
147
  `;
138
148
  var PRAGMA_FOREIGN_KEYS_ON = `PRAGMA foreign_keys = ON;`;
@@ -205,14 +215,16 @@ var makeCursor = (record, sort, kindOf) => {
205
215
 
206
216
  // ../sqlite-shared/dist/fts5.js
207
217
  var OUTSIDE_PHRASE_DISALLOWED = /[^\p{L}\p{N}\p{M}_\s()"]/gu;
218
+ var collapseWhitespace = (query) => query.replace(/\s+/g, " ");
208
219
  var sanitizeFts5Query = (query, maxDepth = 2) => {
209
220
  if (!query)
210
221
  return "";
211
222
  let clean = query.replace(/[\p{Cc}\p{Cf}]/gu, " ");
212
223
  clean = clean.replace(/\*/g, "");
213
- clean = clean.replace(/\bNEAR\s*\(\s*([^,)]*)(?:,[^)]*)?\)/gi, "$1");
224
+ clean = clean.replace(/\bNEAR\s*\(([^,()]*)(?:,[^()]*)?\)/gi, "$1");
214
225
  clean = clean.replace(/\bNEAR\s*\(/gi, "(");
215
226
  clean = clean.replace(/"[^"]*"|[^"]+/g, (span) => span.startsWith('"') ? span : span.replace(OUTSIDE_PHRASE_DISALLOWED, " "));
227
+ clean = collapseWhitespace(clean);
216
228
  clean = clean.replace(/(?:^|\(\s*)NOT\s+/gi, (m) => m.replace(/NOT\s+/i, ""));
217
229
  let currentDepth = 0;
218
230
  let inPhrase = false;
@@ -246,12 +258,11 @@ var sanitizeFts5Query = (query, maxDepth = 2) => {
246
258
  let prev;
247
259
  do {
248
260
  prev = result;
249
- result = result.replace(/""/g, " ");
250
- result = result.replace(/\(\s*\)/g, " ");
261
+ result = result.replace(/(?:""|\(\s*\))+/g, " ");
251
262
  result = dropDanglingOperators(result);
252
263
  result = restoreImplicitAnd(result);
253
264
  } while (result !== prev);
254
- return result.replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim();
265
+ return collapseWhitespace(result).replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim();
255
266
  };
256
267
  var OPERATOR = String.raw`(?:AND|OR|NOT)`;
257
268
  var PHRASE_HOLE = "\uE000";
@@ -262,7 +273,7 @@ var dropDanglingOperators = (query) => withPhrasesMasked(query, (masked) => {
262
273
  prev = out;
263
274
  out = out.replace(new RegExp(String.raw`\b(${OPERATOR})\b(?:\s+\b${OPERATOR}\b)+`, "gi"), "$1");
264
275
  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");
276
+ out = out.replace(new RegExp(String.raw`\b${OPERATOR}\b\s*($|\))`, "gi"), "$1");
266
277
  } while (out !== prev);
267
278
  return out;
268
279
  });
@@ -280,7 +291,7 @@ var isOperator = (word) => /^(?:AND|OR|NOT)$/i.test(word);
280
291
  var restoreImplicitAnd = (query) => withPhrasesMasked(query, (masked) => masked.replace(new RegExp(String.raw`\)\s*(?=${OPERAND_CHAR}|\()`, "gu"), (match, offset, whole) => {
281
292
  const next = /^\s*([\p{L}\p{N}_]+)/u.exec(whole.slice(offset + match.length));
282
293
  return next && isOperator(next[1]) ? match : ") AND ";
283
- }).replace(new RegExp(String.raw`(${OPERAND_CHAR}+)\s*(?=\()`, "gu"), (match, word) => isOperator(word) ? match : `${word} AND `));
294
+ }).replace(new RegExp(String.raw`(?<!${OPERAND_CHAR})(${OPERAND_CHAR}+)\s*(?=\()`, "gu"), (match, word) => isOperator(word) ? match : `${word} AND `));
284
295
  var fts5Strategy = {
285
296
  /** Index a record that has no FTS entry yet (used right after an INSERT). */
286
297
  insert(exec, recordId, content) {
@@ -305,6 +316,10 @@ var fts5Strategy = {
305
316
  };
306
317
 
307
318
  // ../sqlite-shared/dist/query.js
319
+ var atBudget = (page, rows) => ({
320
+ sql: page.sql,
321
+ params: [...page.params, rows]
322
+ });
308
323
  var sqlDirection = (query) => {
309
324
  const dir = query.sort?.direction ?? "desc";
310
325
  if (dir !== "asc" && dir !== "desc") {
@@ -333,49 +348,18 @@ var contentPathExists = (segments, leaf, params, nextAlias) => {
333
348
  };
334
349
  var equalsLeaf = (alias) => `${alias}.type NOT IN ('object', 'array') AND ${alias}.value = ?`;
335
350
  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
- };
351
+ var cursorMismatch = (cursor, field) => new StackQueryError(`Cursor sort field "${cursor.field}" does not match query sort field "${field}"`);
352
+ var nativeCursor = (cursor, field) => {
353
+ if (cursor.kind !== "native" || cursor.field !== field)
354
+ throw cursorMismatch(cursor, field);
355
+ return cursor;
344
356
  };
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
- }
357
- };
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}))`;
357
+ var contentCursor = (cursor, field) => {
358
+ if (cursor.kind === "native" || cursor.field !== field)
359
+ throw cursorMismatch(cursor, field);
360
+ return cursor;
377
361
  };
378
- var buildWhereClause = (query) => {
362
+ var recordConditions = (query) => {
379
363
  const conditions = ["r.id != '_config'"];
380
364
  const params = [];
381
365
  const f = query.filter ?? {};
@@ -441,7 +425,7 @@ var buildWhereClause = (query) => {
441
425
  }
442
426
  if (f.attachmentFileId) {
443
427
  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 = ?))`);
428
+ OR r.id IN (SELECT ci.record_id FROM content_index ci WHERE ci.file_id = ?))`);
445
429
  params.push(f.attachmentFileId, f.attachmentFileId);
446
430
  }
447
431
  if (f.relatedTo) {
@@ -455,11 +439,11 @@ var buildWhereClause = (query) => {
455
439
  clauses.push("a.related_scope = ?");
456
440
  params.push(target.scope);
457
441
  if (target.scope === "record") {
458
- clauses.push("a.related_id = ?", "a.related_stack = ?");
459
- params.push(target.recordId, target.stackUrl ?? "");
442
+ clauses.push("a.related_ns = ?", "a.related_id = ?", "a.related_stack = ?");
443
+ params.push("", target.recordId, target.stackUrl ?? "");
460
444
  } else if (target.scope === "entity") {
461
- clauses.push("a.related_id = ?");
462
- params.push(target.entityId);
445
+ clauses.push("a.related_ns = ?", "a.related_id = ?");
446
+ params.push("", target.entityId);
463
447
  } else {
464
448
  clauses.push("a.related_ns = ?");
465
449
  params.push(target.ns);
@@ -503,29 +487,61 @@ var buildWhereClause = (query) => {
503
487
  conditions.push("0");
504
488
  }
505
489
  }
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
- }
490
+ return { conditions, params };
491
+ };
492
+ var presentCursorCondition = (cursor, op, params) => {
493
+ if (cursor.kind === "num") {
494
+ params.push(cursor.value, cursor.value, cursor.id);
495
+ 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
496
  }
518
- return {
519
- sql: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "",
520
- params
521
- };
497
+ const key = contentSortKey(cursor.value);
498
+ params.push(key, key, cursor.value, cursor.value, cursor.id);
499
+ 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
500
  };
523
- var buildOrderClause = (query) => {
501
+ var statement = (select, from, conditions, params, tail = "") => ({
502
+ sql: `SELECT ${select} FROM ${from} WHERE ${conditions.join(" AND ")}${tail}`,
503
+ params
504
+ });
505
+ var buildQueryPlan = (query) => {
524
506
  const dir = sqlDirection(query);
525
- if (query.sort?.contentField === void 0) {
526
- return `ORDER BY r.${getSortColumn(getSortField(query))} ${dir}, r.id ${dir}`;
507
+ const op = dir === "ASC" ? ">" : "<";
508
+ const decoded = query.cursor ? decodeCursor(query.cursor) : null;
509
+ const contentField = query.sort?.contentField;
510
+ if (contentField === void 0) {
511
+ const { conditions, params } = recordConditions(query);
512
+ const col = getSortColumn(getSortField(query));
513
+ if (decoded) {
514
+ const cursor2 = nativeCursor(decoded, getSortField(query));
515
+ conditions.push(`(r.${col} ${op} ? OR (r.${col} = ? AND r.id ${op} ?))`);
516
+ params.push(cursor2.value, cursor2.value, cursor2.id);
517
+ }
518
+ return [
519
+ statement("r.*", "records r", conditions, params, ` ORDER BY r.${col} ${dir}, r.id ${dir} LIMIT ?`)
520
+ ];
521
+ }
522
+ const cursor = decoded ? contentCursor(decoded, contentField) : null;
523
+ const pages = [];
524
+ if (cursor?.kind !== "absent") {
525
+ const { conditions, params } = recordConditions(query);
526
+ const from = `content_index ${SORT_ALIAS} JOIN records r ON r.id = ${SORT_ALIAS}.record_id`;
527
+ conditions.push(`${SORT_ALIAS}.field = ?`);
528
+ params.push(contentField);
529
+ if (cursor)
530
+ conditions.push(presentCursorCondition(cursor, op, params));
531
+ 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}`;
532
+ pages.push(statement("r.*", from, conditions, params, ` ORDER BY ${order} LIMIT ?`));
533
+ }
534
+ {
535
+ const { conditions, params } = recordConditions(query);
536
+ conditions.push(`NOT EXISTS (SELECT 1 FROM content_index ${SORT_ALIAS} WHERE ${SORT_ALIAS}.record_id = r.id AND ${SORT_ALIAS}.field = ?)`);
537
+ params.push(contentField);
538
+ if (cursor?.kind === "absent") {
539
+ conditions.push(`r.id ${op} ?`);
540
+ params.push(cursor.id);
541
+ }
542
+ pages.push(statement("r.*", "records r", conditions, params, ` ORDER BY r.id ${dir} LIMIT ?`));
527
543
  }
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}`;
544
+ return pages;
529
545
  };
530
546
 
531
547
  // ../sqlite-shared/dist/mappers.js
@@ -701,8 +717,7 @@ var asStackQueryError = (query, run) => {
701
717
  throw new StackQueryError(`Search text could not be parsed: ${err instanceof Error ? err.message : String(err)}`);
702
718
  }
703
719
  };
704
- var fileRefFieldNames = (schema) => Object.keys(schema).filter((field) => schema[field].kind === "file-ref");
705
- var sortableFieldKinds = (schema) => {
720
+ var indexedFieldKinds = (schema) => {
706
721
  const kinds = /* @__PURE__ */ new Map();
707
722
  for (const [field, def] of Object.entries(schema)) {
708
723
  if (!def.kind || def.kind === "array" || def.kind === "object")
@@ -714,13 +729,12 @@ var sortableFieldKinds = (schema) => {
714
729
  var SharedSqlRecordLogic = class {
715
730
  deps;
716
731
  /**
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().
732
+ * Per-typeId cache of the fields content_index covers and their declared
733
+ * kinds, so syncContentIndex() doesn't hit the `types` table and
734
+ * re-parse schema JSON on every write. Populated eagerly in saveType()
735
+ * and lazily via getIndexedFields().
720
736
  */
721
- fileRefFieldsByType = /* @__PURE__ */ new Map();
722
- /** Per-typeId cache of sortable field kinds, as fileRefFieldsByType is. */
723
- sortFieldsByType = /* @__PURE__ */ new Map();
737
+ indexedFieldsByType = /* @__PURE__ */ new Map();
724
738
  constructor(deps) {
725
739
  this.deps = deps;
726
740
  }
@@ -801,8 +815,7 @@ var SharedSqlRecordLogic = class {
801
815
  this.insertAssociations(record.id, record.associations);
802
816
  }
803
817
  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);
818
+ this.syncContentIndex(record.id, record.typeId, record.content);
806
819
  return record;
807
820
  }
808
821
  async getRecord(id) {
@@ -847,8 +860,7 @@ var SharedSqlRecordLogic = class {
847
860
  id
848
861
  ]);
849
862
  fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
850
- this.syncFileRefs(id, existing.typeId, merged);
851
- this.syncContentSort(id, existing.typeId, merged);
863
+ this.syncContentIndex(id, existing.typeId, merged);
852
864
  });
853
865
  const updated = await this.getRecord(id);
854
866
  if (!updated)
@@ -903,8 +915,7 @@ var SharedSqlRecordLogic = class {
903
915
  fts5Strategy.remove(this.exec, id);
904
916
  this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
905
917
  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]);
918
+ this.exec.run("DELETE FROM content_index WHERE record_id = ?", [id]);
908
919
  this.exec.run("DELETE FROM records WHERE id = ?", [id]);
909
920
  return purged;
910
921
  }
@@ -991,8 +1002,7 @@ var SharedSqlRecordLogic = class {
991
1002
  this.insertAssociations(id, target.associations);
992
1003
  }
993
1004
  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);
1005
+ this.syncContentIndex(id, target.typeId, target.content);
996
1006
  });
997
1007
  const updated = await this.getRecord(id);
998
1008
  if (!updated)
@@ -1017,8 +1027,7 @@ var SharedSqlRecordLogic = class {
1017
1027
  id
1018
1028
  ]);
1019
1029
  fts5Strategy.insert(this.exec, id, JSON.stringify(content));
1020
- this.syncFileRefs(id, toTypeId, content);
1021
- this.syncContentSort(id, toTypeId, content);
1030
+ this.syncContentIndex(id, toTypeId, content);
1022
1031
  });
1023
1032
  const updated = await this.getRecord(id);
1024
1033
  if (!updated)
@@ -1026,26 +1035,25 @@ var SharedSqlRecordLogic = class {
1026
1035
  return updated;
1027
1036
  }
1028
1037
  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
1038
  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
- ]));
1039
+ const fetch = limit + 1;
1040
+ const rows = [];
1041
+ for (const page2 of buildQueryPlan(query)) {
1042
+ const budget = fetch - rows.length;
1043
+ if (budget <= 0)
1044
+ break;
1045
+ const statement2 = atBudget(page2, budget);
1046
+ rows.push(...asStackQueryError(query, () => this.exec.all(statement2.sql, statement2.params)));
1047
+ }
1038
1048
  const hasMore = rows.length > limit;
1039
1049
  const page = hasMore ? rows.slice(0, limit) : rows;
1040
1050
  const records = page.map((row) => {
1041
1051
  const associations = this.getAssociationsForRecord(row.id);
1042
1052
  return rowToRecord(row, associations);
1043
1053
  });
1044
- const countRows = asStackQueryError(query, () => this.exec.all(`SELECT COUNT(*) as total FROM ${from} ${where}`, params));
1045
- const total = countRows[0]?.total ?? 0;
1046
1054
  const lastRecord = records[records.length - 1];
1047
1055
  const cursor = hasMore && lastRecord ? makeCursor(lastRecord, query.sort, (record, field) => this.sortFieldKind(record, field)) : null;
1048
- return { records, cursor, total };
1056
+ return { records, cursor };
1049
1057
  }
1050
1058
  /**
1051
1059
  * Atomically verify fileId is unreferenced, then hard-delete its
@@ -1058,7 +1066,7 @@ var SharedSqlRecordLogic = class {
1058
1066
  return this.exec.transaction(() => {
1059
1067
  const referenced = this.exec.all(`SELECT 1 as found FROM associations WHERE kind = 'attachment' AND file_id = ?
1060
1068
  UNION ALL
1061
- SELECT 1 FROM file_refs WHERE file_id = ?
1069
+ SELECT 1 FROM content_index WHERE file_id = ?
1062
1070
  LIMIT 1`, [fileId, fileId]);
1063
1071
  if (referenced.length) {
1064
1072
  throw new StackConflictError("Attachment is still referenced by one or more records");
@@ -1178,8 +1186,7 @@ var SharedSqlRecordLogic = class {
1178
1186
  toMs(type.createdAt)
1179
1187
  ]);
1180
1188
  const schema = type.schema;
1181
- this.fileRefFieldsByType.set(type.id, fileRefFieldNames(schema));
1182
- this.sortFieldsByType.set(type.id, sortableFieldKinds(schema));
1189
+ this.indexedFieldsByType.set(type.id, indexedFieldKinds(schema));
1183
1190
  }
1184
1191
  async getType(id) {
1185
1192
  const rows = this.exec.all("SELECT * FROM types WHERE id = ?", [id]);
@@ -1255,67 +1262,49 @@ var SharedSqlRecordLogic = class {
1255
1262
  return rows.map(rowToAssociation);
1256
1263
  }
1257
1264
  /**
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().
1265
+ * The fields content_index covers for typeId, from the in-memory cache
1266
+ * — falling back to a `types` lookup (and populating the cache) only for
1267
+ * a typeId this process hasn't seen via saveType() yet, e.g. right after
1268
+ * open().
1261
1269
  */
1262
- getFileRefFields(typeId) {
1263
- const cached = this.fileRefFieldsByType.get(typeId);
1270
+ getIndexedFields(typeId) {
1271
+ const cached = this.indexedFieldsByType.get(typeId);
1264
1272
  if (cached)
1265
1273
  return cached;
1266
1274
  const typeRow = this.exec.get("SELECT schema FROM types WHERE id = ?", [
1267
1275
  typeId
1268
1276
  ]);
1269
- const fields = typeRow ? fileRefFieldNames(JSON.parse(typeRow.schema)) : [];
1270
- this.fileRefFieldsByType.set(typeId, fields);
1277
+ const fields = typeRow ? indexedFieldKinds(JSON.parse(typeRow.schema)) : /* @__PURE__ */ new Map();
1278
+ this.indexedFieldsByType.set(typeId, fields);
1271
1279
  return fields;
1272
1280
  }
1273
1281
  /**
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).
1282
+ * Replace a record's content_index rows with what its content currently
1283
+ * holds, on every write that can change content or typeId, so neither
1284
+ * the ordering nor the file-reference lookup can drift from the record.
1285
+ * A field holding no orderable value gets no row at all, which is what
1286
+ * puts the record at the end of a sort on that field.
1278
1287
  */
1279
- getSortFields(typeId) {
1280
- const cached = this.sortFieldsByType.get(typeId);
1281
- if (cached)
1282
- return cached;
1283
- const typeRow = this.exec.get("SELECT schema FROM types WHERE id = ?", [
1284
- typeId
1285
- ]);
1286
- const fields = typeRow ? sortableFieldKinds(JSON.parse(typeRow.schema)) : /* @__PURE__ */ new Map();
1287
- this.sortFieldsByType.set(typeId, fields);
1288
- return fields;
1289
- }
1290
- /**
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.
1295
- */
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)) {
1288
+ syncContentIndex(recordId, typeId, content) {
1289
+ this.exec.run("DELETE FROM content_index WHERE record_id = ?", [recordId]);
1290
+ for (const [field, kind] of this.getIndexedFields(typeId)) {
1299
1291
  const entry = contentSortEntry(kind, content[field]);
1300
1292
  if (!entry)
1301
1293
  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]);
1294
+ const sql = `INSERT OR IGNORE INTO content_index
1295
+ (record_id, field, value_rank, num_value, text_key, text_value, file_id)
1296
+ VALUES (?, ?, ?, ?, ?, ?, ?)`;
1297
+ if (entry.kind === "num") {
1298
+ this.exec.run(sql, [recordId, field, 0, entry.num, null, null, null]);
1299
+ } else {
1300
+ const fileId = kind === "file-ref" ? entry.text : null;
1301
+ this.exec.run(sql, [recordId, field, 1, null, entry.key, entry.text, fileId]);
1302
+ }
1304
1303
  }
1305
1304
  }
1306
1305
  /** The declared kind of a record's top-level `field`, if it has one. */
1307
1306
  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
- }
1307
+ return this.getIndexedFields(record.typeId).get(field);
1319
1308
  }
1320
1309
  };
1321
1310
  var SharedTokenLogic = class {
@@ -1444,14 +1433,19 @@ var NativeSQLiteRecordAdapter = class _NativeSQLiteRecordAdapter {
1444
1433
  }
1445
1434
  path;
1446
1435
  capabilities = {
1447
- fullTextSearch: true,
1448
- contentFieldQuery: true,
1449
- nestedContentQuery: true,
1450
- contentPresenceQuery: true,
1451
- contentFieldSort: true,
1452
- sortableFields: ["createdAt", "updatedAt", "version"],
1453
- maxAttachmentBytes: null,
1454
- maxContentBytes: null
1436
+ filter: {
1437
+ content: "path",
1438
+ contentPresent: true,
1439
+ search: true
1440
+ },
1441
+ sort: {
1442
+ fields: ["createdAt", "updatedAt", "version"],
1443
+ contentField: true
1444
+ },
1445
+ limits: {
1446
+ attachmentBytes: null,
1447
+ contentBytes: null
1448
+ }
1455
1449
  };
1456
1450
  ownerEntityId;
1457
1451
  timezone;