@haverstack/record-adapter-do-sqlite 0.1.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/LICENSE +7 -0
- package/dist/index.d.ts +134 -0
- package/dist/index.js +1174 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1174 @@
|
|
|
1
|
+
import { StackVersionConflictError, StackNotFoundError, StackConflictError, applyMergePatch, StackQueryError } from '@haverstack/core';
|
|
2
|
+
|
|
3
|
+
// ../sqlite-shared/dist/schema.js
|
|
4
|
+
var RECORD_SCHEMA_SQL = `
|
|
5
|
+
CREATE TABLE IF NOT EXISTS records (
|
|
6
|
+
id TEXT PRIMARY KEY,
|
|
7
|
+
type_id TEXT NOT NULL,
|
|
8
|
+
created_at INTEGER NOT NULL,
|
|
9
|
+
updated_at INTEGER NOT NULL,
|
|
10
|
+
content TEXT NOT NULL CHECK (json_valid(content)),
|
|
11
|
+
version INTEGER NOT NULL DEFAULT 1,
|
|
12
|
+
parent_id TEXT,
|
|
13
|
+
entity_id TEXT,
|
|
14
|
+
app_id TEXT,
|
|
15
|
+
principal_id TEXT,
|
|
16
|
+
updated_by TEXT,
|
|
17
|
+
updated_via TEXT,
|
|
18
|
+
deleted_at INTEGER,
|
|
19
|
+
unlisted_at INTEGER,
|
|
20
|
+
permissions TEXT CHECK (permissions IS NULL OR json_valid(permissions))
|
|
21
|
+
) STRICT;
|
|
22
|
+
|
|
23
|
+
-- A relationship's target is (related_scope, related_id) plus one
|
|
24
|
+
-- qualifier: related_stack for a record in another stack, related_ns for
|
|
25
|
+
-- a foreign namespace. All four are in the primary key, so two targets
|
|
26
|
+
-- differing only by namespace are two associations.
|
|
27
|
+
CREATE TABLE IF NOT EXISTS associations (
|
|
28
|
+
record_id TEXT NOT NULL REFERENCES records(id),
|
|
29
|
+
kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship')),
|
|
30
|
+
label TEXT NOT NULL,
|
|
31
|
+
file_id TEXT NOT NULL DEFAULT '',
|
|
32
|
+
related_scope TEXT NOT NULL DEFAULT '' CHECK (related_scope IN ('', 'record', 'entity', 'external')),
|
|
33
|
+
related_id TEXT NOT NULL DEFAULT '',
|
|
34
|
+
related_ns TEXT NOT NULL DEFAULT '',
|
|
35
|
+
related_stack TEXT NOT NULL DEFAULT '',
|
|
36
|
+
PRIMARY KEY (record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack)
|
|
37
|
+
) STRICT;
|
|
38
|
+
|
|
39
|
+
CREATE TABLE IF NOT EXISTS versions (
|
|
40
|
+
record_id TEXT NOT NULL REFERENCES records(id),
|
|
41
|
+
version INTEGER NOT NULL,
|
|
42
|
+
type_id TEXT NOT NULL,
|
|
43
|
+
content TEXT NOT NULL CHECK (json_valid(content)),
|
|
44
|
+
updated_at INTEGER NOT NULL,
|
|
45
|
+
entity_id TEXT,
|
|
46
|
+
updated_by TEXT,
|
|
47
|
+
updated_via TEXT,
|
|
48
|
+
associations TEXT CHECK (associations IS NULL OR json_valid(associations)),
|
|
49
|
+
permissions TEXT CHECK (permissions IS NULL OR json_valid(permissions)),
|
|
50
|
+
PRIMARY KEY (record_id, version)
|
|
51
|
+
) STRICT;
|
|
52
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS types (
|
|
54
|
+
id TEXT PRIMARY KEY,
|
|
55
|
+
base_id TEXT NOT NULL,
|
|
56
|
+
version INTEGER NOT NULL,
|
|
57
|
+
name TEXT NOT NULL,
|
|
58
|
+
schema TEXT NOT NULL CHECK (json_valid(schema)),
|
|
59
|
+
schema_hash TEXT NOT NULL,
|
|
60
|
+
migrates_from TEXT,
|
|
61
|
+
created_at INTEGER NOT NULL
|
|
62
|
+
) STRICT;
|
|
63
|
+
|
|
64
|
+
-- One row per top-level file-ref content field on a record, kept in sync
|
|
65
|
+
-- on every content/typeId write. Lets the attachmentFileId query filter
|
|
66
|
+
-- and deleteAttachment()'s reference check see content-held file
|
|
67
|
+
-- references, not just attachment associations.
|
|
68
|
+
CREATE TABLE IF NOT EXISTS file_refs (
|
|
69
|
+
record_id TEXT NOT NULL REFERENCES records(id),
|
|
70
|
+
field TEXT NOT NULL,
|
|
71
|
+
file_id TEXT NOT NULL,
|
|
72
|
+
PRIMARY KEY (record_id, field)
|
|
73
|
+
) STRICT;
|
|
74
|
+
|
|
75
|
+
-- Indexes
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_records_type_id ON records(type_id);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_records_parent_id ON records(parent_id);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_records_entity_id ON records(entity_id);
|
|
79
|
+
CREATE INDEX IF NOT EXISTS idx_records_app_id ON records(app_id);
|
|
80
|
+
CREATE INDEX IF NOT EXISTS idx_records_principal_id ON records(principal_id);
|
|
81
|
+
CREATE INDEX IF NOT EXISTS idx_records_deleted_at ON records(deleted_at);
|
|
82
|
+
CREATE INDEX IF NOT EXISTS idx_records_unlisted_at ON records(unlisted_at);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS idx_records_created_at ON records(created_at);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_records_updated_at ON records(updated_at);
|
|
85
|
+
CREATE INDEX IF NOT EXISTS idx_assoc_record_id ON associations(record_id);
|
|
86
|
+
CREATE INDEX IF NOT EXISTS idx_assoc_kind_label ON associations(kind, label);
|
|
87
|
+
CREATE INDEX IF NOT EXISTS idx_assoc_kind_file_id ON associations(kind, file_id);
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_assoc_related ON associations(related_scope, related_ns, related_id);
|
|
89
|
+
CREATE INDEX IF NOT EXISTS idx_types_base_id ON types(base_id);
|
|
90
|
+
CREATE INDEX IF NOT EXISTS idx_file_refs_file_id ON file_refs(file_id);
|
|
91
|
+
`;
|
|
92
|
+
var FTS5_SCHEMA_SQL = `
|
|
93
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS records_fts USING fts5(
|
|
94
|
+
content,
|
|
95
|
+
content='records',
|
|
96
|
+
content_rowid='rowid'
|
|
97
|
+
);
|
|
98
|
+
`;
|
|
99
|
+
var PRAGMA_FOREIGN_KEYS_ON = `PRAGMA foreign_keys = ON;`;
|
|
100
|
+
var SORT_FIELDS = ["createdAt", "updatedAt", "version"];
|
|
101
|
+
var encodeCursor = (field, value, id) => btoa(`${field}|${value}|${id}`);
|
|
102
|
+
var decodeCursor = (cursor) => {
|
|
103
|
+
let decoded;
|
|
104
|
+
try {
|
|
105
|
+
decoded = atob(cursor);
|
|
106
|
+
} catch {
|
|
107
|
+
throw new StackQueryError(`Invalid cursor: malformed "${cursor}"`);
|
|
108
|
+
}
|
|
109
|
+
const parts = decoded.split("|");
|
|
110
|
+
if (parts.length !== 3) {
|
|
111
|
+
throw new StackQueryError(`Invalid cursor: malformed "${cursor}"`);
|
|
112
|
+
}
|
|
113
|
+
const [field, value, id] = parts;
|
|
114
|
+
if (!SORT_FIELDS.includes(field)) {
|
|
115
|
+
throw new StackQueryError(`Invalid cursor: unknown sort field "${field}"`);
|
|
116
|
+
}
|
|
117
|
+
const numericValue = Number(value);
|
|
118
|
+
if (!isFinite(numericValue)) {
|
|
119
|
+
throw new StackQueryError(`Invalid cursor: non-numeric sort value`);
|
|
120
|
+
}
|
|
121
|
+
return { field, value: numericValue, id };
|
|
122
|
+
};
|
|
123
|
+
var getSortField = (query) => query.sort?.field ?? "createdAt";
|
|
124
|
+
var getSortColumn = (field) => field === "createdAt" ? "created_at" : field === "updatedAt" ? "updated_at" : "version";
|
|
125
|
+
var makeCursor = (record, field) => {
|
|
126
|
+
const value = field === "updatedAt" ? record.updatedAt.getTime() : field === "version" ? record.version : record.createdAt.getTime();
|
|
127
|
+
return encodeCursor(field, value, record.id);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// ../sqlite-shared/dist/fts5.js
|
|
131
|
+
var sanitizeFts5Query = (query, maxDepth = 2) => {
|
|
132
|
+
if (!query)
|
|
133
|
+
return "";
|
|
134
|
+
let clean = query.replace(/\*/g, "");
|
|
135
|
+
clean = clean.replace(/\bNEAR\s*\(\s*([^,)]*)(?:,[^)]*)?\)/gi, "$1");
|
|
136
|
+
clean = clean.replace(/:/g, " ");
|
|
137
|
+
clean = clean.replace(/(?:^|\(\s*)NOT\s+/gi, (m) => m.replace(/NOT\s+/i, ""));
|
|
138
|
+
let currentDepth = 0;
|
|
139
|
+
let result = "";
|
|
140
|
+
for (const char of clean) {
|
|
141
|
+
if (char === "(") {
|
|
142
|
+
if (currentDepth < maxDepth) {
|
|
143
|
+
currentDepth++;
|
|
144
|
+
result += char;
|
|
145
|
+
} else
|
|
146
|
+
result += " ";
|
|
147
|
+
} else if (char === ")") {
|
|
148
|
+
if (currentDepth > 0) {
|
|
149
|
+
currentDepth--;
|
|
150
|
+
result += char;
|
|
151
|
+
} else
|
|
152
|
+
result += " ";
|
|
153
|
+
} else {
|
|
154
|
+
result += char;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (currentDepth > 0)
|
|
158
|
+
result += ")".repeat(currentDepth);
|
|
159
|
+
let prev;
|
|
160
|
+
do {
|
|
161
|
+
prev = result;
|
|
162
|
+
result = result.replace(/\(\s*\)/g, " ");
|
|
163
|
+
} while (result !== prev);
|
|
164
|
+
return result.replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim();
|
|
165
|
+
};
|
|
166
|
+
var fts5Strategy = {
|
|
167
|
+
/** Index a record that has no FTS entry yet (used right after an INSERT). */
|
|
168
|
+
insert(exec, recordId, content) {
|
|
169
|
+
exec.run(`INSERT INTO records_fts(rowid, content) SELECT rowid, ? FROM records WHERE id = ?`, [
|
|
170
|
+
content,
|
|
171
|
+
recordId
|
|
172
|
+
]);
|
|
173
|
+
},
|
|
174
|
+
/**
|
|
175
|
+
* Remove a record's FTS entry. MUST be called before the records row's
|
|
176
|
+
* content changes or the row is deleted — see the note above.
|
|
177
|
+
*/
|
|
178
|
+
remove(exec, recordId) {
|
|
179
|
+
const row = exec.get("SELECT rowid, content FROM records WHERE id = ?", [recordId]);
|
|
180
|
+
if (!row)
|
|
181
|
+
return;
|
|
182
|
+
exec.run(`INSERT INTO records_fts(records_fts, rowid, content) VALUES('delete', ?, ?)`, [
|
|
183
|
+
row.rowid,
|
|
184
|
+
row.content
|
|
185
|
+
]);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// ../sqlite-shared/dist/query.js
|
|
190
|
+
var sqlDirection = (query) => {
|
|
191
|
+
const dir = query.sort?.direction ?? "desc";
|
|
192
|
+
if (dir !== "asc" && dir !== "desc") {
|
|
193
|
+
throw new StackQueryError(`Invalid sort direction "${dir}": expected "asc" or "desc".`);
|
|
194
|
+
}
|
|
195
|
+
return dir === "asc" ? "ASC" : "DESC";
|
|
196
|
+
};
|
|
197
|
+
var buildWhereClause = (query) => {
|
|
198
|
+
const conditions = ["r.id != '_config'"];
|
|
199
|
+
const params = [];
|
|
200
|
+
const f = query.filter ?? {};
|
|
201
|
+
if (!f.includeDeleted) {
|
|
202
|
+
conditions.push("r.deleted_at IS NULL");
|
|
203
|
+
}
|
|
204
|
+
if (!f.includeUnlisted) {
|
|
205
|
+
conditions.push("r.unlisted_at IS NULL");
|
|
206
|
+
}
|
|
207
|
+
if (f.typeId !== void 0) {
|
|
208
|
+
const ids = Array.isArray(f.typeId) ? f.typeId : [f.typeId];
|
|
209
|
+
conditions.push(`r.type_id IN (${ids.map(() => "?").join(",")})`);
|
|
210
|
+
params.push(...ids);
|
|
211
|
+
}
|
|
212
|
+
if (f.parentId !== void 0) {
|
|
213
|
+
if (f.parentId === null) {
|
|
214
|
+
conditions.push("r.parent_id IS NULL");
|
|
215
|
+
} else {
|
|
216
|
+
conditions.push("r.parent_id = ?");
|
|
217
|
+
params.push(f.parentId);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (f.appId !== void 0) {
|
|
221
|
+
const ids = Array.isArray(f.appId) ? f.appId : [f.appId];
|
|
222
|
+
conditions.push(`r.app_id IN (${ids.map(() => "?").join(",")})`);
|
|
223
|
+
params.push(...ids);
|
|
224
|
+
}
|
|
225
|
+
if (f.entityId !== void 0) {
|
|
226
|
+
const ids = Array.isArray(f.entityId) ? f.entityId : [f.entityId];
|
|
227
|
+
conditions.push(`r.entity_id IN (${ids.map(() => "?").join(",")})`);
|
|
228
|
+
params.push(...ids);
|
|
229
|
+
}
|
|
230
|
+
if (f.principalId !== void 0) {
|
|
231
|
+
const ids = Array.isArray(f.principalId) ? f.principalId : [f.principalId];
|
|
232
|
+
conditions.push(`r.principal_id IN (${ids.map(() => "?").join(",")})`);
|
|
233
|
+
params.push(...ids);
|
|
234
|
+
}
|
|
235
|
+
if (f.createdAt?.after) {
|
|
236
|
+
conditions.push("r.created_at > ?");
|
|
237
|
+
params.push(f.createdAt.after.getTime());
|
|
238
|
+
}
|
|
239
|
+
if (f.createdAt?.before) {
|
|
240
|
+
conditions.push("r.created_at < ?");
|
|
241
|
+
params.push(f.createdAt.before.getTime());
|
|
242
|
+
}
|
|
243
|
+
if (f.updatedAt?.after) {
|
|
244
|
+
conditions.push("r.updated_at > ?");
|
|
245
|
+
params.push(f.updatedAt.after.getTime());
|
|
246
|
+
}
|
|
247
|
+
if (f.updatedAt?.before) {
|
|
248
|
+
conditions.push("r.updated_at < ?");
|
|
249
|
+
params.push(f.updatedAt.before.getTime());
|
|
250
|
+
}
|
|
251
|
+
if (f.tags?.length) {
|
|
252
|
+
for (const tag of f.tags) {
|
|
253
|
+
conditions.push(`r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'tag' AND a.label = ?)`);
|
|
254
|
+
params.push(tag);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (f.hasAttachment) {
|
|
258
|
+
conditions.push(`r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'attachment' AND a.label = ?)`);
|
|
259
|
+
params.push(f.hasAttachment);
|
|
260
|
+
}
|
|
261
|
+
if (f.attachmentFileId) {
|
|
262
|
+
conditions.push(`(r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'attachment' AND a.file_id = ?)
|
|
263
|
+
OR r.id IN (SELECT fr.record_id FROM file_refs fr WHERE fr.file_id = ?))`);
|
|
264
|
+
params.push(f.attachmentFileId, f.attachmentFileId);
|
|
265
|
+
}
|
|
266
|
+
if (f.relatedTo) {
|
|
267
|
+
const clauses = [];
|
|
268
|
+
const target = f.relatedTo.target;
|
|
269
|
+
if (f.relatedTo.label !== void 0) {
|
|
270
|
+
clauses.push("a.label = ?");
|
|
271
|
+
params.push(f.relatedTo.label);
|
|
272
|
+
}
|
|
273
|
+
if (target) {
|
|
274
|
+
clauses.push("a.related_scope = ?");
|
|
275
|
+
params.push(target.scope);
|
|
276
|
+
if (target.scope === "record") {
|
|
277
|
+
clauses.push("a.related_id = ?", "a.related_stack = ?");
|
|
278
|
+
params.push(target.recordId, target.stackUrl ?? "");
|
|
279
|
+
} else if (target.scope === "entity") {
|
|
280
|
+
clauses.push("a.related_id = ?");
|
|
281
|
+
params.push(target.entityId);
|
|
282
|
+
} else {
|
|
283
|
+
clauses.push("a.related_ns = ?");
|
|
284
|
+
params.push(target.ns);
|
|
285
|
+
if (target.id !== void 0) {
|
|
286
|
+
clauses.push("a.related_id = ?");
|
|
287
|
+
params.push(target.id);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
conditions.push(`r.id IN (SELECT a.record_id FROM associations a WHERE a.kind = 'relationship'` + clauses.map((c) => ` AND ${c}`).join("") + `)`);
|
|
292
|
+
}
|
|
293
|
+
if (f.content) {
|
|
294
|
+
for (const [key, value] of Object.entries(f.content)) {
|
|
295
|
+
if (value === null) {
|
|
296
|
+
conditions.push(`json_extract(r.content, ?) IS NULL`);
|
|
297
|
+
params.push(`$.${key}`);
|
|
298
|
+
} else {
|
|
299
|
+
conditions.push(`json_extract(r.content, ?) = ?`);
|
|
300
|
+
params.push(`$.${key}`, value);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (f.search) {
|
|
305
|
+
const sanitized = sanitizeFts5Query(f.search);
|
|
306
|
+
if (sanitized) {
|
|
307
|
+
conditions.push(`r.rowid IN (SELECT rowid FROM records_fts WHERE records_fts MATCH ?)`);
|
|
308
|
+
params.push(sanitized);
|
|
309
|
+
} else {
|
|
310
|
+
conditions.push("0");
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (query.cursor) {
|
|
314
|
+
const { field: cursorField, value: numericValue, id: cursorId } = decodeCursor(query.cursor);
|
|
315
|
+
const sortField = getSortField(query);
|
|
316
|
+
if (cursorField !== sortField) {
|
|
317
|
+
throw new StackQueryError(`Cursor sort field "${cursorField}" does not match query sort field "${sortField}"`);
|
|
318
|
+
}
|
|
319
|
+
const col = getSortColumn(cursorField);
|
|
320
|
+
const op = sqlDirection(query) === "ASC" ? ">" : "<";
|
|
321
|
+
conditions.push(`(r.${col} ${op} ? OR (r.${col} = ? AND r.id ${op} ?))`);
|
|
322
|
+
params.push(numericValue, numericValue, cursorId);
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
sql: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "",
|
|
326
|
+
params
|
|
327
|
+
};
|
|
328
|
+
};
|
|
329
|
+
var buildOrderClause = (query) => {
|
|
330
|
+
const field = getSortField(query);
|
|
331
|
+
const dir = sqlDirection(query);
|
|
332
|
+
return `ORDER BY r.${getSortColumn(field)} ${dir}, r.id ${dir}`;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
// ../sqlite-shared/dist/mappers.js
|
|
336
|
+
var toMs = (d) => d.getTime();
|
|
337
|
+
var fromMs = (ms) => new Date(ms);
|
|
338
|
+
var rowToRecord = (row, associations) => {
|
|
339
|
+
const record = {
|
|
340
|
+
id: row.id,
|
|
341
|
+
typeId: row.type_id,
|
|
342
|
+
createdAt: fromMs(row.created_at),
|
|
343
|
+
updatedAt: fromMs(row.updated_at),
|
|
344
|
+
content: JSON.parse(row.content),
|
|
345
|
+
version: row.version
|
|
346
|
+
};
|
|
347
|
+
if (row.parent_id)
|
|
348
|
+
record.parentId = row.parent_id;
|
|
349
|
+
if (row.entity_id)
|
|
350
|
+
record.entityId = row.entity_id;
|
|
351
|
+
if (row.app_id)
|
|
352
|
+
record.appId = row.app_id;
|
|
353
|
+
if (row.principal_id)
|
|
354
|
+
record.principalId = row.principal_id;
|
|
355
|
+
if (row.updated_by)
|
|
356
|
+
record.updatedBy = row.updated_by;
|
|
357
|
+
if (row.updated_via)
|
|
358
|
+
record.updatedVia = row.updated_via;
|
|
359
|
+
if (row.deleted_at)
|
|
360
|
+
record.deletedAt = fromMs(row.deleted_at);
|
|
361
|
+
if (row.unlisted_at)
|
|
362
|
+
record.unlistedAt = fromMs(row.unlisted_at);
|
|
363
|
+
if (row.permissions)
|
|
364
|
+
record.permissions = JSON.parse(row.permissions);
|
|
365
|
+
if (associations.length)
|
|
366
|
+
record.associations = associations;
|
|
367
|
+
return record;
|
|
368
|
+
};
|
|
369
|
+
var rowToAssociation = (row) => {
|
|
370
|
+
if (row.kind === "tag") {
|
|
371
|
+
return { kind: "tag", label: row.label };
|
|
372
|
+
}
|
|
373
|
+
if (row.kind === "attachment") {
|
|
374
|
+
return {
|
|
375
|
+
kind: "attachment",
|
|
376
|
+
label: row.label,
|
|
377
|
+
fileId: row.file_id
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
kind: "relationship",
|
|
382
|
+
label: row.label,
|
|
383
|
+
target: rowToTarget(row)
|
|
384
|
+
};
|
|
385
|
+
};
|
|
386
|
+
var associationKeyColumns = (a) => {
|
|
387
|
+
if (a.kind === "attachment")
|
|
388
|
+
return [a.fileId, "", "", "", ""];
|
|
389
|
+
if (a.kind !== "relationship")
|
|
390
|
+
return ["", "", "", "", ""];
|
|
391
|
+
const t = a.target;
|
|
392
|
+
if (t.scope === "entity")
|
|
393
|
+
return ["", "entity", t.entityId, "", ""];
|
|
394
|
+
if (t.scope === "external")
|
|
395
|
+
return ["", "external", t.id, t.ns, ""];
|
|
396
|
+
return ["", "record", t.recordId, "", t.stackUrl ?? ""];
|
|
397
|
+
};
|
|
398
|
+
var rowToTarget = (row) => {
|
|
399
|
+
const id = row.related_id;
|
|
400
|
+
if (row.related_scope === "entity")
|
|
401
|
+
return { scope: "entity", entityId: id };
|
|
402
|
+
if (row.related_scope === "external") {
|
|
403
|
+
return { scope: "external", ns: row.related_ns, id };
|
|
404
|
+
}
|
|
405
|
+
const stackUrl = row.related_stack;
|
|
406
|
+
return { scope: "record", recordId: id, ...stackUrl && { stackUrl } };
|
|
407
|
+
};
|
|
408
|
+
var rowToType = (row) => {
|
|
409
|
+
const t = {
|
|
410
|
+
id: row.id,
|
|
411
|
+
baseId: row.base_id,
|
|
412
|
+
version: row.version,
|
|
413
|
+
name: row.name,
|
|
414
|
+
schema: JSON.parse(row.schema),
|
|
415
|
+
schemaHash: row.schema_hash,
|
|
416
|
+
createdAt: fromMs(row.created_at)
|
|
417
|
+
};
|
|
418
|
+
if (row.migrates_from)
|
|
419
|
+
t.migratesFrom = row.migrates_from;
|
|
420
|
+
return t;
|
|
421
|
+
};
|
|
422
|
+
var rowToVersion = (row) => {
|
|
423
|
+
const v = {
|
|
424
|
+
version: row.version,
|
|
425
|
+
typeId: row.type_id,
|
|
426
|
+
content: JSON.parse(row.content),
|
|
427
|
+
updatedAt: fromMs(row.updated_at)
|
|
428
|
+
};
|
|
429
|
+
if (row.entity_id)
|
|
430
|
+
v.entityId = row.entity_id;
|
|
431
|
+
if (row.updated_by)
|
|
432
|
+
v.updatedBy = row.updated_by;
|
|
433
|
+
if (row.updated_via)
|
|
434
|
+
v.updatedVia = row.updated_via;
|
|
435
|
+
if (row.associations)
|
|
436
|
+
v.associations = JSON.parse(row.associations);
|
|
437
|
+
if (row.permissions)
|
|
438
|
+
v.permissions = JSON.parse(row.permissions);
|
|
439
|
+
return v;
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
// ../sqlite-shared/dist/executor.js
|
|
443
|
+
var isForeignKeyViolation = (err) => err instanceof Error && err.message.includes("FOREIGN KEY constraint failed");
|
|
444
|
+
var isUniqueConstraintViolation = (err) => err instanceof Error && err.message.includes("UNIQUE constraint failed");
|
|
445
|
+
|
|
446
|
+
// ../sqlite-shared/dist/config.js
|
|
447
|
+
var insertConfigRecord = (exec, entityId, timezone) => {
|
|
448
|
+
const now = Date.now();
|
|
449
|
+
exec.run(`INSERT INTO records (id, type_id, created_at, updated_at, content, version)
|
|
450
|
+
VALUES ('_config', '_config@1', ?, ?, ?, 1)`, [now, now, JSON.stringify({ entityId, timezone })]);
|
|
451
|
+
};
|
|
452
|
+
var readStackConfig = (exec) => {
|
|
453
|
+
const row = exec.get(`SELECT content FROM records WHERE id = '_config'`);
|
|
454
|
+
if (!row)
|
|
455
|
+
throw new Error("Stack database is missing its config record.");
|
|
456
|
+
const content = JSON.parse(row.content);
|
|
457
|
+
return { entityId: content.entityId, timezone: content.timezone };
|
|
458
|
+
};
|
|
459
|
+
var fileRefFieldNames = (schema) => Object.keys(schema).filter((field) => schema[field].kind === "file-ref");
|
|
460
|
+
var SharedSqlRecordLogic = class {
|
|
461
|
+
deps;
|
|
462
|
+
/**
|
|
463
|
+
* Per-typeId cache of file-ref field names, so syncFileRefs() doesn't
|
|
464
|
+
* hit the `types` table and re-parse schema JSON on every write.
|
|
465
|
+
* Populated eagerly in saveType() and lazily via getFileRefFields().
|
|
466
|
+
*/
|
|
467
|
+
fileRefFieldsByType = /* @__PURE__ */ new Map();
|
|
468
|
+
constructor(deps) {
|
|
469
|
+
this.deps = deps;
|
|
470
|
+
}
|
|
471
|
+
get exec() {
|
|
472
|
+
return this.deps.exec;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* SQL fragment (plus its bind params) that gates a records-table
|
|
476
|
+
* UPDATE/DELETE on the opt-in `expectedVersion` precondition. Empty when
|
|
477
|
+
* expectedVersion is omitted, keeping today's unconditional behavior.
|
|
478
|
+
*/
|
|
479
|
+
versionGuard(expectedVersion) {
|
|
480
|
+
return expectedVersion === void 0 ? { clause: "", params: [] } : { clause: " AND version = ?", params: [expectedVersion] };
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Precondition check for mutations that can't fold expectedVersion into
|
|
484
|
+
* their primary UPDATE's WHERE clause: patchContent/restoreVersion need
|
|
485
|
+
* fts.remove() to run *before* the records-table content changes, so
|
|
486
|
+
* the check happens first, standalone.
|
|
487
|
+
*/
|
|
488
|
+
checkExpectedVersion(record, expectedVersion) {
|
|
489
|
+
if (expectedVersion === void 0 || record.version === expectedVersion)
|
|
490
|
+
return;
|
|
491
|
+
throw new StackVersionConflictError(`Record "${record.id}" is at version ${record.version}, expected ${expectedVersion}`, record.id, expectedVersion, record.version);
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Called after a versionGuard()-gated statement affected zero rows.
|
|
495
|
+
* Distinguishes "record doesn't exist" (StackNotFoundError) from "it
|
|
496
|
+
* exists but isn't at expectedVersion" (StackVersionConflictError, with
|
|
497
|
+
* the actual current version for the caller to act on).
|
|
498
|
+
*/
|
|
499
|
+
throwVersionConflict(id, expectedVersion) {
|
|
500
|
+
const row = this.exec.get("SELECT version FROM records WHERE id = ?", [
|
|
501
|
+
id
|
|
502
|
+
]);
|
|
503
|
+
if (!row)
|
|
504
|
+
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
505
|
+
throw new StackVersionConflictError(`Record "${id}" is at version ${row.version}, expected ${expectedVersion}`, id, expectedVersion, row.version);
|
|
506
|
+
}
|
|
507
|
+
// -------------------------------------------------------
|
|
508
|
+
// Records
|
|
509
|
+
// -------------------------------------------------------
|
|
510
|
+
/**
|
|
511
|
+
* A duplicate id hits the `records` PK — mapped to StackConflictError
|
|
512
|
+
* instead of surfacing the raw engine exception, mirroring saveVersion's
|
|
513
|
+
* collision mapping below.
|
|
514
|
+
*/
|
|
515
|
+
async createRecord(record) {
|
|
516
|
+
try {
|
|
517
|
+
this.exec.run(`INSERT INTO records
|
|
518
|
+
(id, type_id, created_at, updated_at, content, version,
|
|
519
|
+
parent_id, entity_id, app_id, principal_id, updated_by, updated_via,
|
|
520
|
+
deleted_at, unlisted_at, permissions)
|
|
521
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
522
|
+
record.id,
|
|
523
|
+
record.typeId,
|
|
524
|
+
toMs(record.createdAt),
|
|
525
|
+
toMs(record.updatedAt),
|
|
526
|
+
JSON.stringify(record.content),
|
|
527
|
+
record.version,
|
|
528
|
+
record.parentId ?? null,
|
|
529
|
+
record.entityId ?? null,
|
|
530
|
+
record.appId ?? null,
|
|
531
|
+
record.principalId ?? null,
|
|
532
|
+
record.updatedBy ?? null,
|
|
533
|
+
record.updatedVia ?? null,
|
|
534
|
+
record.deletedAt ? toMs(record.deletedAt) : null,
|
|
535
|
+
record.unlistedAt ? toMs(record.unlistedAt) : null,
|
|
536
|
+
record.permissions ? JSON.stringify(record.permissions) : null
|
|
537
|
+
]);
|
|
538
|
+
} catch (err) {
|
|
539
|
+
if (isUniqueConstraintViolation(err)) {
|
|
540
|
+
throw new StackConflictError(`Record already exists: "${record.id}"`);
|
|
541
|
+
}
|
|
542
|
+
throw err;
|
|
543
|
+
}
|
|
544
|
+
if (record.associations?.length) {
|
|
545
|
+
this.insertAssociations(record.id, record.associations);
|
|
546
|
+
}
|
|
547
|
+
fts5Strategy.insert(this.exec, record.id, JSON.stringify(record.content));
|
|
548
|
+
this.syncFileRefs(record.id, record.typeId, record.content);
|
|
549
|
+
return record;
|
|
550
|
+
}
|
|
551
|
+
async getRecord(id) {
|
|
552
|
+
return this.readRecord(id);
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* The synchronous read behind getRecord(). Callers inside exec.transaction()
|
|
556
|
+
* use this one: an `await` inside that callback yields the microtask queue
|
|
557
|
+
* mid-transaction, and the next operation to run would try to open one of
|
|
558
|
+
* its own — transaction() requires a synchronous callback for exactly this
|
|
559
|
+
* reason (see SqlExecutor.transaction).
|
|
560
|
+
*
|
|
561
|
+
* The same synchrony is what lets the post-commit reads below report the
|
|
562
|
+
* version their own mutation produced: `getRecord()` runs its body before
|
|
563
|
+
* the `await` yields, so nothing interleaves between the commit and the
|
|
564
|
+
* read. A backend that made these reads genuinely asynchronous would open
|
|
565
|
+
* that window, and a concurrent mutation could be reported under this
|
|
566
|
+
* one's verb.
|
|
567
|
+
*/
|
|
568
|
+
readRecord(id) {
|
|
569
|
+
const row = this.exec.get("SELECT * FROM records WHERE id = ?", [id]);
|
|
570
|
+
if (!row)
|
|
571
|
+
return null;
|
|
572
|
+
const associations = this.getAssociationsForRecord(id);
|
|
573
|
+
return rowToRecord(row, associations);
|
|
574
|
+
}
|
|
575
|
+
async patchContent(id, patch, opts = {}) {
|
|
576
|
+
const existing = await this.getRecord(id);
|
|
577
|
+
if (!existing)
|
|
578
|
+
throw new Error(`Record not found: "${id}"`);
|
|
579
|
+
this.checkExpectedVersion(existing, opts.expectedVersion);
|
|
580
|
+
const merged = applyMergePatch(existing.content, patch);
|
|
581
|
+
this.exec.transaction(() => {
|
|
582
|
+
if (opts.snapshot)
|
|
583
|
+
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
584
|
+
fts5Strategy.remove(this.exec, id);
|
|
585
|
+
this.exec.run(`UPDATE records SET content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?`, [
|
|
586
|
+
JSON.stringify(merged),
|
|
587
|
+
toMs(/* @__PURE__ */ new Date()),
|
|
588
|
+
opts.updatedBy ?? null,
|
|
589
|
+
opts.updatedVia ?? null,
|
|
590
|
+
id
|
|
591
|
+
]);
|
|
592
|
+
fts5Strategy.insert(this.exec, id, JSON.stringify(merged));
|
|
593
|
+
this.syncFileRefs(id, existing.typeId, merged);
|
|
594
|
+
});
|
|
595
|
+
const updated = await this.getRecord(id);
|
|
596
|
+
if (!updated)
|
|
597
|
+
throw new Error(`Record not found after patchContent: "${id}"`);
|
|
598
|
+
return updated;
|
|
599
|
+
}
|
|
600
|
+
async deleteRecord(id, opts = {}) {
|
|
601
|
+
if (opts.hard) {
|
|
602
|
+
return this.exec.transaction(() => this.hardDeleteRecord(id, opts.expectedVersion));
|
|
603
|
+
} else {
|
|
604
|
+
this.exec.transaction(() => {
|
|
605
|
+
if (opts.snapshot)
|
|
606
|
+
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
607
|
+
const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
|
|
608
|
+
const changed = this.exec.run(`UPDATE records SET deleted_at = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
|
|
609
|
+
toMs(/* @__PURE__ */ new Date()),
|
|
610
|
+
toMs(/* @__PURE__ */ new Date()),
|
|
611
|
+
opts.updatedBy ?? null,
|
|
612
|
+
opts.updatedVia ?? null,
|
|
613
|
+
id,
|
|
614
|
+
...verParams
|
|
615
|
+
]);
|
|
616
|
+
if (changed === 0)
|
|
617
|
+
this.throwVersionConflict(id, opts.expectedVersion);
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
const updated = await this.getRecord(id);
|
|
621
|
+
if (!updated)
|
|
622
|
+
throw new Error(`Record not found after deleteRecord: "${id}"`);
|
|
623
|
+
return updated;
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Deletes a record's FTS entry, associations, versions, file-refs, and
|
|
627
|
+
* row, returning the record as it stood — the last copy that will ever
|
|
628
|
+
* exist, read here so that nothing can overtake it between the read and
|
|
629
|
+
* the deletes. Null when there was no such record. No write() call —
|
|
630
|
+
* callers batch it. expectedVersion is checked first so a lost CAS race
|
|
631
|
+
* leaves nothing touched (children reference the row, so the check can't
|
|
632
|
+
* fold into the final DELETE).
|
|
633
|
+
*/
|
|
634
|
+
hardDeleteRecord(id, expectedVersion) {
|
|
635
|
+
const purged = this.readRecord(id);
|
|
636
|
+
if (expectedVersion !== void 0) {
|
|
637
|
+
if (!purged)
|
|
638
|
+
throw new StackNotFoundError(`Record not found: "${id}"`);
|
|
639
|
+
if (purged.version !== expectedVersion) {
|
|
640
|
+
throw new StackVersionConflictError(`Record "${id}" is at version ${purged.version}, expected ${expectedVersion}`, id, expectedVersion, purged.version);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (!purged)
|
|
644
|
+
return null;
|
|
645
|
+
fts5Strategy.remove(this.exec, id);
|
|
646
|
+
this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
|
|
647
|
+
this.exec.run("DELETE FROM versions WHERE record_id = ?", [id]);
|
|
648
|
+
this.exec.run("DELETE FROM file_refs WHERE record_id = ?", [id]);
|
|
649
|
+
this.exec.run("DELETE FROM records WHERE id = ?", [id]);
|
|
650
|
+
return purged;
|
|
651
|
+
}
|
|
652
|
+
async undeleteRecord(id, opts = {}) {
|
|
653
|
+
this.exec.transaction(() => {
|
|
654
|
+
if (opts.snapshot)
|
|
655
|
+
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
656
|
+
const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
|
|
657
|
+
const changed = this.exec.run(`UPDATE records SET deleted_at = NULL, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [toMs(/* @__PURE__ */ new Date()), opts.updatedBy ?? null, opts.updatedVia ?? null, id, ...verParams]);
|
|
658
|
+
if (changed === 0)
|
|
659
|
+
this.throwVersionConflict(id, opts.expectedVersion);
|
|
660
|
+
});
|
|
661
|
+
const updated = await this.getRecord(id);
|
|
662
|
+
if (!updated)
|
|
663
|
+
throw new Error(`Record not found after undelete: "${id}"`);
|
|
664
|
+
return updated;
|
|
665
|
+
}
|
|
666
|
+
async setPermissions(id, permissions, opts = {}) {
|
|
667
|
+
this.exec.transaction(() => {
|
|
668
|
+
if (opts.snapshot)
|
|
669
|
+
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
670
|
+
const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
|
|
671
|
+
const changed = this.exec.run(`UPDATE records SET permissions = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
|
|
672
|
+
permissions.length ? JSON.stringify(permissions) : null,
|
|
673
|
+
toMs(/* @__PURE__ */ new Date()),
|
|
674
|
+
opts.updatedBy ?? null,
|
|
675
|
+
opts.updatedVia ?? null,
|
|
676
|
+
id,
|
|
677
|
+
...verParams
|
|
678
|
+
]);
|
|
679
|
+
if (changed === 0)
|
|
680
|
+
this.throwVersionConflict(id, opts.expectedVersion);
|
|
681
|
+
});
|
|
682
|
+
const updated = await this.getRecord(id);
|
|
683
|
+
if (!updated)
|
|
684
|
+
throw new Error(`Record not found after setPermissions: "${id}"`);
|
|
685
|
+
return updated;
|
|
686
|
+
}
|
|
687
|
+
async setUnlisted(id, unlisted, opts = {}) {
|
|
688
|
+
this.exec.transaction(() => {
|
|
689
|
+
if (opts.snapshot)
|
|
690
|
+
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
691
|
+
const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
|
|
692
|
+
const now = toMs(/* @__PURE__ */ new Date());
|
|
693
|
+
const changed = this.exec.run(`UPDATE records SET unlisted_at = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [
|
|
694
|
+
unlisted ? now : null,
|
|
695
|
+
now,
|
|
696
|
+
opts.updatedBy ?? null,
|
|
697
|
+
opts.updatedVia ?? null,
|
|
698
|
+
id,
|
|
699
|
+
...verParams
|
|
700
|
+
]);
|
|
701
|
+
if (changed === 0)
|
|
702
|
+
this.throwVersionConflict(id, opts.expectedVersion);
|
|
703
|
+
});
|
|
704
|
+
const updated = await this.getRecord(id);
|
|
705
|
+
if (!updated)
|
|
706
|
+
throw new Error(`Record not found after setUnlisted: "${id}"`);
|
|
707
|
+
return updated;
|
|
708
|
+
}
|
|
709
|
+
async restoreVersion(id, version, opts = {}) {
|
|
710
|
+
const existing = await this.getRecord(id);
|
|
711
|
+
if (!existing)
|
|
712
|
+
throw new Error(`Record not found: "${id}"`);
|
|
713
|
+
this.checkExpectedVersion(existing, opts.expectedVersion);
|
|
714
|
+
const target = await this.getVersion(id, version);
|
|
715
|
+
if (!target)
|
|
716
|
+
throw new Error(`Version not found: ${id}@${version}`);
|
|
717
|
+
this.exec.transaction(() => {
|
|
718
|
+
if (opts.snapshot)
|
|
719
|
+
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
720
|
+
fts5Strategy.remove(this.exec, id);
|
|
721
|
+
this.exec.run(`UPDATE records SET type_id = ?, content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?`, [
|
|
722
|
+
target.typeId,
|
|
723
|
+
JSON.stringify(target.content),
|
|
724
|
+
toMs(/* @__PURE__ */ new Date()),
|
|
725
|
+
opts.updatedBy ?? null,
|
|
726
|
+
opts.updatedVia ?? null,
|
|
727
|
+
id
|
|
728
|
+
]);
|
|
729
|
+
if (target.associations !== void 0) {
|
|
730
|
+
this.exec.run("DELETE FROM associations WHERE record_id = ?", [id]);
|
|
731
|
+
if (target.associations.length)
|
|
732
|
+
this.insertAssociations(id, target.associations);
|
|
733
|
+
}
|
|
734
|
+
fts5Strategy.insert(this.exec, id, JSON.stringify(target.content));
|
|
735
|
+
this.syncFileRefs(id, target.typeId, target.content);
|
|
736
|
+
});
|
|
737
|
+
const updated = await this.getRecord(id);
|
|
738
|
+
if (!updated)
|
|
739
|
+
throw new Error(`Record not found after restoreVersion: "${id}"`);
|
|
740
|
+
return updated;
|
|
741
|
+
}
|
|
742
|
+
async commitMigration(id, toTypeId, content, opts = {}) {
|
|
743
|
+
const existing = await this.getRecord(id);
|
|
744
|
+
if (!existing)
|
|
745
|
+
throw new Error(`Record not found: "${id}"`);
|
|
746
|
+
this.checkExpectedVersion(existing, opts.expectedVersion);
|
|
747
|
+
this.exec.transaction(() => {
|
|
748
|
+
if (opts.snapshot)
|
|
749
|
+
this.snapshotBeforeMutation(id, opts.snapshot);
|
|
750
|
+
fts5Strategy.remove(this.exec, id);
|
|
751
|
+
this.exec.run(`UPDATE records SET type_id = ?, content = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?`, [
|
|
752
|
+
toTypeId,
|
|
753
|
+
JSON.stringify(content),
|
|
754
|
+
toMs(/* @__PURE__ */ new Date()),
|
|
755
|
+
opts.updatedBy ?? null,
|
|
756
|
+
opts.updatedVia ?? null,
|
|
757
|
+
id
|
|
758
|
+
]);
|
|
759
|
+
fts5Strategy.insert(this.exec, id, JSON.stringify(content));
|
|
760
|
+
this.syncFileRefs(id, toTypeId, content);
|
|
761
|
+
});
|
|
762
|
+
const updated = await this.getRecord(id);
|
|
763
|
+
if (!updated)
|
|
764
|
+
throw new Error(`Record not found after commitMigration: "${id}"`);
|
|
765
|
+
return updated;
|
|
766
|
+
}
|
|
767
|
+
async queryRecords(query) {
|
|
768
|
+
const { sql: where, params } = buildWhereClause(query);
|
|
769
|
+
const order = buildOrderClause(query);
|
|
770
|
+
const limit = query.limit ?? 50;
|
|
771
|
+
const rows = this.exec.all(`SELECT r.* FROM records r ${where} ${order} LIMIT ?`, [...params, limit + 1]);
|
|
772
|
+
const hasMore = rows.length > limit;
|
|
773
|
+
const page = hasMore ? rows.slice(0, limit) : rows;
|
|
774
|
+
const records = page.map((row) => {
|
|
775
|
+
const associations = this.getAssociationsForRecord(row.id);
|
|
776
|
+
return rowToRecord(row, associations);
|
|
777
|
+
});
|
|
778
|
+
const countRows = this.exec.all(`SELECT COUNT(*) as total FROM records r ${where}`, params);
|
|
779
|
+
const total = countRows[0]?.total ?? 0;
|
|
780
|
+
const lastRecord = records[records.length - 1];
|
|
781
|
+
const cursor = hasMore && lastRecord ? makeCursor(lastRecord, getSortField(query)) : null;
|
|
782
|
+
return { records, cursor, total };
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Atomically verify fileId is unreferenced, then hard-delete its
|
|
786
|
+
* metadata records (see deleteUnreferencedAttachmentRecords on the
|
|
787
|
+
* adapter contract). A real SQL transaction of synchronous calls — no
|
|
788
|
+
* `await` between the check and the deletes, so nothing interleaves.
|
|
789
|
+
*/
|
|
790
|
+
async deleteUnreferencedAttachmentRecords(fileId, metadataTypeId) {
|
|
791
|
+
return this.exec.transaction(() => {
|
|
792
|
+
const referenced = this.exec.all(`SELECT 1 as found FROM associations WHERE kind = 'attachment' AND file_id = ?
|
|
793
|
+
UNION ALL
|
|
794
|
+
SELECT 1 FROM file_refs WHERE file_id = ?
|
|
795
|
+
LIMIT 1`, [fileId, fileId]);
|
|
796
|
+
if (referenced.length) {
|
|
797
|
+
throw new StackConflictError("Attachment is still referenced by one or more records");
|
|
798
|
+
}
|
|
799
|
+
const metaRows = this.exec.all(`SELECT id FROM records WHERE type_id = ? AND json_extract(content, '$.fileId') = ?`, [metadataTypeId, fileId]);
|
|
800
|
+
const deleted = [];
|
|
801
|
+
for (const row of metaRows) {
|
|
802
|
+
const purged = this.hardDeleteRecord(row.id);
|
|
803
|
+
if (purged)
|
|
804
|
+
deleted.push(purged);
|
|
805
|
+
}
|
|
806
|
+
return deleted;
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
// -------------------------------------------------------
|
|
810
|
+
// Versions
|
|
811
|
+
// -------------------------------------------------------
|
|
812
|
+
async getVersions(id) {
|
|
813
|
+
const rows = this.exec.all("SELECT * FROM versions WHERE record_id = ? ORDER BY version DESC", [id]);
|
|
814
|
+
return rows.map(rowToVersion);
|
|
815
|
+
}
|
|
816
|
+
async getVersion(id, version) {
|
|
817
|
+
const rows = this.exec.all("SELECT * FROM versions WHERE record_id = ? AND version = ?", [id, version]);
|
|
818
|
+
return rows.length ? rowToVersion(rows[0]) : null;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* A (record_id, version) collision is rejected loudly via the UNIQUE
|
|
822
|
+
* constraint, mapped to StackConflictError — never a silently discarded
|
|
823
|
+
* snapshot leaving a hole in rollback history. See
|
|
824
|
+
* docs/spec/versioning.md § Optimistic concurrency (`ifVersion`).
|
|
825
|
+
*/
|
|
826
|
+
insertVersionRow(id, version) {
|
|
827
|
+
try {
|
|
828
|
+
this.exec.run(`INSERT INTO versions
|
|
829
|
+
(record_id, version, type_id, content, updated_at, entity_id,
|
|
830
|
+
updated_by, updated_via, associations, permissions)
|
|
831
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
832
|
+
id,
|
|
833
|
+
version.version,
|
|
834
|
+
version.typeId,
|
|
835
|
+
JSON.stringify(version.content),
|
|
836
|
+
toMs(version.updatedAt),
|
|
837
|
+
version.entityId ?? null,
|
|
838
|
+
version.updatedBy ?? null,
|
|
839
|
+
version.updatedVia ?? null,
|
|
840
|
+
version.associations ? JSON.stringify(version.associations) : null,
|
|
841
|
+
version.permissions ? JSON.stringify(version.permissions) : null
|
|
842
|
+
]);
|
|
843
|
+
} catch (err) {
|
|
844
|
+
if (isUniqueConstraintViolation(err)) {
|
|
845
|
+
throw new StackConflictError(`Version ${version.version} already exists for record "${id}" \u2014 a concurrent writer raced past this version. Use ifVersion to detect this before it happens.`);
|
|
846
|
+
}
|
|
847
|
+
throw err;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
overwriteVersionRow(id, version) {
|
|
851
|
+
this.exec.run(`UPDATE versions
|
|
852
|
+
SET type_id = ?, content = ?, updated_at = ?, entity_id = ?, associations = ?, permissions = ?
|
|
853
|
+
WHERE record_id = ? AND version = ?`, [
|
|
854
|
+
version.typeId,
|
|
855
|
+
JSON.stringify(version.content),
|
|
856
|
+
toMs(version.updatedAt),
|
|
857
|
+
version.entityId ?? null,
|
|
858
|
+
version.associations ? JSON.stringify(version.associations) : null,
|
|
859
|
+
version.permissions ? JSON.stringify(version.permissions) : null,
|
|
860
|
+
id,
|
|
861
|
+
version.version
|
|
862
|
+
]);
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Standalone snapshot write for tooling and tests — loud on any
|
|
866
|
+
* collision, since nothing here is about to bump the version. Mutating
|
|
867
|
+
* methods take a `snapshot` option instead; see snapshotBeforeMutation.
|
|
868
|
+
*/
|
|
869
|
+
async saveVersion(id, version) {
|
|
870
|
+
this.insertVersionRow(id, version);
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* The snapshot half of a mutating method's atomic snapshot-then-mutate
|
|
874
|
+
* transaction. A collision is a racing-writer conflict, except an orphan
|
|
875
|
+
* row at the record's *current* version — recognized by version number
|
|
876
|
+
* alone, never content — which is overwritten so the interrupted write
|
|
877
|
+
* can complete. See docs/spec/versioning.md § Snapshot atomicity.
|
|
878
|
+
*/
|
|
879
|
+
snapshotBeforeMutation(id, version) {
|
|
880
|
+
try {
|
|
881
|
+
this.insertVersionRow(id, version);
|
|
882
|
+
} catch (err) {
|
|
883
|
+
if (!(err instanceof StackConflictError))
|
|
884
|
+
throw err;
|
|
885
|
+
const row = this.exec.get("SELECT version FROM records WHERE id = ?", [
|
|
886
|
+
id
|
|
887
|
+
]);
|
|
888
|
+
if (!row || row.version !== version.version)
|
|
889
|
+
throw err;
|
|
890
|
+
this.overwriteVersionRow(id, version);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
// -------------------------------------------------------
|
|
894
|
+
// Types
|
|
895
|
+
// -------------------------------------------------------
|
|
896
|
+
async saveType(type) {
|
|
897
|
+
this.exec.run(`INSERT OR REPLACE INTO types
|
|
898
|
+
(id, base_id, version, name, schema, schema_hash, migrates_from, created_at)
|
|
899
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
900
|
+
type.id,
|
|
901
|
+
type.baseId,
|
|
902
|
+
type.version,
|
|
903
|
+
type.name,
|
|
904
|
+
JSON.stringify(type.schema),
|
|
905
|
+
type.schemaHash,
|
|
906
|
+
type.migratesFrom ?? null,
|
|
907
|
+
toMs(type.createdAt)
|
|
908
|
+
]);
|
|
909
|
+
this.fileRefFieldsByType.set(type.id, fileRefFieldNames(type.schema));
|
|
910
|
+
}
|
|
911
|
+
async getType(id) {
|
|
912
|
+
const rows = this.exec.all("SELECT * FROM types WHERE id = ?", [id]);
|
|
913
|
+
return rows.length ? rowToType(rows[0]) : null;
|
|
914
|
+
}
|
|
915
|
+
async listTypes() {
|
|
916
|
+
const rows = this.exec.all("SELECT * FROM types ORDER BY base_id, version");
|
|
917
|
+
return rows.map(rowToType);
|
|
918
|
+
}
|
|
919
|
+
// -------------------------------------------------------
|
|
920
|
+
// Associations
|
|
921
|
+
// -------------------------------------------------------
|
|
922
|
+
async associate(recordId, association, opts = {}) {
|
|
923
|
+
this.exec.transaction(() => {
|
|
924
|
+
if (opts.snapshot)
|
|
925
|
+
this.snapshotBeforeMutation(recordId, opts.snapshot);
|
|
926
|
+
this.bumpVersion(recordId, opts);
|
|
927
|
+
this.insertAssociations(recordId, [association]);
|
|
928
|
+
});
|
|
929
|
+
const updated = await this.getRecord(recordId);
|
|
930
|
+
if (!updated)
|
|
931
|
+
throw new Error(`Record not found after associate: "${recordId}"`);
|
|
932
|
+
return updated;
|
|
933
|
+
}
|
|
934
|
+
async dissociate(recordId, association, opts = {}) {
|
|
935
|
+
this.exec.transaction(() => {
|
|
936
|
+
if (opts.snapshot)
|
|
937
|
+
this.snapshotBeforeMutation(recordId, opts.snapshot);
|
|
938
|
+
this.bumpVersion(recordId, opts);
|
|
939
|
+
this.exec.run(`DELETE FROM associations
|
|
940
|
+
WHERE record_id = ?
|
|
941
|
+
AND kind = ?
|
|
942
|
+
AND label = ?
|
|
943
|
+
AND file_id = ?
|
|
944
|
+
AND related_scope = ?
|
|
945
|
+
AND related_id = ?
|
|
946
|
+
AND related_ns = ?
|
|
947
|
+
AND related_stack = ?`, [recordId, association.kind, association.label, ...associationKeyColumns(association)]);
|
|
948
|
+
});
|
|
949
|
+
const updated = await this.getRecord(recordId);
|
|
950
|
+
if (!updated)
|
|
951
|
+
throw new Error(`Record not found after dissociate: "${recordId}"`);
|
|
952
|
+
return updated;
|
|
953
|
+
}
|
|
954
|
+
bumpVersion(id, opts = {}) {
|
|
955
|
+
const { clause, params: verParams } = this.versionGuard(opts.expectedVersion);
|
|
956
|
+
const changed = this.exec.run(`UPDATE records SET version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, [toMs(/* @__PURE__ */ new Date()), opts.updatedBy ?? null, opts.updatedVia ?? null, id, ...verParams]);
|
|
957
|
+
if (changed === 0)
|
|
958
|
+
this.throwVersionConflict(id, opts.expectedVersion);
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* FK enforcement (PRAGMA_FOREIGN_KEYS_ON) means inserting an
|
|
962
|
+
* association against a record that doesn't exist throws — mapped
|
|
963
|
+
* here to StackNotFoundError so associate() on a nonexistent record
|
|
964
|
+
* fails loudly instead of silently creating an orphan row.
|
|
965
|
+
*/
|
|
966
|
+
insertAssociations(recordId, associations) {
|
|
967
|
+
for (const assoc of associations) {
|
|
968
|
+
try {
|
|
969
|
+
this.exec.run(`INSERT OR IGNORE INTO associations
|
|
970
|
+
(record_id, kind, label, file_id, related_scope, related_id, related_ns, related_stack)
|
|
971
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [recordId, assoc.kind, assoc.label, ...associationKeyColumns(assoc)]);
|
|
972
|
+
} catch (err) {
|
|
973
|
+
if (isForeignKeyViolation(err)) {
|
|
974
|
+
throw new StackNotFoundError(`Record not found: "${recordId}"`);
|
|
975
|
+
}
|
|
976
|
+
throw err;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
getAssociationsForRecord(recordId) {
|
|
981
|
+
const rows = this.exec.all("SELECT * FROM associations WHERE record_id = ?", [recordId]);
|
|
982
|
+
return rows.map(rowToAssociation);
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* File-ref field names for typeId, from the in-memory cache — falling
|
|
986
|
+
* back to a `types` lookup (and populating the cache) only for a typeId
|
|
987
|
+
* this process hasn't seen via saveType() yet, e.g. right after open().
|
|
988
|
+
*/
|
|
989
|
+
getFileRefFields(typeId) {
|
|
990
|
+
const cached = this.fileRefFieldsByType.get(typeId);
|
|
991
|
+
if (cached)
|
|
992
|
+
return cached;
|
|
993
|
+
const typeRow = this.exec.get("SELECT schema FROM types WHERE id = ?", [
|
|
994
|
+
typeId
|
|
995
|
+
]);
|
|
996
|
+
const fields = typeRow ? fileRefFieldNames(JSON.parse(typeRow.schema)) : [];
|
|
997
|
+
this.fileRefFieldsByType.set(typeId, fields);
|
|
998
|
+
return fields;
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Replace a record's file_refs rows with whatever its content currently
|
|
1002
|
+
* holds in top-level file-ref fields, on every write that can change
|
|
1003
|
+
* content or typeId, so the index never drifts. Only top-level scalars
|
|
1004
|
+
* are indexed; the schema lookup is cached (fileRefFieldsByType).
|
|
1005
|
+
*/
|
|
1006
|
+
syncFileRefs(recordId, typeId, content) {
|
|
1007
|
+
this.exec.run("DELETE FROM file_refs WHERE record_id = ?", [recordId]);
|
|
1008
|
+
const fields = this.getFileRefFields(typeId);
|
|
1009
|
+
for (const field of fields) {
|
|
1010
|
+
const value = content[field];
|
|
1011
|
+
if (typeof value === "string") {
|
|
1012
|
+
this.exec.run("INSERT OR IGNORE INTO file_refs (record_id, field, file_id) VALUES (?, ?, ?)", [recordId, field, value]);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
// src/executor.ts
|
|
1019
|
+
var DurableObjectSqliteExecutor = class {
|
|
1020
|
+
constructor(storage) {
|
|
1021
|
+
this.storage = storage;
|
|
1022
|
+
}
|
|
1023
|
+
storage;
|
|
1024
|
+
get sql() {
|
|
1025
|
+
return this.storage.sql;
|
|
1026
|
+
}
|
|
1027
|
+
exec(sql) {
|
|
1028
|
+
this.sql.exec(sql);
|
|
1029
|
+
}
|
|
1030
|
+
run(sql, params = []) {
|
|
1031
|
+
const cursor = this.sql.exec(sql, ...params);
|
|
1032
|
+
return cursor.rowsWritten;
|
|
1033
|
+
}
|
|
1034
|
+
get(sql, params = []) {
|
|
1035
|
+
const rows = this.sql.exec(sql, ...params).toArray();
|
|
1036
|
+
return rows[0];
|
|
1037
|
+
}
|
|
1038
|
+
all(sql, params = []) {
|
|
1039
|
+
return this.sql.exec(sql, ...params).toArray();
|
|
1040
|
+
}
|
|
1041
|
+
transaction(fn) {
|
|
1042
|
+
return this.storage.transactionSync(fn);
|
|
1043
|
+
}
|
|
1044
|
+
};
|
|
1045
|
+
|
|
1046
|
+
// src/index.ts
|
|
1047
|
+
var DoSQLiteRecordAdapter = class _DoSQLiteRecordAdapter {
|
|
1048
|
+
constructor(exec) {
|
|
1049
|
+
this.exec = exec;
|
|
1050
|
+
this.record = new SharedSqlRecordLogic({ exec });
|
|
1051
|
+
}
|
|
1052
|
+
exec;
|
|
1053
|
+
capabilities = {
|
|
1054
|
+
fullTextSearch: true,
|
|
1055
|
+
contentFieldQuery: true,
|
|
1056
|
+
sortableFields: ["createdAt", "updatedAt", "version"],
|
|
1057
|
+
maxAttachmentBytes: null,
|
|
1058
|
+
maxContentBytes: null
|
|
1059
|
+
};
|
|
1060
|
+
ownerEntityId;
|
|
1061
|
+
timezone;
|
|
1062
|
+
record;
|
|
1063
|
+
/**
|
|
1064
|
+
* Create (or reattach to) the adapter for a DO instance. There is no
|
|
1065
|
+
* initialize()/open() split the way file-based adapters need one: a DO
|
|
1066
|
+
* id either already has a config record (a previous call created it —
|
|
1067
|
+
* reattach, opts.entityId/timezone ignored in favor of what's stored)
|
|
1068
|
+
* or it doesn't (first call — opts.entityId/timezone become the config).
|
|
1069
|
+
* Schema DDL is `CREATE TABLE IF NOT EXISTS`, so running it every call
|
|
1070
|
+
* is idempotent and cheap.
|
|
1071
|
+
*/
|
|
1072
|
+
static async create(storage, opts) {
|
|
1073
|
+
const exec = new DurableObjectSqliteExecutor(storage);
|
|
1074
|
+
exec.exec(RECORD_SCHEMA_SQL);
|
|
1075
|
+
exec.exec(FTS5_SCHEMA_SQL);
|
|
1076
|
+
exec.exec(PRAGMA_FOREIGN_KEYS_ON);
|
|
1077
|
+
const adapter = new _DoSQLiteRecordAdapter(exec);
|
|
1078
|
+
const existing = exec.get(
|
|
1079
|
+
`SELECT content FROM records WHERE id = '_config'`
|
|
1080
|
+
);
|
|
1081
|
+
if (existing) {
|
|
1082
|
+
const config = readStackConfig(exec);
|
|
1083
|
+
adapter.ownerEntityId = config.entityId;
|
|
1084
|
+
adapter.timezone = config.timezone;
|
|
1085
|
+
} else {
|
|
1086
|
+
insertConfigRecord(exec, opts.entityId, opts.timezone);
|
|
1087
|
+
adapter.ownerEntityId = opts.entityId;
|
|
1088
|
+
adapter.timezone = opts.timezone;
|
|
1089
|
+
}
|
|
1090
|
+
return adapter;
|
|
1091
|
+
}
|
|
1092
|
+
// -------------------------------------------------------
|
|
1093
|
+
// Records
|
|
1094
|
+
// -------------------------------------------------------
|
|
1095
|
+
createRecord(record) {
|
|
1096
|
+
return this.record.createRecord(record);
|
|
1097
|
+
}
|
|
1098
|
+
getRecord(id) {
|
|
1099
|
+
return this.record.getRecord(id);
|
|
1100
|
+
}
|
|
1101
|
+
patchContent(id, patch, opts) {
|
|
1102
|
+
return this.record.patchContent(id, patch, opts);
|
|
1103
|
+
}
|
|
1104
|
+
deleteRecord(id, opts) {
|
|
1105
|
+
return this.record.deleteRecord(id, opts);
|
|
1106
|
+
}
|
|
1107
|
+
undeleteRecord(id, opts) {
|
|
1108
|
+
return this.record.undeleteRecord(id, opts);
|
|
1109
|
+
}
|
|
1110
|
+
setPermissions(id, permissions, opts) {
|
|
1111
|
+
return this.record.setPermissions(id, permissions, opts);
|
|
1112
|
+
}
|
|
1113
|
+
setUnlisted(id, unlisted, opts) {
|
|
1114
|
+
return this.record.setUnlisted(id, unlisted, opts);
|
|
1115
|
+
}
|
|
1116
|
+
restoreVersion(id, version, opts) {
|
|
1117
|
+
return this.record.restoreVersion(id, version, opts);
|
|
1118
|
+
}
|
|
1119
|
+
commitMigration(id, toTypeId, content, opts) {
|
|
1120
|
+
return this.record.commitMigration(id, toTypeId, content, opts);
|
|
1121
|
+
}
|
|
1122
|
+
queryRecords(query) {
|
|
1123
|
+
return this.record.queryRecords(query);
|
|
1124
|
+
}
|
|
1125
|
+
deleteUnreferencedAttachmentRecords(fileId, metadataTypeId) {
|
|
1126
|
+
return this.record.deleteUnreferencedAttachmentRecords(fileId, metadataTypeId);
|
|
1127
|
+
}
|
|
1128
|
+
// -------------------------------------------------------
|
|
1129
|
+
// Versions
|
|
1130
|
+
// -------------------------------------------------------
|
|
1131
|
+
getVersions(id) {
|
|
1132
|
+
return this.record.getVersions(id);
|
|
1133
|
+
}
|
|
1134
|
+
getVersion(id, version) {
|
|
1135
|
+
return this.record.getVersion(id, version);
|
|
1136
|
+
}
|
|
1137
|
+
saveVersion(id, version) {
|
|
1138
|
+
return this.record.saveVersion(id, version);
|
|
1139
|
+
}
|
|
1140
|
+
// -------------------------------------------------------
|
|
1141
|
+
// Types
|
|
1142
|
+
// -------------------------------------------------------
|
|
1143
|
+
saveType(type) {
|
|
1144
|
+
return this.record.saveType(type);
|
|
1145
|
+
}
|
|
1146
|
+
getType(id) {
|
|
1147
|
+
return this.record.getType(id);
|
|
1148
|
+
}
|
|
1149
|
+
listTypes() {
|
|
1150
|
+
return this.record.listTypes();
|
|
1151
|
+
}
|
|
1152
|
+
// -------------------------------------------------------
|
|
1153
|
+
// Associations
|
|
1154
|
+
// -------------------------------------------------------
|
|
1155
|
+
associate(recordId, association, opts) {
|
|
1156
|
+
return this.record.associate(recordId, association, opts);
|
|
1157
|
+
}
|
|
1158
|
+
dissociate(recordId, association, opts) {
|
|
1159
|
+
return this.record.dissociate(recordId, association, opts);
|
|
1160
|
+
}
|
|
1161
|
+
// -------------------------------------------------------
|
|
1162
|
+
// Lifecycle
|
|
1163
|
+
// -------------------------------------------------------
|
|
1164
|
+
/** No-op: every write through ctx.storage.sql is already durable. */
|
|
1165
|
+
async flush() {
|
|
1166
|
+
}
|
|
1167
|
+
/** No-op: no lock file, no connection to release — the DO's own lifecycle governs storage. */
|
|
1168
|
+
async close() {
|
|
1169
|
+
}
|
|
1170
|
+
};
|
|
1171
|
+
|
|
1172
|
+
export { DoSQLiteRecordAdapter, DurableObjectSqliteExecutor };
|
|
1173
|
+
//# sourceMappingURL=index.js.map
|
|
1174
|
+
//# sourceMappingURL=index.js.map
|