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