@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 +189 -195
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
|
73
|
-
--
|
|
74
|
-
--
|
|
75
|
-
--
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
CREATE INDEX IF NOT EXISTS
|
|
108
|
-
CREATE INDEX IF NOT EXISTS
|
|
109
|
-
CREATE INDEX IF NOT EXISTS
|
|
110
|
-
CREATE INDEX IF NOT EXISTS
|
|
111
|
-
CREATE INDEX IF NOT EXISTS
|
|
112
|
-
CREATE INDEX IF NOT EXISTS
|
|
113
|
-
CREATE INDEX IF NOT EXISTS
|
|
114
|
-
CREATE INDEX IF NOT EXISTS
|
|
115
|
-
|
|
116
|
-
|
|
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*\(
|
|
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(/""
|
|
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
|
|
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`\
|
|
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
|
|
337
|
-
|
|
338
|
-
if (field
|
|
339
|
-
|
|
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
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
|
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
|
-
|
|
526
|
-
|
|
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
|
|
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
|
|
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
|
|
718
|
-
* hit the `types` table and
|
|
719
|
-
* Populated eagerly in saveType()
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
1035
|
-
|
|
1036
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
1259
|
-
* back to a `types` lookup (and populating the cache) only for
|
|
1260
|
-
* this process hasn't seen via saveType() yet, e.g. right after
|
|
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
|
-
|
|
1263
|
-
const cached = this.
|
|
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 ?
|
|
1270
|
-
this.
|
|
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
|
|
1275
|
-
* holds
|
|
1276
|
-
*
|
|
1277
|
-
*
|
|
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
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
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
|
-
|
|
1303
|
-
|
|
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.
|
|
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
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
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;
|