@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 +189 -193
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
|
66
|
-
--
|
|
67
|
-
--
|
|
68
|
-
--
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
CREATE INDEX IF NOT EXISTS
|
|
101
|
-
CREATE INDEX IF NOT EXISTS
|
|
102
|
-
CREATE INDEX IF NOT EXISTS
|
|
103
|
-
CREATE INDEX IF NOT EXISTS
|
|
104
|
-
CREATE INDEX IF NOT EXISTS
|
|
105
|
-
CREATE INDEX IF NOT EXISTS
|
|
106
|
-
CREATE INDEX IF NOT EXISTS
|
|
107
|
-
CREATE INDEX IF NOT EXISTS
|
|
108
|
-
|
|
109
|
-
|
|
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*\(
|
|
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(/""
|
|
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
|
|
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`\
|
|
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
|
|
316
|
-
|
|
317
|
-
if (field
|
|
318
|
-
|
|
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
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
|
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
|
-
|
|
505
|
-
|
|
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
|
|
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
|
|
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
|
|
657
|
-
* hit the `types` table and
|
|
658
|
-
* Populated eagerly in saveType()
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
974
|
-
|
|
975
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
1198
|
-
* back to a `types` lookup (and populating the cache) only for
|
|
1199
|
-
* this process hasn't seen via saveType() yet, e.g. right after
|
|
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
|
-
|
|
1219
|
-
const cached = this.
|
|
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 ?
|
|
1226
|
-
this.
|
|
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
|
|
1231
|
-
* holds, on every write that can change content or typeId
|
|
1232
|
-
*
|
|
1233
|
-
*
|
|
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
|
-
|
|
1236
|
-
this.exec.run("DELETE FROM
|
|
1237
|
-
for (const [field, kind] of this.
|
|
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
|
-
|
|
1242
|
-
|
|
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.
|
|
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
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
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;
|