@silo-ai/silo 0.0.0 → 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/dist/bin.mjs ADDED
@@ -0,0 +1,3338 @@
1
+ #!/usr/bin/env node
2
+ import { binary, command, number, option, optional, positional, runSafely, string, subcommands } from "cmd-ts";
3
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { File } from "cmd-ts/dist/cjs/batteries/fs.js";
5
+ import { DatabaseSync, backup, constants } from "node:sqlite";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
9
+ import { isIP } from "node:net";
10
+ import { execFile, execFileSync, spawn } from "node:child_process";
11
+ import { homedir } from "node:os";
12
+ import { createServer } from "node:http";
13
+ import "react";
14
+ import { renderToStaticMarkup } from "react-dom/server";
15
+ import ReactMarkdown from "react-markdown";
16
+ import remarkGfm from "remark-gfm";
17
+ import { jsx, jsxs } from "react/jsx-runtime";
18
+ import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
19
+ import { promisify } from "node:util";
20
+ //#region src/model.ts
21
+ var SiloError = class extends Error {
22
+ exitCode;
23
+ code;
24
+ path;
25
+ constructor(exitCode, code, message, path = "") {
26
+ super(message);
27
+ this.name = "SiloError";
28
+ this.exitCode = exitCode;
29
+ this.code = code;
30
+ this.path = path;
31
+ }
32
+ };
33
+ const exits = {
34
+ input: 2,
35
+ workspace: 3,
36
+ absent: 4,
37
+ notFound: 5,
38
+ schema: 6,
39
+ constraint: 7,
40
+ revision: 8,
41
+ io: 9,
42
+ integrity: 10
43
+ };
44
+ //#endregion
45
+ //#region src/lock.ts
46
+ function ownerIsAlive(path) {
47
+ try {
48
+ const pid = Number(readFileSync(path, "utf8"));
49
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
50
+ process.kill(pid, 0);
51
+ return true;
52
+ } catch (error) {
53
+ return error.code === "EPERM";
54
+ }
55
+ }
56
+ function acquireFileLock(path, message) {
57
+ for (let attempt = 0; attempt < 2; attempt++) {
58
+ let descriptor;
59
+ try {
60
+ descriptor = openSync(path, "wx");
61
+ } catch {
62
+ if (attempt === 0 && !ownerIsAlive(path)) {
63
+ rmSync(path, { force: true });
64
+ continue;
65
+ }
66
+ throw new SiloError(exits.io, "sync_in_progress", message);
67
+ }
68
+ try {
69
+ writeFileSync(descriptor, String(process.pid));
70
+ } finally {
71
+ closeSync(descriptor);
72
+ }
73
+ let released = false;
74
+ return () => {
75
+ if (released) return;
76
+ released = true;
77
+ rmSync(path, { force: true });
78
+ };
79
+ }
80
+ throw new SiloError(exits.io, "sync_in_progress", message);
81
+ }
82
+ //#endregion
83
+ //#region src/registry.ts
84
+ function fail(column, message) {
85
+ throw new SiloError(exits.schema, "invalid_semantic_value", message, column.name);
86
+ }
87
+ const text = (validate, normalize, check) => ({
88
+ storage: "TEXT",
89
+ canonicalize(value, column) {
90
+ if (typeof value !== "string") fail(column, `${column.type} requires a JSON string.`);
91
+ if (validate && !validate(value, column)) fail(column, `Value is not valid for ${column.type}.`);
92
+ return normalize ? normalize(value, column) : value;
93
+ },
94
+ check: check ? (quotedColumn) => check(quotedColumn) : void 0
95
+ });
96
+ const integer = (validate, check, render) => ({
97
+ storage: "INTEGER",
98
+ canonicalize(value, column) {
99
+ if (typeof value === "boolean" && column.type === "integer/boolean") return value ? 1 : 0;
100
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || validate && !validate(value)) fail(column, `Value is not valid for ${column.type}.`);
101
+ return value;
102
+ },
103
+ check: check ? (q) => check(q) : void 0,
104
+ render(value) {
105
+ return render ? render(value) : value;
106
+ }
107
+ });
108
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
109
+ const ulid = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
110
+ const date = /^\d{4}-\d{2}-\d{2}$/;
111
+ const time = /^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})?$/;
112
+ const email = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
113
+ const hostname = /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
114
+ function calendarDate(value) {
115
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
116
+ if (!match) return false;
117
+ const year = Number(match[1]);
118
+ const month = Number(match[2]);
119
+ const day = Number(match[3]);
120
+ const date = new Date(Date.UTC(year, month - 1, day));
121
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
122
+ }
123
+ function decimal(value, column) {
124
+ const precision = column.type_options?.precision;
125
+ const scale = column.type_options?.scale;
126
+ if (!Number.isInteger(precision) || !Number.isInteger(scale) || precision <= 0 || scale < 0 || scale > precision) fail(column, "text/decimal requires integer precision and scale options with 0 <= scale <= precision.");
127
+ const match = /^([+-]?)(\d+)(?:\.(\d+))?$/.exec(value);
128
+ if (!match || (match[3]?.length ?? 0) > scale) fail(column, "Decimal value exceeds its configured scale or uses an unsupported form.");
129
+ const digits = match[2].replace(/^0+(?=\d)/, "");
130
+ if (digits.length + scale > precision) fail(column, "Decimal value exceeds its configured precision.");
131
+ return `${match[1] === "+" ? "" : match[1]}${digits}.${(match[3] ?? "").padEnd(scale, "0")}`.replace(/\.$/, "");
132
+ }
133
+ const semanticTypes = {
134
+ text: text(),
135
+ integer: integer(),
136
+ real: {
137
+ storage: "REAL",
138
+ canonicalize(value, column) {
139
+ if (typeof value !== "number" || !Number.isFinite(value)) fail(column, "real requires a finite JSON number.");
140
+ return value;
141
+ }
142
+ },
143
+ blob: {
144
+ storage: "BLOB",
145
+ canonicalize(value, column) {
146
+ if (typeof value !== "string") fail(column, "blob requires a base64 JSON string.");
147
+ return Uint8Array.from(Buffer.from(value, "base64"));
148
+ }
149
+ },
150
+ any: {
151
+ storage: "ANY",
152
+ canonicalize(value, column) {
153
+ if (typeof value === "boolean") return value ? 1 : 0;
154
+ if (typeof value === "string") return value;
155
+ if (typeof value === "number" && Number.isFinite(value)) return value;
156
+ fail(column, "any accepts only JSON strings, finite numbers, booleans, or null.");
157
+ }
158
+ },
159
+ "text/uuid": text((v) => uuid.test(v), (v) => v.toLowerCase(), (q) => `length(${q}) = 36 AND ${q} = lower(${q}) AND substr(${q}, 9, 1) = '-' AND substr(${q}, 14, 1) = '-' AND substr(${q}, 19, 1) = '-' AND substr(${q}, 24, 1) = '-' AND ${q} NOT GLOB '*[^0-9a-f-]*'`),
160
+ "text/ulid": text((v) => ulid.test(v), (v) => v.toUpperCase(), (q) => `length(${q}) = 26 AND ${q} = upper(${q}) AND ${q} NOT GLOB '*[^0-9A-HJKMNP-TV-Z]*'`),
161
+ "text/slug": text((v) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(v)),
162
+ "text/git-oid": text((v, c) => new RegExp(`^[0-9a-f]{${c.type_options?.length ?? "40|64"}}$`, "i").test(v), (v) => v.toLowerCase()),
163
+ "text/date": text((v) => date.test(v) && calendarDate(v)),
164
+ "text/time": text((v) => time.test(v)),
165
+ "text/datetime": text((v) => /^\d{4}-\d{2}-\d{2}T.+(?:Z|[+-]\d{2}:\d{2})$/.test(v) && !Number.isNaN(Date.parse(v)), (v) => new Date(v).toISOString(), (q) => `${q} GLOB '????-??-??T*Z'`),
166
+ "text/json": {
167
+ storage: "TEXT",
168
+ canonicalize(value, column) {
169
+ if (value === void 0 || typeof value === "bigint") fail(column, "text/json requires a JSON object, array, string, number, or boolean.");
170
+ try {
171
+ const json = JSON.stringify(value);
172
+ if (json === void 0) fail(column, "text/json requires a JSON object, array, string, number, or boolean.");
173
+ return json;
174
+ } catch {
175
+ fail(column, "Value cannot be represented as JSON.");
176
+ }
177
+ }
178
+ },
179
+ "text/markdown": text(),
180
+ "text/html": text(),
181
+ "text/url": text((v) => {
182
+ try {
183
+ const u = new URL(v);
184
+ return Boolean(u.protocol && u.hostname);
185
+ } catch {
186
+ return false;
187
+ }
188
+ }),
189
+ "text/uri": text((v) => /^[a-z][a-z0-9+.-]*:/i.test(v)),
190
+ "text/email": text((v) => email.test(v)),
191
+ "text/ip": text((v) => isIP(v) !== 0),
192
+ "text/cidr": text((v) => {
193
+ const [ip, bits] = v.split("/");
194
+ const family = isIP(ip ?? "");
195
+ return family !== 0 && /^\d+$/.test(bits ?? "") && Number(bits) <= (family === 4 ? 32 : 128);
196
+ }),
197
+ "text/hostname": text((v) => hostname.test(v), (v) => v.toLowerCase()),
198
+ "text/path": text((v) => !v.includes("\0")),
199
+ "text/path-posix": text((v) => !v.includes("\0") && !v.includes("\\")),
200
+ "text/path-relative": text((v) => !v.startsWith("/") && !v.split("/").includes("..")),
201
+ "text/git-ref": text((v) => !/\.\.|[~^:?*[\\\s]|(?:^|\/)\.|\.lock$|\/$/.test(v)),
202
+ "text/semver": text((v) => /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(v)),
203
+ "text/base64": text((v) => /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(v)),
204
+ "text/hex": text((v) => /^(?:[0-9a-f]{2})*$/i.test(v), (v) => v.toLowerCase()),
205
+ "text/sha256": text((v) => /^[0-9a-f]{64}$/i.test(v), (v) => v.toLowerCase()),
206
+ "text/sha512": text((v) => /^[0-9a-f]{128}$/i.test(v), (v) => v.toLowerCase()),
207
+ "text/decimal": text(void 0, decimal),
208
+ "text/enum": text((v, c) => Array.isArray(c.type_options?.values) && c.type_options.values.includes(v)),
209
+ "integer/boolean": integer((v) => v === 0 || v === 1, (q) => `${q} IN (0, 1)`, (value) => Boolean(value)),
210
+ "integer/positive": integer((v) => v > 0, (q) => `${q} > 0`),
211
+ "integer/nonnegative": integer((v) => v >= 0, (q) => `${q} >= 0`),
212
+ "integer/port": integer((v) => v >= 0 && v <= 65535, (q) => `${q} BETWEEN 0 AND 65535`),
213
+ "integer/unix-seconds": integer(),
214
+ "integer/unix-milliseconds": integer(),
215
+ "integer/duration-ms": integer((v) => v >= 0, (q) => `${q} >= 0`),
216
+ "integer/money-minor": integer(),
217
+ "real/percentage": {
218
+ storage: "REAL",
219
+ canonicalize(value, column) {
220
+ if (typeof value !== "number" || value < 0 || value > 1) fail(column, "real/percentage requires a number from 0 through 1.");
221
+ return value;
222
+ },
223
+ check: (q) => `${q} BETWEEN 0 AND 1`
224
+ },
225
+ "blob/bytes": {
226
+ storage: "BLOB",
227
+ canonicalize(value, column) {
228
+ if (typeof value !== "string") fail(column, "blob/bytes requires base64 input.");
229
+ return Uint8Array.from(Buffer.from(value, "base64"));
230
+ }
231
+ }
232
+ };
233
+ function semantic(column) {
234
+ const found = semanticTypes[column.type];
235
+ if (!found) throw new SiloError(exits.schema, "unknown_semantic_type", `${column.type} is not registered.`, `columns.${column.name}.type`);
236
+ return found;
237
+ }
238
+ function canonicalize(column, value) {
239
+ if (value === null) {
240
+ if (column.nullable === false) fail(column, "Column is not nullable.");
241
+ return null;
242
+ }
243
+ return semantic(column).canonicalize(value, column);
244
+ }
245
+ //#endregion
246
+ //#region src/schema.ts
247
+ const identifier = /^[A-Za-z_][A-Za-z0-9_]*$/;
248
+ const actions = /* @__PURE__ */ new Set([
249
+ "NO ACTION",
250
+ "RESTRICT",
251
+ "SET NULL",
252
+ "SET DEFAULT",
253
+ "CASCADE"
254
+ ]);
255
+ function quote(name) {
256
+ return `"${name.replaceAll("\"", "\"\"")}"`;
257
+ }
258
+ function validateIdentifier(name, path) {
259
+ if (typeof name !== "string" || !identifier.test(name) || name.toLowerCase().startsWith("_silo_")) throw new SiloError(exits.schema, "invalid_identifier", "Expected a SQLite identifier outside the reserved _silo_ namespace.", path);
260
+ }
261
+ function assertObject(value, path) {
262
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new SiloError(exits.input, "invalid_shape", "Expected a JSON object.", path);
263
+ }
264
+ function rejectUnknown(object, allowed, path) {
265
+ const key = Object.keys(object).find((candidate) => !allowed.includes(candidate));
266
+ if (key) throw new SiloError(exits.input, "unknown_field", `Unknown field ${key}.`, `${path}.${key}`);
267
+ }
268
+ function parseTable(value) {
269
+ assertObject(value, "$.");
270
+ rejectUnknown(value, [
271
+ "name",
272
+ "comment",
273
+ "columns",
274
+ "primary_key",
275
+ "foreign_keys",
276
+ "unique_constraints",
277
+ "indexes",
278
+ "checks",
279
+ "policies",
280
+ "strict",
281
+ "without_rowid"
282
+ ], "$.");
283
+ validateIdentifier(value.name, "$.name");
284
+ if (typeof value.comment !== "string" || !value.comment.trim()) throw new SiloError(exits.schema, "comment_required", "A non-empty table comment is required.", "$.comment");
285
+ if (!Array.isArray(value.columns)) throw new SiloError(exits.input, "invalid_shape", "columns must be an array.", "$.columns");
286
+ const table = value;
287
+ const names = /* @__PURE__ */ new Set();
288
+ for (const [i, column] of table.columns.entries()) {
289
+ assertObject(column, `$.columns[${i}]`);
290
+ rejectUnknown(column, [
291
+ "name",
292
+ "type",
293
+ "type_options",
294
+ "nullable",
295
+ "default",
296
+ "comment",
297
+ "collate",
298
+ "generated"
299
+ ], `$.columns[${i}]`);
300
+ validateIdentifier(column.name, `$.columns[${i}].name`);
301
+ if (names.has(column.name.toLowerCase())) throw new SiloError(exits.schema, "duplicate_column", "Column names are case-insensitively unique.", `$.columns[${i}].name`);
302
+ names.add(column.name.toLowerCase());
303
+ if (typeof column.comment !== "string" || !column.comment.trim()) throw new SiloError(exits.schema, "comment_required", "A non-empty column comment is required.", `$.columns[${i}].comment`);
304
+ if (typeof column.type !== "string") throw new SiloError(exits.schema, "unknown_semantic_type", "A semantic type is required.", `$.columns[${i}].type`);
305
+ semantic(column);
306
+ if (column.default) {
307
+ assertObject(column.default, `$.columns[${i}].default`);
308
+ rejectUnknown(column.default, ["literal", "expression"], `$.columns[${i}].default`);
309
+ if (Number(Object.hasOwn(column.default, "literal")) + Number(Object.hasOwn(column.default, "expression")) !== 1) throw new SiloError(exits.schema, "invalid_default", "A default requires exactly one literal or expression.", `$.columns[${i}].default`);
310
+ if (Object.hasOwn(column.default, "literal")) canonicalize(column, column.default.literal);
311
+ }
312
+ if (column.generated) {
313
+ assertObject(column.generated, `$.columns[${i}].generated`);
314
+ rejectUnknown(column.generated, ["expression", "storage"], `$.columns[${i}].generated`);
315
+ }
316
+ if (column.default && column.generated) throw new SiloError(exits.schema, "incompatible_features", "A generated column cannot have a default.", `$.columns[${i}]`);
317
+ }
318
+ const has = (name) => names.has(name.toLowerCase());
319
+ for (const [i, name] of (table.primary_key ?? []).entries()) if (!has(name)) throw new SiloError(exits.schema, "missing_column", "Primary-key column does not exist.", `$.primary_key[${i}]`);
320
+ for (const [i, foreign] of (table.foreign_keys ?? []).entries()) {
321
+ assertObject(foreign, `$.foreign_keys[${i}]`);
322
+ rejectUnknown(foreign, [
323
+ "columns",
324
+ "references",
325
+ "on_update",
326
+ "on_delete",
327
+ "deferrable",
328
+ "initially_deferred"
329
+ ], `$.foreign_keys[${i}]`);
330
+ assertObject(foreign.references, `$.foreign_keys[${i}].references`);
331
+ rejectUnknown(foreign.references, ["table", "columns"], `$.foreign_keys[${i}].references`);
332
+ if (!foreign.columns?.length || foreign.columns.length !== foreign.references?.columns?.length) throw new SiloError(exits.schema, "invalid_foreign_key", "Foreign-key column lists must be non-empty and have equal length.", `$.foreign_keys[${i}]`);
333
+ for (const name of foreign.columns) if (!has(name)) throw new SiloError(exits.schema, "missing_column", "Foreign-key column does not exist.", `$.foreign_keys[${i}].columns`);
334
+ validateIdentifier(foreign.references.table, `$.foreign_keys[${i}].references.table`);
335
+ for (const name of foreign.references.columns) validateIdentifier(name, `$.foreign_keys[${i}].references.columns`);
336
+ for (const action of [foreign.on_update, foreign.on_delete]) if (action && !actions.has(action)) throw new SiloError(exits.schema, "invalid_foreign_key_action", `${action} is not a SQLite foreign-key action.`, `$.foreign_keys[${i}]`);
337
+ }
338
+ for (const [i, unique] of (table.unique_constraints ?? []).entries()) {
339
+ assertObject(unique, `$.unique_constraints[${i}]`);
340
+ rejectUnknown(unique, ["name", "columns"], `$.unique_constraints[${i}]`);
341
+ if (!Array.isArray(unique.columns) || !unique.columns.length) throw new SiloError(exits.schema, "invalid_unique", "Unique constraints require columns.", `$.unique_constraints[${i}].columns`);
342
+ for (const column of unique.columns) if (!has(column)) throw new SiloError(exits.schema, "missing_column", "Unique column does not exist.", `$.unique_constraints[${i}].columns`);
343
+ }
344
+ for (const [i, index] of (table.indexes ?? []).entries()) {
345
+ assertObject(index, `$.indexes[${i}]`);
346
+ rejectUnknown(index, [
347
+ "name",
348
+ "columns",
349
+ "unique",
350
+ "where",
351
+ "comment"
352
+ ], `$.indexes[${i}]`);
353
+ if (!Array.isArray(index.columns) || !index.columns.length) throw new SiloError(exits.schema, "invalid_index", "Indexes require columns or expressions.", `$.indexes[${i}].columns`);
354
+ for (const [j, part] of index.columns.entries()) {
355
+ assertObject(part, `$.indexes[${i}].columns[${j}]`);
356
+ rejectUnknown(part, [
357
+ "column",
358
+ "expression",
359
+ "direction",
360
+ "collate"
361
+ ], `$.indexes[${i}].columns[${j}]`);
362
+ if (typeof part.column === "string" && !has(part.column)) throw new SiloError(exits.schema, "missing_column", "Indexed column does not exist.", `$.indexes[${i}].columns[${j}].column`);
363
+ }
364
+ }
365
+ for (const [i, check] of (table.checks ?? []).entries()) {
366
+ assertObject(check, `$.checks[${i}]`);
367
+ rejectUnknown(check, [
368
+ "name",
369
+ "expression",
370
+ "comment"
371
+ ], `$.checks[${i}]`);
372
+ if (!check.expression) throw new SiloError(exits.schema, "invalid_check", "Check expression is required.", `$.checks[${i}].expression`);
373
+ }
374
+ if (table.without_rowid && !table.primary_key?.length) throw new SiloError(exits.schema, "without_rowid_requires_key", "WITHOUT ROWID requires a primary key.", "$.without_rowid");
375
+ validatePolicies(table, has);
376
+ return structuredClone(table);
377
+ }
378
+ function validatePolicies(table, has) {
379
+ const seen = /* @__PURE__ */ new Set();
380
+ for (const [i, policy] of (table.policies ?? []).entries()) {
381
+ if (!policy || typeof policy !== "object" || typeof policy.type !== "string") throw new SiloError(exits.schema, "invalid_policy", "A policy type is required.", `$.policies[${i}]`);
382
+ if (seen.has(policy.type)) throw new SiloError(exits.schema, "duplicate_policy", "A policy may be configured once per table.", `$.policies[${i}]`);
383
+ seen.add(policy.type);
384
+ rejectUnknown(policy, {
385
+ generated_identity: [
386
+ "type",
387
+ "column",
388
+ "strategy"
389
+ ],
390
+ timestamps: [
391
+ "type",
392
+ "created_column",
393
+ "updated_column"
394
+ ],
395
+ optimistic_revision: [
396
+ "type",
397
+ "column",
398
+ "initial"
399
+ ],
400
+ immutable_rows: ["type"],
401
+ immutable_columns: ["type", "columns"],
402
+ append_only: ["type"],
403
+ natural_key_upsert: [
404
+ "type",
405
+ "columns",
406
+ "update_columns"
407
+ ]
408
+ }[policy.type] ?? ["type"], `$.policies[${i}]`);
409
+ const required = (key) => {
410
+ if (typeof policy[key] !== "string" || !has(policy[key])) throw new SiloError(exits.schema, "policy_precondition", `Policy requires an existing ${key}.`, `$.policies[${i}].${key}`);
411
+ };
412
+ if (policy.type === "generated_identity") {
413
+ required("column");
414
+ if (!(/* @__PURE__ */ new Set([
415
+ "integer",
416
+ "uuid",
417
+ "ulid"
418
+ ])).has(policy.strategy)) throw new SiloError(exits.schema, "invalid_identity_strategy", "Identity strategy must be integer, uuid, or ulid.", `$.policies[${i}].strategy`);
419
+ if (policy.strategy === "integer" && (table.primary_key?.length !== 1 || table.primary_key[0] !== policy.column)) throw new SiloError(exits.schema, "policy_precondition", "Integer identity must be the table's single primary key.", `$.policies[${i}]`);
420
+ const column = table.columns.find((item) => item.name === policy.column);
421
+ if (policy.strategy === "integer" && column.type !== "integer") throw new SiloError(exits.schema, "policy_precondition", "Integer identity requires the integer type.", `$.policies[${i}].column`);
422
+ if (policy.strategy === "uuid" && column.type !== "text/uuid" || policy.strategy === "ulid" && column.type !== "text/ulid") throw new SiloError(exits.schema, "policy_precondition", `${policy.strategy} identity requires its matching semantic type.`, `$.policies[${i}].column`);
423
+ } else if (policy.type === "timestamps") {
424
+ if (policy.created_column) required("created_column");
425
+ if (policy.updated_column) required("updated_column");
426
+ if (!policy.created_column && !policy.updated_column) throw new SiloError(exits.schema, "policy_precondition", "timestamps requires a created_column or updated_column.", `$.policies[${i}]`);
427
+ for (const key of ["created_column", "updated_column"]) if (policy[key] && table.columns.find((item) => item.name === policy[key]).type !== "text/datetime") throw new SiloError(exits.schema, "policy_precondition", `timestamps ${key} requires text/datetime.`, `$.policies[${i}].${key}`);
428
+ } else if (policy.type === "optimistic_revision") {
429
+ required("column");
430
+ if (!table.columns.find((item) => item.name === policy.column).type.startsWith("integer")) throw new SiloError(exits.schema, "policy_precondition", "Optimistic revision requires an integer column.", `$.policies[${i}].column`);
431
+ } else if (policy.type === "immutable_columns") {
432
+ if (!Array.isArray(policy.columns) || !policy.columns.length) throw new SiloError(exits.schema, "policy_precondition", "immutable_columns requires columns.", `$.policies[${i}].columns`);
433
+ for (const column of policy.columns) if (!has(column)) throw new SiloError(exits.schema, "policy_precondition", "Immutable column does not exist.", `$.policies[${i}].columns`);
434
+ } else if (policy.type === "natural_key_upsert") {
435
+ if (!Array.isArray(policy.columns) || !policy.columns.length) throw new SiloError(exits.schema, "policy_precondition", "natural_key_upsert requires columns.", `$.policies[${i}].columns`);
436
+ for (const column of policy.columns) if (!has(column)) throw new SiloError(exits.schema, "policy_precondition", "Natural-key column does not exist.", `$.policies[${i}].columns`);
437
+ for (const column of policy.update_columns ?? []) if (!has(column)) throw new SiloError(exits.schema, "policy_precondition", "Natural-key update column does not exist.", `$.policies[${i}].update_columns`);
438
+ const key = policy.columns;
439
+ if (![table.primary_key ?? [], ...(table.unique_constraints ?? []).map((item) => item.columns)].some((columns) => columns.length === key.length && columns.every((column, position) => column === key[position]))) throw new SiloError(exits.schema, "policy_precondition", "Natural-key upsert columns must exactly match a primary key or unique constraint.", `$.policies[${i}].columns`);
440
+ } else if (policy.type !== "immutable_rows" && policy.type !== "append_only") throw new SiloError(exits.schema, "unknown_policy", `${policy.type} is not registered.`, `$.policies[${i}].type`);
441
+ }
442
+ if (seen.has("append_only") && seen.has("immutable_rows")) throw new SiloError(exits.schema, "incompatible_policies", "append_only and immutable_rows are redundant and cannot be combined.", "$.policies");
443
+ if ((seen.has("append_only") || seen.has("immutable_rows")) && (seen.has("optimistic_revision") || seen.has("natural_key_upsert"))) throw new SiloError(exits.schema, "incompatible_policies", "Immutable and append-only rows cannot use update-oriented policies.", "$.policies");
444
+ if ((seen.has("append_only") || seen.has("immutable_rows")) && seen.has("timestamps") && table.policies?.find((item) => item.type === "timestamps")?.updated_column) throw new SiloError(exits.schema, "incompatible_policies", "An updated timestamp cannot be combined with an immutable or append-only row.", "$.policies");
445
+ const immutable = table.policies?.find((item) => item.type === "immutable_columns");
446
+ if (immutable) {
447
+ const managed = [table.policies?.find((item) => item.type === "timestamps")?.updated_column, table.policies?.find((item) => item.type === "optimistic_revision")?.column].filter(Boolean);
448
+ if (immutable.columns.some((column) => managed.includes(column))) throw new SiloError(exits.schema, "incompatible_policies", "CLI-managed update columns cannot also be immutable.", "$.policies");
449
+ }
450
+ }
451
+ function literal(value) {
452
+ if (value === null) return "NULL";
453
+ if (typeof value === "boolean") return value ? "1" : "0";
454
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
455
+ if (typeof value === "string") return `'${value.replaceAll("'", "''")}'`;
456
+ if (value instanceof Uint8Array) return `X'${Buffer.from(value).toString("hex")}'`;
457
+ throw new SiloError(exits.schema, "invalid_default", "Default literal must be a JSON scalar.", "default.literal");
458
+ }
459
+ function columnSql(column, table) {
460
+ const type = semantic(column);
461
+ const integerIdentity = (table.policies ?? []).some((p) => p.type === "generated_identity" && p.column === column.name && p.strategy === "integer");
462
+ const pieces = [quote(column.name), integerIdentity ? "INTEGER PRIMARY KEY" : type.storage];
463
+ if (column.collate) {
464
+ validateIdentifier(column.collate, `${column.name}.collate`);
465
+ pieces.push(`COLLATE ${quote(column.collate)}`);
466
+ }
467
+ if (column.generated) pieces.push(`GENERATED ALWAYS AS (${column.generated.expression}) ${column.generated.storage ?? "VIRTUAL"}`);
468
+ if (column.nullable === false && !integerIdentity) pieces.push("NOT NULL");
469
+ if (column.default) {
470
+ if (Object.hasOwn(column.default, "literal")) pieces.push(`DEFAULT ${literal(canonicalize(column, column.default.literal))}`);
471
+ else if (column.default.expression) pieces.push(`DEFAULT (${column.default.expression})`);
472
+ }
473
+ const check = type.check?.(quote(column.name), column);
474
+ if (check) pieces.push(`CHECK (${check})`);
475
+ if (column.type === "text/enum") pieces.push(`CHECK (${quote(column.name)} IN (${column.type_options.values.map(literal).join(", ")}))`);
476
+ return pieces.join(" ");
477
+ }
478
+ function generatedName(table, kind, index) {
479
+ return `_silo_${table}_${kind}_${index}`;
480
+ }
481
+ function indexSql(table, index, position) {
482
+ if (index.name) validateIdentifier(index.name, `${table.name}.indexes[${position}].name`);
483
+ const logicalName = index.name ?? String(position);
484
+ const name = `_silo_idx_${table.name.length}_${table.name}_${logicalName}`;
485
+ const parts = index.columns.map((part) => {
486
+ if (part.column) validateIdentifier(part.column, `${table.name}.indexes[${position}].columns`);
487
+ if (!part.column && !part.expression) throw new SiloError(exits.schema, "invalid_index", "Index part requires column or expression.", `${table.name}.indexes[${position}].columns`);
488
+ return `${part.column ? quote(part.column) : `(${part.expression})`}${part.collate ? ` COLLATE ${quote(part.collate)}` : ""}${part.direction ? ` ${part.direction}` : ""}`;
489
+ });
490
+ return `CREATE ${index.unique ? "UNIQUE " : ""}INDEX ${quote(name)} ON ${quote(table.name)} (${parts.join(", ")})${index.where ? ` WHERE ${index.where}` : ""};`;
491
+ }
492
+ function compileTable(table) {
493
+ const integerIdentity = (table.policies ?? []).some((p) => p.type === "generated_identity" && p.strategy === "integer");
494
+ const constraints = [];
495
+ if (table.primary_key?.length && !integerIdentity) constraints.push(`PRIMARY KEY (${table.primary_key.map(quote).join(", ")})`);
496
+ for (const unique of table.unique_constraints ?? []) {
497
+ if (unique.name) validateIdentifier(unique.name, `${table.name}.unique_constraints.name`);
498
+ constraints.push(`${unique.name ? `CONSTRAINT ${quote(unique.name)} ` : ""}UNIQUE (${unique.columns.map(quote).join(", ")})`);
499
+ }
500
+ for (const check of table.checks ?? []) constraints.push(`${check.name ? `CONSTRAINT ${quote(check.name)} ` : ""}CHECK (${check.expression})`);
501
+ for (const fk of table.foreign_keys ?? []) constraints.push(`FOREIGN KEY (${fk.columns.map(quote).join(", ")}) REFERENCES ${quote(fk.references.table)} (${fk.references.columns.map(quote).join(", ")})${fk.on_update ? ` ON UPDATE ${fk.on_update}` : ""}${fk.on_delete ? ` ON DELETE ${fk.on_delete}` : ""}${fk.deferrable ? ` DEFERRABLE${fk.initially_deferred ? " INITIALLY DEFERRED" : ""}` : ""}`);
502
+ const body = [...table.columns.map((column) => columnSql(column, table)), ...constraints].join(",\n ");
503
+ const options = [table.strict !== false ? "STRICT" : "", table.without_rowid ? "WITHOUT ROWID" : ""].filter(Boolean).join(", ");
504
+ const statements = [`CREATE TABLE ${quote(table.name)} (\n ${body}\n)${options ? ` ${options}` : ""};`];
505
+ for (const [i, index] of (table.indexes ?? []).entries()) statements.push(indexSql(table, index, i));
506
+ for (const [i, unique] of (table.unique_constraints ?? []).entries());
507
+ statements.push(...compilePolicyTriggers(table));
508
+ return statements;
509
+ }
510
+ function compilePolicyTriggers(table) {
511
+ const result = [];
512
+ for (const [i, policy] of (table.policies ?? []).entries()) {
513
+ const name = generatedName(table.name, policy.type, i);
514
+ if (policy.type === "append_only" || policy.type === "immutable_rows") {
515
+ result.push(`CREATE TRIGGER ${quote(`${name}_update`)} BEFORE UPDATE ON ${quote(table.name)} BEGIN SELECT RAISE(ABORT, '${policy.type} forbids updates'); END;`);
516
+ result.push(`CREATE TRIGGER ${quote(`${name}_delete`)} BEFORE DELETE ON ${quote(table.name)} BEGIN SELECT RAISE(ABORT, '${policy.type} forbids deletes'); END;`);
517
+ } else if (policy.type === "immutable_columns") {
518
+ const changed = policy.columns.map((column) => `OLD.${quote(column)} IS NOT NEW.${quote(column)}`).join(" OR ");
519
+ result.push(`CREATE TRIGGER ${quote(name)} BEFORE UPDATE ON ${quote(table.name)} WHEN ${changed} BEGIN SELECT RAISE(ABORT, 'immutable column changed'); END;`);
520
+ } else if (policy.type === "timestamps" && policy.updated_column) {
521
+ const column = quote(policy.updated_column);
522
+ const locator = table.without_rowid ? table.primary_key.map((key) => `${quote(key)} = NEW.${quote(key)}`).join(" AND ") : "rowid = NEW.rowid";
523
+ result.push(`CREATE TRIGGER ${quote(name)} AFTER UPDATE ON ${quote(table.name)} WHEN NEW.${column} IS OLD.${column} BEGIN UPDATE ${quote(table.name)} SET ${column} = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE ${locator}; END;`);
524
+ }
525
+ }
526
+ return result;
527
+ }
528
+ function compileSchema(schema) {
529
+ return schema.tables.flatMap(compileTable);
530
+ }
531
+ function validateCompiledSchema(schema) {
532
+ const names = /* @__PURE__ */ new Set();
533
+ for (const [i, table] of schema.tables.entries()) {
534
+ const key = table.name.toLowerCase();
535
+ if (names.has(key)) throw new SiloError(exits.schema, "duplicate_table", "Table names are case-insensitively unique.", `$.tables[${i}].name`);
536
+ names.add(key);
537
+ }
538
+ for (const [i, table] of schema.tables.entries()) for (const [j, foreign] of (table.foreign_keys ?? []).entries()) {
539
+ const target = schema.tables.find((candidate) => candidate.name.toLowerCase() === foreign.references.table.toLowerCase());
540
+ if (!target) throw new SiloError(exits.schema, "missing_referenced_table", `${foreign.references.table} does not exist.`, `$.tables[${i}].foreign_keys[${j}]`);
541
+ for (const column of foreign.references.columns) if (!target.columns.some((candidate) => candidate.name.toLowerCase() === column.toLowerCase())) throw new SiloError(exits.schema, "missing_referenced_column", `${column} does not exist on ${target.name}.`, `$.tables[${i}].foreign_keys[${j}]`);
542
+ if (![target.primary_key ?? [], ...(target.unique_constraints ?? []).map((item) => item.columns)].some((columns) => columns.length === foreign.references.columns.length && columns.every((column, position) => column === foreign.references.columns[position]))) throw new SiloError(exits.schema, "invalid_foreign_key_target", "Referenced columns must exactly match a primary key or unique constraint.", `$.tables[${i}].foreign_keys[${j}]`);
543
+ }
544
+ const database = new DatabaseSync(":memory:");
545
+ try {
546
+ database.exec("PRAGMA foreign_keys=ON;");
547
+ database.exec(compileSchema(schema).join("\n"));
548
+ const check = database.prepare("PRAGMA integrity_check").get();
549
+ if (!Object.values(check).includes("ok")) throw new Error("integrity_check did not return ok");
550
+ } catch (error) {
551
+ throw new SiloError(exits.schema, "sqlite_compile_error", error instanceof Error ? error.message : String(error), "$.");
552
+ } finally {
553
+ database.close();
554
+ }
555
+ }
556
+ function policy(table, type) {
557
+ return table.policies?.find((candidate) => candidate.type === type);
558
+ }
559
+ function generatedValue(strategy) {
560
+ if (strategy === "uuid") return randomUUID().toLowerCase();
561
+ if (strategy === "ulid") {
562
+ const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
563
+ let time = Date.now();
564
+ let result = "";
565
+ for (let i = 0; i < 10; i++) {
566
+ result = alphabet[time % 32] + result;
567
+ time = Math.floor(time / 32);
568
+ }
569
+ for (let i = 0; i < 16; i++) result += alphabet[Math.floor(Math.random() * 32)];
570
+ return result;
571
+ }
572
+ throw new SiloError(exits.schema, "invalid_identity_strategy", "Generated identity strategy must be uuid, ulid, or integer.", "policy.strategy");
573
+ }
574
+ //#endregion
575
+ //#region src/workspace.ts
576
+ function normalizeOrigin(origin) {
577
+ const value = origin.trim();
578
+ if (value.replace(/[?#].*$/, "").split(/[/:]/).some((segment) => {
579
+ try {
580
+ const decoded = decodeURIComponent(segment);
581
+ return decoded === "." || decoded === "..";
582
+ } catch {
583
+ return true;
584
+ }
585
+ })) throw new SiloError(exits.workspace, "invalid_origin", "The origin remote contains an unsafe path segment.", "remote.origin.url");
586
+ let host;
587
+ let path;
588
+ const scp = /^(?:[^@/]+@)?([^:/]+):(.+)$/.exec(value);
589
+ if (scp && !value.includes("://")) {
590
+ host = scp[1];
591
+ path = scp[2];
592
+ } else {
593
+ let url;
594
+ try {
595
+ url = new URL(value);
596
+ } catch {
597
+ throw new SiloError(exits.workspace, "invalid_origin", "The origin remote is not a usable URL.", "remote.origin.url");
598
+ }
599
+ host = url.hostname;
600
+ path = url.pathname;
601
+ }
602
+ host = host.toLowerCase();
603
+ path = path.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "");
604
+ const segments = path.split("/");
605
+ if (!host || !path || segments.some((part) => !part || part === "." || part === "..")) throw new SiloError(exits.workspace, "invalid_origin", "The origin remote has an unsafe or empty repository path.", "remote.origin.url");
606
+ return `${host}/${segments.join("/")}`;
607
+ }
608
+ function dataRoot() {
609
+ if (process.env.SILO_DATA_HOME) return join(process.env.SILO_DATA_HOME, "silo");
610
+ if (process.platform === "darwin") return join(homedir(), "Library", "Application Support", "silo");
611
+ if (process.platform === "win32") return join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "silo");
612
+ return join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "silo");
613
+ }
614
+ function git(cwd, args) {
615
+ try {
616
+ return execFileSync("git", args, {
617
+ cwd,
618
+ encoding: "utf8",
619
+ stdio: [
620
+ "ignore",
621
+ "pipe",
622
+ "ignore"
623
+ ]
624
+ }).trim();
625
+ } catch {
626
+ throw new SiloError(exits.workspace, "workspace_unresolved", "The current directory is not a Git worktree with a usable origin remote.");
627
+ }
628
+ }
629
+ function resolveWorkspace(cwd = process.cwd()) {
630
+ const root = git(cwd, ["rev-parse", "--show-toplevel"]);
631
+ const origin = git(root, [
632
+ "config",
633
+ "--get",
634
+ "remote.origin.url"
635
+ ]);
636
+ const identity = normalizeOrigin(origin);
637
+ const parts = identity.split("/");
638
+ const leaf = parts.pop();
639
+ return {
640
+ root,
641
+ origin,
642
+ identity,
643
+ databasePath: join(dataRoot(), "databases", ...parts, `${leaf}.sqlite`)
644
+ };
645
+ }
646
+ //#endregion
647
+ //#region src/markdown.ts
648
+ function cell(value) {
649
+ if (value === null || value === void 0) return "NULL";
650
+ if (typeof value === "boolean") return value ? "true" : "false";
651
+ if (value instanceof Uint8Array) return `[BLOB ${value.byteLength} bytes]`;
652
+ return (typeof value === "object" ? JSON.stringify(value) : String(value)).replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
653
+ }
654
+ function table(headers, rows) {
655
+ const lines = [`| ${headers.map(cell).join(" | ")} |`, `| ${headers.map(() => "---").join(" | ")} |`];
656
+ for (const row of rows) lines.push(`| ${row.map(cell).join(" | ")} |`);
657
+ return lines.join("\n");
658
+ }
659
+ function errorMarkdown(error) {
660
+ return table([
661
+ "Path",
662
+ "Code",
663
+ "Message"
664
+ ], [[
665
+ error.path || "`$`",
666
+ `\`${error.code}\``,
667
+ error.message
668
+ ]]);
669
+ }
670
+ function heading(title, body) {
671
+ return `# ${title}\n\n${body}\n`;
672
+ }
673
+ //#endregion
674
+ //#region src/report.ts
675
+ const slotPattern = /\{\{silo-query:([a-z][a-z0-9_-]*)\}\}/g;
676
+ const queryName = /^[a-z][a-z0-9_-]*$/;
677
+ const slug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
678
+ const resultLimit = 500;
679
+ function object(value, path) {
680
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new SiloError(exits.input, "invalid_shape", "Expected a JSON object.", path);
681
+ }
682
+ function knownFields(value, allowed, path) {
683
+ const key = Object.keys(value).find((candidate) => !allowed.includes(candidate));
684
+ if (key) throw new SiloError(exits.input, "unknown_field", `Unknown field ${key}.`, `${path}.${key}`);
685
+ }
686
+ function validateReportSlug(value, path = "$.slug") {
687
+ if (typeof value !== "string" || !slug.test(value)) throw new SiloError(exits.input, "invalid_report_slug", "Expected a lowercase slug containing letters, digits, and single hyphens.", path);
688
+ }
689
+ function parseReportDefinition(value) {
690
+ object(value, "$");
691
+ knownFields(value, [
692
+ "slug",
693
+ "title",
694
+ "markdown",
695
+ "queries"
696
+ ], "$");
697
+ validateReportSlug(value.slug);
698
+ if (typeof value.title !== "string" || !value.title.trim()) throw new SiloError(exits.input, "invalid_report_title", "title must be non-empty.", "$.title");
699
+ if (typeof value.markdown !== "string" || !value.markdown.trim()) throw new SiloError(exits.input, "invalid_report_markdown", "markdown must be non-empty.", "$.markdown");
700
+ if (!Array.isArray(value.queries) || !value.queries.length) throw new SiloError(exits.input, "invalid_report_queries", "queries must contain at least one saved query.", "$.queries");
701
+ const names = /* @__PURE__ */ new Set();
702
+ const queries = value.queries.map((candidate, index) => {
703
+ const path = `$.queries[${index}]`;
704
+ object(candidate, path);
705
+ knownFields(candidate, [
706
+ "name",
707
+ "sql",
708
+ "empty_markdown"
709
+ ], path);
710
+ if (typeof candidate.name !== "string" || !queryName.test(candidate.name)) throw new SiloError(exits.input, "invalid_report_query_name", "Query names must start with a lowercase letter and contain lowercase letters, digits, underscores, or hyphens.", `${path}.name`);
711
+ if (names.has(candidate.name)) throw new SiloError(exits.input, "duplicate_report_query", `Duplicate query name ${candidate.name}.`, `${path}.name`);
712
+ names.add(candidate.name);
713
+ if (typeof candidate.sql !== "string" || !candidate.sql.trim()) throw new SiloError(exits.input, "invalid_report_query", "sql must be non-empty.", `${path}.sql`);
714
+ if (candidate.empty_markdown !== void 0 && (typeof candidate.empty_markdown !== "string" || !candidate.empty_markdown.trim())) throw new SiloError(exits.input, "invalid_empty_markdown", "empty_markdown must be a non-empty Markdown string when supplied.", `${path}.empty_markdown`);
715
+ return {
716
+ name: candidate.name,
717
+ sql: candidate.sql,
718
+ ...candidate.empty_markdown === void 0 ? {} : { empty_markdown: candidate.empty_markdown }
719
+ };
720
+ });
721
+ const referenced = /* @__PURE__ */ new Set();
722
+ for (const match of value.markdown.matchAll(slotPattern)) referenced.add(match[1]);
723
+ if (value.markdown.replace(slotPattern, "").includes("{{silo-query:")) throw new SiloError(exits.input, "invalid_report_slot", "Query slots must use {{silo-query:name}} with a valid query name.", "$.markdown");
724
+ for (const name of referenced) if (!names.has(name)) throw new SiloError(exits.input, "unknown_report_query", `The template references unknown query ${name}.`, "$.markdown");
725
+ for (const name of names) if (!referenced.has(name)) throw new SiloError(exits.input, "unused_report_query", `Saved query ${name} has no template slot.`, "$.queries");
726
+ return {
727
+ slug: value.slug,
728
+ title: value.title.trim(),
729
+ markdown: value.markdown,
730
+ queries
731
+ };
732
+ }
733
+ function meaningfulSql(value) {
734
+ let lineComment = false;
735
+ let blockComment = false;
736
+ for (let index = 0; index < value.length; index++) {
737
+ const char = value[index];
738
+ const next = value[index + 1];
739
+ if (lineComment) {
740
+ if (char === "\n") lineComment = false;
741
+ continue;
742
+ }
743
+ if (blockComment) {
744
+ if (char === "*" && next === "/") {
745
+ blockComment = false;
746
+ index++;
747
+ }
748
+ continue;
749
+ }
750
+ if (char === "-" && next === "-") {
751
+ lineComment = true;
752
+ index++;
753
+ continue;
754
+ }
755
+ if (char === "/" && next === "*") {
756
+ blockComment = true;
757
+ index++;
758
+ continue;
759
+ }
760
+ if (!/\s/.test(char)) return true;
761
+ }
762
+ return false;
763
+ }
764
+ function assertSingleStatement(sql, path) {
765
+ let quote;
766
+ let lineComment = false;
767
+ let blockComment = false;
768
+ for (let index = 0; index < sql.length; index++) {
769
+ const char = sql[index];
770
+ const next = sql[index + 1];
771
+ if (lineComment) {
772
+ if (char === "\n") lineComment = false;
773
+ continue;
774
+ }
775
+ if (blockComment) {
776
+ if (char === "*" && next === "/") {
777
+ blockComment = false;
778
+ index++;
779
+ }
780
+ continue;
781
+ }
782
+ if (quote) {
783
+ if (char === quote) if (quote !== "]" && next === quote) index++;
784
+ else quote = void 0;
785
+ continue;
786
+ }
787
+ if (char === "-" && next === "-") {
788
+ lineComment = true;
789
+ index++;
790
+ } else if (char === "/" && next === "*") {
791
+ blockComment = true;
792
+ index++;
793
+ } else if (char === "'" || char === "\"" || char === "`") quote = char;
794
+ else if (char === "[") quote = "]";
795
+ else if (char === ";" && meaningfulSql(sql.slice(index + 1))) throw new SiloError(exits.input, "multiple_report_statements", "A saved query must contain exactly one SQLite statement.", path);
796
+ }
797
+ }
798
+ function runQuery(database, query, index) {
799
+ const path = `$.queries[${index}].sql`;
800
+ assertSingleStatement(query.sql, path);
801
+ try {
802
+ const statement = database.prepare(query.sql);
803
+ statement.setReturnArrays(true);
804
+ const rawColumns = statement.columns().map((column) => column.name || "column");
805
+ if (!rawColumns.length) throw new SiloError(exits.input, "report_query_has_no_columns", "A saved query must return result columns.", path);
806
+ const seen = /* @__PURE__ */ new Map();
807
+ const columns = rawColumns.map((name) => {
808
+ const count = (seen.get(name) ?? 0) + 1;
809
+ seen.set(name, count);
810
+ return count === 1 ? name : `${name}_${count}`;
811
+ });
812
+ const rows = [];
813
+ let truncated = false;
814
+ for (const row of statement.iterate()) {
815
+ if (rows.length === resultLimit) {
816
+ truncated = true;
817
+ break;
818
+ }
819
+ rows.push(row);
820
+ }
821
+ const rendered = rows.length ? table(columns, rows) : query.empty_markdown ?? "_No rows._";
822
+ return truncated ? `${rendered}\n\n> Results truncated to ${resultLimit} rows.` : rendered;
823
+ } catch (error) {
824
+ if (error instanceof SiloError) throw error;
825
+ throw new SiloError(exits.input, "invalid_report_query", error instanceof Error ? error.message : String(error), path);
826
+ }
827
+ }
828
+ function renderReport(database, definition) {
829
+ const allowed = /* @__PURE__ */ new Set([
830
+ constants.SQLITE_SELECT,
831
+ constants.SQLITE_READ,
832
+ constants.SQLITE_FUNCTION,
833
+ constants.SQLITE_RECURSIVE
834
+ ]);
835
+ database.setAuthorizer((action, first, second) => {
836
+ const resource = action === constants.SQLITE_READ ? first : action === constants.SQLITE_FUNCTION ? second : null;
837
+ if (resource && (resource.startsWith("_silo_") || resource.startsWith("sqlite_") || [
838
+ "load_extension",
839
+ "readfile",
840
+ "writefile",
841
+ "fts3_tokenizer"
842
+ ].includes(resource.toLowerCase()))) return constants.SQLITE_DENY;
843
+ return allowed.has(action) ? constants.SQLITE_OK : constants.SQLITE_DENY;
844
+ });
845
+ try {
846
+ const results = new Map(definition.queries.map((query, index) => [query.name, runQuery(database, query, index)]));
847
+ return definition.markdown.replace(slotPattern, (_, name) => results.get(name));
848
+ } finally {
849
+ database.setAuthorizer(null);
850
+ }
851
+ }
852
+ //#endregion
853
+ //#region src/database.ts
854
+ const FORMAT_VERSION = 2;
855
+ const TOOL_VERSION = "0.1.0";
856
+ function binding(value) {
857
+ if (value === null || typeof value === "number" || typeof value === "bigint" || typeof value === "string" || value instanceof Uint8Array) return value;
858
+ throw new SiloError(exits.input, "unsupported_sqlite_value", "The semantic value cannot be bound to SQLite.");
859
+ }
860
+ function now() {
861
+ return (/* @__PURE__ */ new Date()).toISOString();
862
+ }
863
+ function laterThan(value) {
864
+ const current = Date.now();
865
+ const previous = typeof value === "string" ? Date.parse(value) : NaN;
866
+ return new Date(Number.isFinite(previous) ? Math.max(current, previous + 1) : current).toISOString();
867
+ }
868
+ function sqliteError(error) {
869
+ if (error instanceof SiloError) throw error;
870
+ const message = error instanceof Error ? error.message : String(error);
871
+ const sqlite = error;
872
+ const code = (typeof sqlite.errcode === "number" ? sqlite.errcode & 255 : void 0) === 19 || /constraint|unique|foreign key|not null|check/i.test(`${typeof sqlite.errstr === "string" ? sqlite.errstr : ""} ${message}`) ? exits.constraint : exits.io;
873
+ throw new SiloError(code, code === exits.constraint ? "sqlite_constraint" : "sqlite_error", message);
874
+ }
875
+ function configure(database, writable) {
876
+ database.exec("PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000; PRAGMA trusted_schema=OFF;");
877
+ if (writable) {
878
+ const result = database.prepare("PRAGMA journal_mode=WAL").get();
879
+ if (!Object.values(result).some((value) => String(value).toLowerCase() === "wal")) throw new SiloError(exits.io, "wal_unavailable", "SQLite could not establish WAL journal mode.");
880
+ database.exec("PRAGMA synchronous=NORMAL;");
881
+ }
882
+ const version = String(Object.values(database.prepare("SELECT sqlite_version() AS version").get())[0]);
883
+ const [major, minor] = version.split(".").map(Number);
884
+ if (major < 3 || major === 3 && minor < 37) throw new SiloError(exits.integrity, "sqlite_too_old", `SQLite ${version} is older than the required 3.37.0.`);
885
+ }
886
+ function initialize(database, workspace, schema) {
887
+ const timestamp = now();
888
+ database.exec(`
889
+ CREATE TABLE _silo_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
890
+ CREATE TABLE _silo_schema (id INTEGER PRIMARY KEY CHECK (id = 1), schema_json TEXT NOT NULL) STRICT;
891
+ CREATE TABLE _silo_reports (
892
+ slug TEXT PRIMARY KEY,
893
+ title TEXT NOT NULL,
894
+ template_markdown TEXT NOT NULL,
895
+ rendered_markdown TEXT NOT NULL,
896
+ created_at TEXT NOT NULL,
897
+ updated_at TEXT NOT NULL,
898
+ refreshed_at TEXT NOT NULL,
899
+ last_refresh_attempt_at TEXT NOT NULL,
900
+ last_refresh_error TEXT
901
+ ) STRICT;
902
+ CREATE TABLE _silo_report_queries (
903
+ report_slug TEXT NOT NULL REFERENCES _silo_reports(slug) ON DELETE CASCADE,
904
+ name TEXT NOT NULL,
905
+ sql TEXT NOT NULL,
906
+ empty_markdown TEXT,
907
+ position INTEGER NOT NULL CHECK (position >= 0),
908
+ PRIMARY KEY (report_slug, name),
909
+ UNIQUE (report_slug, position)
910
+ ) STRICT;
911
+ `);
912
+ const insert = database.prepare("INSERT INTO _silo_meta (key, value) VALUES (?, ?)");
913
+ const values = {
914
+ format_version: String(FORMAT_VERSION),
915
+ registry_version: String(schema.registry_version),
916
+ tool_version: TOOL_VERSION,
917
+ identity: workspace.identity,
918
+ original_origin: workspace.origin,
919
+ created_at: timestamp,
920
+ updated_at: timestamp
921
+ };
922
+ if (schema.template_imports?.length) values.template_names = JSON.stringify(schema.template_imports.map((item) => item.name));
923
+ for (const [key, value] of Object.entries(values)) insert.run(key, value);
924
+ database.prepare("INSERT INTO _silo_schema (id, schema_json) VALUES (1, ?)").run(JSON.stringify(schema));
925
+ }
926
+ function metadata(database) {
927
+ try {
928
+ const rows = database.prepare("SELECT key, value FROM _silo_meta ORDER BY key").all();
929
+ const values = Object.fromEntries(rows.map((row) => [row.key, row.value]));
930
+ if (!values.identity || Number(values.format_version) !== FORMAT_VERSION) throw new SiloError(exits.integrity, "incompatible_database", "Database metadata is missing or incompatible.");
931
+ return {
932
+ identity: values.identity,
933
+ original_origin: values.original_origin,
934
+ created_at: values.created_at,
935
+ updated_at: values.updated_at,
936
+ format_version: Number(values.format_version),
937
+ tool_version: values.tool_version
938
+ };
939
+ } catch (error) {
940
+ if (error instanceof SiloError) throw error;
941
+ throw new SiloError(exits.integrity, "unrecognized_database", "The file is not a recognized Silo database.");
942
+ }
943
+ }
944
+ function readSchema(database) {
945
+ try {
946
+ const row = database.prepare("SELECT schema_json FROM _silo_schema WHERE id = 1").get();
947
+ if (!row) throw new Error("schema row missing");
948
+ return JSON.parse(row.schema_json);
949
+ } catch (error) {
950
+ throw new SiloError(exits.integrity, "schema_metadata_invalid", error instanceof Error ? error.message : String(error));
951
+ }
952
+ }
953
+ function normalizeDdl(sql) {
954
+ const tokens = [];
955
+ for (let i = 0; i < sql.length;) {
956
+ const char = sql[i];
957
+ if (/\s/.test(char)) {
958
+ i++;
959
+ continue;
960
+ }
961
+ if (char === "-" && sql[i + 1] === "-") {
962
+ i = sql.indexOf("\n", i + 2);
963
+ if (i < 0) break;
964
+ continue;
965
+ }
966
+ if (char === "/" && sql[i + 1] === "*") {
967
+ const end = sql.indexOf("*/", i + 2);
968
+ i = end < 0 ? sql.length : end + 2;
969
+ continue;
970
+ }
971
+ if (char === "'" || char === "\"" || char === "`" || char === "[") {
972
+ const close = char === "[" ? "]" : char;
973
+ let token = char;
974
+ i++;
975
+ while (i < sql.length) {
976
+ token += sql[i];
977
+ if (sql[i] === close) {
978
+ if (close !== "]" && sql[i + 1] === close) {
979
+ token += close;
980
+ i += 2;
981
+ continue;
982
+ }
983
+ i++;
984
+ break;
985
+ }
986
+ i++;
987
+ }
988
+ tokens.push(token);
989
+ continue;
990
+ }
991
+ if (/[A-Za-z0-9_$]/.test(char)) {
992
+ let end = i + 1;
993
+ while (end < sql.length && /[A-Za-z0-9_$]/.test(sql[end])) end++;
994
+ tokens.push(sql.slice(i, end).toLowerCase());
995
+ i = end;
996
+ continue;
997
+ }
998
+ const operator = [
999
+ "->>",
1000
+ "||",
1001
+ ">=",
1002
+ "<=",
1003
+ "<>",
1004
+ "!=",
1005
+ "==",
1006
+ "->"
1007
+ ].find((candidate) => sql.startsWith(candidate, i));
1008
+ tokens.push(operator ?? char);
1009
+ i += operator?.length ?? 1;
1010
+ }
1011
+ return tokens.join(" ");
1012
+ }
1013
+ function physicalFingerprint(database, schema) {
1014
+ const tableNames = new Set(schema.tables.map((table) => table.name.toLowerCase()));
1015
+ return database.prepare("SELECT type, name, tbl_name, sql FROM sqlite_schema WHERE type IN ('table', 'index', 'trigger') AND sql IS NOT NULL ORDER BY type, name").all().filter((row) => tableNames.has(row.tbl_name.toLowerCase())).map((row) => `${row.type}:${row.name.toLowerCase()}:${row.tbl_name.toLowerCase()}:${normalizeDdl(row.sql)}`);
1016
+ }
1017
+ function verifyPhysical(database, schema) {
1018
+ const expectedDatabase = new DatabaseSync(":memory:");
1019
+ try {
1020
+ expectedDatabase.exec("PRAGMA foreign_keys=ON;");
1021
+ expectedDatabase.exec(compileSchema(schema).join("\n"));
1022
+ const expected = physicalFingerprint(expectedDatabase, schema);
1023
+ const actual = physicalFingerprint(database, schema);
1024
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new SiloError(exits.integrity, "physical_schema_mismatch", "Physical tables, indexes, or triggers do not match authoritative schema metadata.");
1025
+ } finally {
1026
+ expectedDatabase.close();
1027
+ }
1028
+ }
1029
+ function validateSynchronizedSchema(schema) {
1030
+ for (const table of schema.tables) {
1031
+ if (!table.primary_key?.length) throw new SiloError(exits.schema, "sync_primary_key_required", `Synchronized table ${table.name} must declare a primary key.`);
1032
+ const nullable = table.primary_key.find((name) => table.columns.find((column) => column.name === name)?.nullable !== false);
1033
+ if (nullable) throw new SiloError(exits.schema, "sync_primary_key_nullable", `Synchronized primary key ${table.name}.${nullable} must be non-nullable.`);
1034
+ }
1035
+ }
1036
+ var SiloDatabase = class SiloDatabase {
1037
+ workspace;
1038
+ database;
1039
+ releaseWriterLock;
1040
+ closed = false;
1041
+ constructor(workspace, database, releaseWriterLock) {
1042
+ this.workspace = workspace;
1043
+ this.database = database;
1044
+ this.releaseWriterLock = releaseWriterLock;
1045
+ }
1046
+ static open(workspace, writable = false, allowSyncLock = false) {
1047
+ if (!existsSync(workspace.databasePath)) throw new SiloError(exits.absent, "database_absent", "No Silo database exists for this workspace.");
1048
+ let database;
1049
+ let releaseWriterLock;
1050
+ try {
1051
+ if (writable && !allowSyncLock) {
1052
+ releaseWriterLock = acquireFileLock(`${workspace.databasePath}.write-lock`, "Another writer or synchronization operation is using this database.");
1053
+ if (existsSync(`${workspace.databasePath}.sync-lock`)) throw new SiloError(exits.io, "sync_in_progress", "A synchronization operation is already using this database.");
1054
+ }
1055
+ database = new DatabaseSync(workspace.databasePath, { readOnly: !writable });
1056
+ configure(database, writable);
1057
+ if (metadata(database).identity !== workspace.identity) throw new SiloError(exits.integrity, "identity_mismatch", "Database identity does not match the normalized origin.");
1058
+ const instance = new SiloDatabase(workspace, database, releaseWriterLock);
1059
+ verifyPhysical(database, instance.getSchema());
1060
+ return instance;
1061
+ } catch (error) {
1062
+ database?.close();
1063
+ releaseWriterLock?.();
1064
+ sqliteError(error);
1065
+ }
1066
+ }
1067
+ static createWithSchema(workspace, schema) {
1068
+ if (existsSync(workspace.databasePath)) throw new SiloError(exits.schema, "database_exists", "A database already exists for this workspace.");
1069
+ validateCompiledSchema(schema);
1070
+ mkdirSync(dirname(workspace.databasePath), { recursive: true });
1071
+ const releaseWriterLock = acquireFileLock(`${workspace.databasePath}.write-lock`, "Another writer or synchronization operation is using this database.");
1072
+ if (existsSync(`${workspace.databasePath}.sync-lock`)) {
1073
+ releaseWriterLock();
1074
+ throw new SiloError(exits.io, "sync_in_progress", "A synchronization operation is already using this database.");
1075
+ }
1076
+ if (existsSync(workspace.databasePath)) {
1077
+ releaseWriterLock();
1078
+ throw new SiloError(exits.schema, "database_exists", "A database already exists for this workspace.");
1079
+ }
1080
+ let database;
1081
+ try {
1082
+ database = new DatabaseSync(workspace.databasePath);
1083
+ configure(database, true);
1084
+ database.exec("BEGIN IMMEDIATE");
1085
+ initialize(database, workspace, schema);
1086
+ database.exec(compileSchema(schema).join("\n"));
1087
+ const instance = new SiloDatabase(workspace, database, releaseWriterLock);
1088
+ instance.verify(schema);
1089
+ database.exec("COMMIT");
1090
+ return instance;
1091
+ } catch (error) {
1092
+ try {
1093
+ database?.exec("ROLLBACK");
1094
+ } catch {}
1095
+ database?.close();
1096
+ rmSync(workspace.databasePath, { force: true });
1097
+ rmSync(`${workspace.databasePath}-wal`, { force: true });
1098
+ rmSync(`${workspace.databasePath}-shm`, { force: true });
1099
+ releaseWriterLock();
1100
+ sqliteError(error);
1101
+ }
1102
+ }
1103
+ close() {
1104
+ if (this.closed) return;
1105
+ this.closed = true;
1106
+ try {
1107
+ this.database.close();
1108
+ } finally {
1109
+ this.releaseWriterLock?.();
1110
+ }
1111
+ }
1112
+ getMetadata() {
1113
+ return metadata(this.database);
1114
+ }
1115
+ getSchema() {
1116
+ return readSchema(this.database);
1117
+ }
1118
+ readReport(slug) {
1119
+ validateReportSlug(slug, "$.slug");
1120
+ const row = this.database.prepare(`SELECT slug, title, template_markdown, rendered_markdown, created_at, updated_at,
1121
+ refreshed_at, last_refresh_attempt_at, last_refresh_error
1122
+ FROM _silo_reports WHERE slug = ?`).get(slug);
1123
+ if (!row) throw new SiloError(exits.notFound, "report_not_found", `No report has slug ${slug}.`);
1124
+ const queries = this.database.prepare(`SELECT name, sql, empty_markdown FROM _silo_report_queries
1125
+ WHERE report_slug = ? ORDER BY position`).all(slug);
1126
+ return {
1127
+ slug: row.slug,
1128
+ title: row.title,
1129
+ markdown: row.template_markdown,
1130
+ queries: queries.map((query) => ({
1131
+ name: query.name,
1132
+ sql: query.sql,
1133
+ ...query.empty_markdown === null ? {} : { empty_markdown: query.empty_markdown }
1134
+ })),
1135
+ rendered_markdown: row.rendered_markdown,
1136
+ created_at: row.created_at,
1137
+ updated_at: row.updated_at,
1138
+ refreshed_at: row.refreshed_at,
1139
+ last_refresh_attempt_at: row.last_refresh_attempt_at,
1140
+ last_refresh_error: row.last_refresh_error
1141
+ };
1142
+ }
1143
+ getReport(slug) {
1144
+ return this.readReport(slug);
1145
+ }
1146
+ listReports() {
1147
+ return this.database.prepare(`SELECT slug, title, refreshed_at, last_refresh_attempt_at, last_refresh_error
1148
+ FROM _silo_reports ORDER BY slug`).all();
1149
+ }
1150
+ putReport(input) {
1151
+ const definition = parseReportDefinition(input);
1152
+ const timestamp = now();
1153
+ return this.mutateRows((report) => ({
1154
+ command: "report.put",
1155
+ report: report.slug
1156
+ }), () => {
1157
+ const rendered = renderReport(this.database, definition);
1158
+ this.database.prepare(`INSERT INTO _silo_reports (
1159
+ slug, title, template_markdown, rendered_markdown, created_at, updated_at,
1160
+ refreshed_at, last_refresh_attempt_at, last_refresh_error
1161
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)
1162
+ ON CONFLICT (slug) DO UPDATE SET
1163
+ title = excluded.title,
1164
+ template_markdown = excluded.template_markdown,
1165
+ rendered_markdown = excluded.rendered_markdown,
1166
+ updated_at = excluded.updated_at,
1167
+ refreshed_at = excluded.refreshed_at,
1168
+ last_refresh_attempt_at = excluded.last_refresh_attempt_at,
1169
+ last_refresh_error = NULL`).run(definition.slug, definition.title, definition.markdown, rendered, timestamp, timestamp, timestamp, timestamp);
1170
+ this.database.prepare("DELETE FROM _silo_report_queries WHERE report_slug = ?").run(definition.slug);
1171
+ const insert = this.database.prepare(`INSERT INTO _silo_report_queries
1172
+ (report_slug, name, sql, empty_markdown, position) VALUES (?, ?, ?, ?, ?)`);
1173
+ for (const [position, query] of definition.queries.entries()) insert.run(definition.slug, query.name, query.sql, query.empty_markdown ?? null, position);
1174
+ return this.readReport(definition.slug);
1175
+ });
1176
+ }
1177
+ refreshReport(slug) {
1178
+ validateReportSlug(slug, "$.slug");
1179
+ const timestamp = now();
1180
+ try {
1181
+ return this.mutateRows((report) => ({
1182
+ command: "report.refresh",
1183
+ report: report.slug
1184
+ }), () => {
1185
+ const current = this.readReport(slug);
1186
+ const definition = {
1187
+ slug: current.slug,
1188
+ title: current.title,
1189
+ markdown: current.markdown,
1190
+ queries: current.queries
1191
+ };
1192
+ const rendered = renderReport(this.database, definition);
1193
+ this.database.prepare(`UPDATE _silo_reports SET rendered_markdown = ?, refreshed_at = ?,
1194
+ last_refresh_attempt_at = ?, last_refresh_error = NULL WHERE slug = ?`).run(rendered, timestamp, timestamp, slug);
1195
+ return this.readReport(slug);
1196
+ });
1197
+ } catch (error) {
1198
+ if (!(error instanceof SiloError) || error.code !== "report_not_found") try {
1199
+ this.mutateRows(() => ({
1200
+ command: "report.refresh_error",
1201
+ report: slug
1202
+ }), () => {
1203
+ this.database.prepare(`UPDATE _silo_reports SET last_refresh_attempt_at = ?, last_refresh_error = ?
1204
+ WHERE slug = ?`).run(timestamp, error instanceof SiloError ? `${error.code}: ${error.message}` : error instanceof Error ? error.message : String(error), slug);
1205
+ });
1206
+ } catch (recordError) {
1207
+ sqliteError(recordError);
1208
+ }
1209
+ sqliteError(error);
1210
+ }
1211
+ }
1212
+ deleteReport(slug) {
1213
+ validateReportSlug(slug, "$.slug");
1214
+ this.mutateRows(() => ({
1215
+ command: "report.delete",
1216
+ report: slug
1217
+ }), () => {
1218
+ if (!this.database.prepare("DELETE FROM _silo_reports WHERE slug = ?").run(slug).changes) throw new SiloError(exits.notFound, "report_not_found", `No report has slug ${slug}.`);
1219
+ });
1220
+ }
1221
+ getSyncState() {
1222
+ if (!this.database.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = '_silo_sync'").get()) return void 0;
1223
+ return this.database.prepare("SELECT * FROM _silo_sync WHERE id = 1").get();
1224
+ }
1225
+ configureSync(remoteUrl, databaseId = randomUUID()) {
1226
+ const existing = this.getSyncState();
1227
+ if (existing) {
1228
+ if (existing.remote_url !== remoteUrl) throw new SiloError(exits.workspace, "sync_already_configured", `This database is already synchronized with ${existing.remote_url}.`);
1229
+ return existing;
1230
+ }
1231
+ validateSynchronizedSchema(this.getSchema());
1232
+ try {
1233
+ this.database.exec("BEGIN IMMEDIATE");
1234
+ this.database.exec(`
1235
+ CREATE TABLE _silo_sync (
1236
+ id INTEGER PRIMARY KEY CHECK (id = 1),
1237
+ database_id TEXT NOT NULL UNIQUE,
1238
+ remote_url TEXT NOT NULL,
1239
+ base_generation TEXT,
1240
+ base_etag TEXT,
1241
+ conflict_transaction_id TEXT
1242
+ ) STRICT;
1243
+ CREATE TABLE _silo_outbox (
1244
+ sequence INTEGER PRIMARY KEY,
1245
+ transaction_id TEXT NOT NULL UNIQUE,
1246
+ kind TEXT NOT NULL CHECK (kind IN ('data', 'schema')),
1247
+ base_generation TEXT,
1248
+ schema_revision INTEGER NOT NULL,
1249
+ operation_json TEXT NOT NULL CHECK (json_valid(operation_json)),
1250
+ changeset BLOB NOT NULL,
1251
+ created_at TEXT NOT NULL
1252
+ ) STRICT;
1253
+ `);
1254
+ this.database.prepare("INSERT INTO _silo_sync (id, database_id, remote_url) VALUES (1, ?, ?)").run(databaseId, remoteUrl);
1255
+ this.database.exec("COMMIT");
1256
+ return this.getSyncState();
1257
+ } catch (error) {
1258
+ try {
1259
+ this.database.exec("ROLLBACK");
1260
+ } catch {}
1261
+ sqliteError(error);
1262
+ }
1263
+ }
1264
+ pendingTransactions() {
1265
+ if (!this.getSyncState()) return [];
1266
+ return this.database.prepare("SELECT * FROM _silo_outbox ORDER BY sequence").all().map(({ operation_json, ...row }) => ({
1267
+ ...row,
1268
+ operation: JSON.parse(operation_json)
1269
+ }));
1270
+ }
1271
+ setSyncConflict(transactionId) {
1272
+ if (!this.getSyncState()) throw new SiloError(exits.workspace, "sync_not_configured", "Synchronization is not configured.");
1273
+ this.database.prepare("UPDATE _silo_sync SET conflict_transaction_id = ? WHERE id = 1").run(transactionId);
1274
+ }
1275
+ markSynchronized(generation, etag) {
1276
+ if (!this.getSyncState()) throw new SiloError(exits.workspace, "sync_not_configured", "Synchronization is not configured.");
1277
+ try {
1278
+ this.database.exec("BEGIN IMMEDIATE");
1279
+ this.database.prepare("UPDATE _silo_sync SET base_generation = ?, base_etag = ?, conflict_transaction_id = NULL WHERE id = 1").run(generation, etag);
1280
+ this.database.prepare("DELETE FROM _silo_outbox").run();
1281
+ this.database.exec("COMMIT");
1282
+ } catch (error) {
1283
+ try {
1284
+ this.database.exec("ROLLBACK");
1285
+ } catch {}
1286
+ sqliteError(error);
1287
+ }
1288
+ }
1289
+ async backupCanonical(path, generation) {
1290
+ if (!this.getSyncState()) throw new SiloError(exits.workspace, "sync_not_configured", "Synchronization is not configured.");
1291
+ this.database.exec("PRAGMA wal_checkpoint(TRUNCATE)");
1292
+ await backup(this.database, path);
1293
+ const canonical = new DatabaseSync(path);
1294
+ try {
1295
+ configure(canonical, true);
1296
+ canonical.exec("BEGIN IMMEDIATE");
1297
+ canonical.prepare("DELETE FROM _silo_outbox").run();
1298
+ canonical.prepare("UPDATE _silo_sync SET base_generation = ?, base_etag = NULL, conflict_transaction_id = NULL WHERE id = 1").run(generation);
1299
+ canonical.exec("COMMIT");
1300
+ } catch (error) {
1301
+ try {
1302
+ canonical.exec("ROLLBACK");
1303
+ } catch {}
1304
+ sqliteError(error);
1305
+ } finally {
1306
+ canonical.close();
1307
+ }
1308
+ }
1309
+ rebasePending(pending, generation, etag, discardTransactionId) {
1310
+ if (!this.getSyncState()) throw new SiloError(exits.integrity, "sync_metadata_missing", "Restored sync metadata is missing.");
1311
+ try {
1312
+ this.database.exec("BEGIN IMMEDIATE");
1313
+ this.database.prepare("DELETE FROM _silo_outbox").run();
1314
+ this.database.prepare("UPDATE _silo_sync SET base_generation = ?, base_etag = ?, conflict_transaction_id = NULL WHERE id = 1").run(generation, etag);
1315
+ for (const item of pending) {
1316
+ if (item.transaction_id === discardTransactionId) continue;
1317
+ if (item.kind !== "data" || item.schema_revision !== this.getSchema().revision) {
1318
+ this.database.exec("ROLLBACK");
1319
+ return item.transaction_id;
1320
+ }
1321
+ if (!this.database.applyChangeset(item.changeset)) {
1322
+ this.database.exec("ROLLBACK");
1323
+ return item.transaction_id;
1324
+ }
1325
+ this.database.prepare(`INSERT INTO _silo_outbox
1326
+ (sequence, transaction_id, kind, base_generation, schema_revision, operation_json, changeset, created_at)
1327
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(item.sequence, item.transaction_id, item.kind, generation, item.schema_revision, JSON.stringify(item.operation), item.changeset, item.created_at);
1328
+ }
1329
+ this.verify(this.getSchema());
1330
+ const integrity = this.database.prepare("PRAGMA integrity_check").get();
1331
+ if (!Object.values(integrity).some((value) => value === "ok")) throw new SiloError(exits.integrity, "integrity_check_failed", "SQLite integrity check failed.");
1332
+ this.database.exec("COMMIT");
1333
+ return;
1334
+ } catch (error) {
1335
+ try {
1336
+ this.database.exec("ROLLBACK");
1337
+ } catch {}
1338
+ sqliteError(error);
1339
+ }
1340
+ }
1341
+ mutateRows(operation, mutate) {
1342
+ const sync = this.getSyncState();
1343
+ const session = sync ? this.database.createSession() : void 0;
1344
+ try {
1345
+ this.database.exec("BEGIN IMMEDIATE");
1346
+ const result = mutate();
1347
+ if (session) {
1348
+ const changeset = session.changeset();
1349
+ if (changeset.byteLength) this.database.prepare(`INSERT INTO _silo_outbox
1350
+ (transaction_id, kind, base_generation, schema_revision, operation_json, changeset, created_at)
1351
+ VALUES (?, 'data', ?, ?, ?, ?, ?)`).run(randomUUID(), sync.base_generation, this.getSchema().revision, JSON.stringify(operation(result)), changeset, now());
1352
+ }
1353
+ this.database.exec("COMMIT");
1354
+ return result;
1355
+ } catch (error) {
1356
+ try {
1357
+ this.database.exec("ROLLBACK");
1358
+ } catch {}
1359
+ return sqliteError(error);
1360
+ } finally {
1361
+ session?.close();
1362
+ }
1363
+ }
1364
+ prepareSchemaMutation(proposed) {
1365
+ const sync = this.getSyncState();
1366
+ if (!sync) return void 0;
1367
+ if (sync.conflict_transaction_id) throw new SiloError(exits.revision, "sync_conflict_unresolved", `Resolve synchronized transaction ${sync.conflict_transaction_id} before changing schema.`);
1368
+ if (this.pendingTransactions().length) throw new SiloError(exits.revision, "sync_schema_requires_clean_base", "Push or discard pending transactions before changing synchronized schema.");
1369
+ validateSynchronizedSchema(proposed);
1370
+ return sync;
1371
+ }
1372
+ recordSchemaMutation(sync, operation, beforeRevision, afterRevision) {
1373
+ if (!sync) return;
1374
+ this.database.prepare(`INSERT INTO _silo_outbox
1375
+ (transaction_id, kind, base_generation, schema_revision, operation_json, changeset, created_at)
1376
+ VALUES (?, 'schema', ?, ?, ?, ?, ?)`).run(randomUUID(), sync.base_generation, afterRevision, JSON.stringify({
1377
+ ...operation,
1378
+ before_revision: beforeRevision,
1379
+ after_revision: afterRevision
1380
+ }), /* @__PURE__ */ new Uint8Array(), now());
1381
+ }
1382
+ replaceSchema(schema) {
1383
+ this.database.prepare("UPDATE _silo_schema SET schema_json = ? WHERE id = 1").run(JSON.stringify(schema));
1384
+ this.database.prepare("UPDATE _silo_meta SET value = ? WHERE key = 'updated_at'").run(now());
1385
+ if (schema.template_imports?.length) this.database.prepare("INSERT INTO _silo_meta (key, value) VALUES ('template_names', ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value").run(JSON.stringify(schema.template_imports.map((item) => item.name)));
1386
+ }
1387
+ createTable(input) {
1388
+ const table = parseTable(input);
1389
+ const schema = this.getSchema();
1390
+ if (schema.tables.some((candidate) => candidate.name.toLowerCase() === table.name.toLowerCase())) throw new SiloError(exits.schema, "table_exists", `${table.name} already exists.`, "$.name");
1391
+ const proposed = {
1392
+ ...schema,
1393
+ revision: schema.revision + 1,
1394
+ tables: [...schema.tables, table]
1395
+ };
1396
+ validateCompiledSchema(proposed);
1397
+ const sync = this.prepareSchemaMutation(proposed);
1398
+ try {
1399
+ this.database.exec("BEGIN IMMEDIATE");
1400
+ this.database.exec(compileTable(table).join("\n"));
1401
+ this.replaceSchema(proposed);
1402
+ this.verify(proposed);
1403
+ this.recordSchemaMutation(sync, {
1404
+ command: "table.create",
1405
+ table: table.name
1406
+ }, schema.revision, proposed.revision);
1407
+ this.database.exec("COMMIT");
1408
+ return table;
1409
+ } catch (error) {
1410
+ try {
1411
+ this.database.exec("ROLLBACK");
1412
+ } catch {}
1413
+ sqliteError(error);
1414
+ }
1415
+ }
1416
+ importTemplate(name, template) {
1417
+ const schema = this.getSchema();
1418
+ const existing = new Set(schema.tables.map((table) => table.name.toLowerCase()));
1419
+ const conflict = template.tables.find((table) => existing.has(table.name.toLowerCase()));
1420
+ if (conflict) throw new SiloError(exits.schema, "template_table_conflict", `Template ${name} conflicts with existing table ${conflict.name}.`, "$.tables");
1421
+ const proposed = {
1422
+ ...schema,
1423
+ revision: schema.revision + 1,
1424
+ tables: [...schema.tables, ...template.tables],
1425
+ template_imports: [...schema.template_imports ?? [], {
1426
+ name,
1427
+ imported_at: now()
1428
+ }],
1429
+ agent_instructions: [...schema.agent_instructions ?? [], ...template.agent_instructions ? [{
1430
+ source: `template:${name}`,
1431
+ content: template.agent_instructions
1432
+ }] : []]
1433
+ };
1434
+ validateCompiledSchema(proposed);
1435
+ const sync = this.prepareSchemaMutation(proposed);
1436
+ try {
1437
+ this.database.exec("BEGIN IMMEDIATE");
1438
+ this.database.exec(template.tables.flatMap(compileTable).join("\n"));
1439
+ this.replaceSchema(proposed);
1440
+ this.verify(proposed);
1441
+ this.recordSchemaMutation(sync, {
1442
+ command: "schema.import",
1443
+ template: name
1444
+ }, schema.revision, proposed.revision);
1445
+ this.database.exec("COMMIT");
1446
+ return proposed;
1447
+ } catch (error) {
1448
+ try {
1449
+ this.database.exec("ROLLBACK");
1450
+ } catch {}
1451
+ sqliteError(error);
1452
+ }
1453
+ }
1454
+ alterTable(name, input) {
1455
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new SiloError(exits.input, "invalid_shape", "Expected an alter request object.");
1456
+ const request = input;
1457
+ const unknown = Object.keys(request).find((key) => key !== "add_columns" && key !== "add_indexes");
1458
+ if (unknown) throw new SiloError(exits.input, "unknown_field", `Unknown field ${unknown}.`, `$.${unknown}`);
1459
+ const schema = this.getSchema();
1460
+ const position = schema.tables.findIndex((table) => table.name.toLowerCase() === name.toLowerCase());
1461
+ if (position < 0) throw new SiloError(exits.notFound, "table_not_found", `${name} does not exist.`);
1462
+ const current = schema.tables[position];
1463
+ const candidate = parseTable({
1464
+ ...current,
1465
+ columns: [...current.columns, ...request.add_columns ?? []],
1466
+ indexes: [...current.indexes ?? [], ...request.add_indexes ?? []]
1467
+ });
1468
+ for (const column of request.add_columns ?? []) {
1469
+ const value = column;
1470
+ if (value.generated || value.nullable === false && value.default === void 0) throw new SiloError(exits.schema, "unsupported_alter", "Added columns must be nullable or have a compatible constant default, and cannot be generated.");
1471
+ if (value.default?.expression) throw new SiloError(exits.schema, "unsupported_alter", "Added-column defaults must be JSON literals in the initial release.");
1472
+ }
1473
+ const proposed = {
1474
+ ...schema,
1475
+ revision: schema.revision + 1,
1476
+ tables: schema.tables.map((table, i) => i === position ? candidate : table)
1477
+ };
1478
+ validateCompiledSchema(proposed);
1479
+ const sync = this.prepareSchemaMutation(proposed);
1480
+ try {
1481
+ this.database.exec("BEGIN IMMEDIATE");
1482
+ for (const column of request.add_columns ?? []) this.database.exec(`ALTER TABLE ${quote(current.name)} ADD COLUMN ${compileTable({
1483
+ ...current,
1484
+ columns: [column],
1485
+ primary_key: void 0,
1486
+ foreign_keys: [],
1487
+ unique_constraints: [],
1488
+ indexes: [],
1489
+ checks: [],
1490
+ policies: []
1491
+ })[0].match(/\(\n (.*)\n\)/s)[1]};`);
1492
+ if ((candidate.indexes?.slice(current.indexes?.length ?? 0) ?? []).length) {
1493
+ const indexes = compileTable(candidate).filter((statement) => /^CREATE (?:UNIQUE )?INDEX /.test(statement));
1494
+ for (const statement of indexes.slice(current.indexes?.length ?? 0)) this.database.exec(statement);
1495
+ }
1496
+ this.replaceSchema(proposed);
1497
+ this.verify(proposed);
1498
+ this.recordSchemaMutation(sync, {
1499
+ command: "table.alter",
1500
+ table: current.name
1501
+ }, schema.revision, proposed.revision);
1502
+ this.database.exec("COMMIT");
1503
+ return candidate;
1504
+ } catch (error) {
1505
+ try {
1506
+ this.database.exec("ROLLBACK");
1507
+ } catch {}
1508
+ sqliteError(error);
1509
+ }
1510
+ }
1511
+ dropTable(name) {
1512
+ const schema = this.getSchema();
1513
+ const existing = schema.tables.find((table) => table.name.toLowerCase() === name.toLowerCase());
1514
+ if (!existing) throw new SiloError(exits.notFound, "table_not_found", `${name} does not exist.`);
1515
+ const proposed = {
1516
+ ...schema,
1517
+ revision: schema.revision + 1,
1518
+ tables: schema.tables.filter((table) => table.name !== existing.name)
1519
+ };
1520
+ validateCompiledSchema(proposed);
1521
+ const sync = this.prepareSchemaMutation(proposed);
1522
+ try {
1523
+ this.database.exec("BEGIN IMMEDIATE");
1524
+ this.database.exec(`DROP TABLE ${quote(existing.name)}`);
1525
+ this.replaceSchema(proposed);
1526
+ this.verify(proposed);
1527
+ this.recordSchemaMutation(sync, {
1528
+ command: "table.drop",
1529
+ table: existing.name
1530
+ }, schema.revision, proposed.revision);
1531
+ this.database.exec("COMMIT");
1532
+ } catch (error) {
1533
+ try {
1534
+ this.database.exec("ROLLBACK");
1535
+ } catch {}
1536
+ sqliteError(error);
1537
+ }
1538
+ }
1539
+ verify(schema) {
1540
+ verifyPhysical(this.database, schema);
1541
+ }
1542
+ table(name) {
1543
+ const table = this.getSchema().tables.find((candidate) => candidate.name.toLowerCase() === name.toLowerCase());
1544
+ if (!table) throw new SiloError(exits.notFound, "table_not_found", `${name} does not exist.`);
1545
+ return table;
1546
+ }
1547
+ addRows(name, input, upsert = false) {
1548
+ const table = this.table(name);
1549
+ const rows = Array.isArray(input) ? input : [input];
1550
+ if (!rows.length) throw new SiloError(exits.input, "invalid_shape", "At least one row is required.");
1551
+ return this.mutateRows((results) => ({
1552
+ command: upsert ? "row.upsert" : "row.add",
1553
+ table: table.name,
1554
+ keys: table.primary_key ? results.map((row) => table.primary_key.map((key) => row[key])) : []
1555
+ }), () => {
1556
+ const results = [];
1557
+ for (const raw of rows) {
1558
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new SiloError(exits.input, "invalid_shape", "Each row must be an object.");
1559
+ const request = raw;
1560
+ const row = this.prepareRow(table, request, true);
1561
+ const columns = Object.keys(row);
1562
+ let sql = columns.length ? `INSERT INTO ${quote(table.name)} (${columns.map(quote).join(", ")}) VALUES (${columns.map(() => "?").join(", ")})` : `INSERT INTO ${quote(table.name)} DEFAULT VALUES`;
1563
+ let naturalKeys;
1564
+ if (upsert) {
1565
+ const upsertPolicy = policy(table, "natural_key_upsert");
1566
+ if (!upsertPolicy) throw new SiloError(exits.schema, "upsert_not_declared", "The table has no natural_key_upsert policy.");
1567
+ const keys = upsertPolicy.columns;
1568
+ if (keys.some((key) => row[key] === void 0)) throw new SiloError(exits.input, "upsert_key_required", "Every natural-key upsert column must be provided.");
1569
+ naturalKeys = keys;
1570
+ const allowed = (upsertPolicy.update_columns ?? Object.keys(request).filter((column) => !keys.includes(column))).filter((column) => columns.includes(column));
1571
+ if (!allowed.length) {
1572
+ const existing = this.findPersistedRow(table, keys, row);
1573
+ if (existing) {
1574
+ results.push(existing);
1575
+ continue;
1576
+ }
1577
+ }
1578
+ sql += ` ON CONFLICT (${keys.map(quote).join(", ")}) ${allowed.length ? `DO UPDATE SET ${allowed.map((column) => `${quote(column)} = excluded.${quote(column)}`).join(", ")}` : "DO NOTHING"}`;
1579
+ }
1580
+ sql += ` RETURNING ${table.without_rowid ? "" : "rowid AS \"_silo_rowid\", "}*`;
1581
+ const returned = this.database.prepare(sql).get(...Object.values(row));
1582
+ results.push(this.readPersistedRow(table, returned, naturalKeys, row));
1583
+ }
1584
+ return results;
1585
+ });
1586
+ }
1587
+ readPersistedRow(table, returned, fallbackColumns, fallbackValues) {
1588
+ let where;
1589
+ let values;
1590
+ if (returned?._silo_rowid !== void 0) {
1591
+ where = "rowid = ?";
1592
+ values = [binding(returned._silo_rowid)];
1593
+ } else {
1594
+ const columns = returned ? table.primary_key : fallbackColumns;
1595
+ if (!columns?.length) throw new SiloError(exits.integrity, "persisted_row_unresolved", "The persisted row could not be located after mutation.");
1596
+ where = columns.map((column) => `${quote(column)} = ?`).join(" AND ");
1597
+ values = columns.map((column) => binding(returned ? returned[column] : fallbackValues?.[column]));
1598
+ }
1599
+ const persisted = this.database.prepare(`SELECT * FROM ${quote(table.name)} WHERE ${where}`).get(...values);
1600
+ if (!persisted) throw new SiloError(exits.integrity, "persisted_row_unresolved", "The persisted row could not be located after mutation.");
1601
+ return this.renderRow(table, persisted);
1602
+ }
1603
+ findPersistedRow(table, columns, source) {
1604
+ const persisted = this.database.prepare(`SELECT * FROM ${quote(table.name)} WHERE ${columns.map((column) => `${quote(column)} = ?`).join(" AND ")}`).get(...columns.map((column) => source[column]));
1605
+ return persisted ? this.renderRow(table, persisted) : void 0;
1606
+ }
1607
+ prepareRow(table, raw, insert) {
1608
+ const known = new Map(table.columns.map((column) => [column.name, column]));
1609
+ for (const key of Object.keys(raw)) if (!known.has(key)) throw new SiloError(exits.input, "unknown_field", `Unknown field ${key}.`, `$.${key}`);
1610
+ else if (known.get(key).generated) throw new SiloError(exits.input, "generated_column_input", `Generated column ${key} cannot be written directly.`, `$.${key}`);
1611
+ const row = {};
1612
+ for (const [key, value] of Object.entries(raw)) row[key] = binding(canonicalize(known.get(key), value));
1613
+ if (insert) {
1614
+ const identity = policy(table, "generated_identity");
1615
+ if (identity && identity.strategy !== "integer" && row[identity.column] === void 0) row[identity.column] = generatedValue(identity.strategy);
1616
+ const timestamps = policy(table, "timestamps");
1617
+ if (timestamps) {
1618
+ const timestamp = now();
1619
+ if (timestamps.created_column && row[timestamps.created_column] === void 0) row[timestamps.created_column] = timestamp;
1620
+ if (timestamps.updated_column && row[timestamps.updated_column] === void 0) row[timestamps.updated_column] = timestamp;
1621
+ }
1622
+ const revision = policy(table, "optimistic_revision");
1623
+ if (revision && row[revision.column] === void 0) row[revision.column] = binding(revision.initial ?? 1);
1624
+ }
1625
+ return row;
1626
+ }
1627
+ keyWhere(table, key) {
1628
+ const keys = table.primary_key?.length ? table.primary_key : table.columns.filter((column) => policy(table, "generated_identity")?.column === column.name).map((column) => column.name);
1629
+ if (!keys?.length) throw new SiloError(exits.schema, "primary_key_required", "Row-by-key operations require a primary key or generated identity.");
1630
+ let values;
1631
+ if (keys.length === 1) values = [key];
1632
+ else if (Array.isArray(key)) values = key;
1633
+ else if (typeof key === "string") try {
1634
+ const decoded = JSON.parse(key);
1635
+ values = Array.isArray(decoded) ? decoded : [];
1636
+ } catch {
1637
+ values = [];
1638
+ }
1639
+ else values = [];
1640
+ if (values.length !== keys.length) throw new SiloError(exits.input, "invalid_key", `Expected ${keys.length} key values.`);
1641
+ return {
1642
+ sql: keys.map((column) => `${quote(column)} = ?`).join(" AND "),
1643
+ values: values.map((value, i) => {
1644
+ const column = table.columns.find((candidate) => candidate.name === keys[i]);
1645
+ let decoded = value;
1646
+ if (typeof value === "string") {
1647
+ if (semantic(column).storage !== "TEXT" || column.type === "text/json" || /^"(?:[^"\\]|\\.)*"$/.test(value)) try {
1648
+ decoded = JSON.parse(value);
1649
+ } catch {}
1650
+ }
1651
+ return binding(canonicalize(column, decoded));
1652
+ })
1653
+ };
1654
+ }
1655
+ getRow(name, key) {
1656
+ const table = this.table(name);
1657
+ const where = this.keyWhere(table, key);
1658
+ const row = this.database.prepare(`SELECT * FROM ${quote(table.name)} WHERE ${where.sql}`).get(...where.values);
1659
+ if (!row) throw new SiloError(exits.notFound, "row_not_found", "No row matches the supplied key.");
1660
+ return this.renderRow(table, row);
1661
+ }
1662
+ listRows(name, limit, offset) {
1663
+ const table = this.table(name);
1664
+ const order = table.primary_key?.length ? ` ORDER BY ${table.primary_key.map(quote).join(", ")}` : table.without_rowid ? "" : " ORDER BY rowid";
1665
+ return this.database.prepare(`SELECT * FROM ${quote(table.name)}${order} LIMIT ? OFFSET ?`).all(limit, offset).map((row) => this.renderRow(table, row));
1666
+ }
1667
+ renderRow(table, row) {
1668
+ return Object.fromEntries(Object.entries(row).map(([name, value]) => [name, semantic(table.columns.find((column) => column.name === name)).render?.(value) ?? value]));
1669
+ }
1670
+ updateRow(name, key, input) {
1671
+ const table = this.table(name);
1672
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new SiloError(exits.input, "invalid_shape", "Expected a row object.");
1673
+ const raw = { ...input };
1674
+ const expected = raw._expected_revision;
1675
+ delete raw._expected_revision;
1676
+ if (this.getSyncState() && table.primary_key?.some((column) => column in raw)) throw new SiloError(exits.input, "sync_primary_key_immutable", "Synchronized primary-key values cannot be updated.");
1677
+ const row = this.prepareRow(table, raw, false);
1678
+ const timestamps = policy(table, "timestamps");
1679
+ const revision = policy(table, "optimistic_revision");
1680
+ const where = this.keyWhere(table, key);
1681
+ if (timestamps?.updated_column) {
1682
+ const column = timestamps.updated_column;
1683
+ row[column] = laterThan(this.database.prepare(`SELECT ${quote(column)} AS value FROM ${quote(table.name)} WHERE ${where.sql}`).get(...where.values)?.value);
1684
+ }
1685
+ if (revision) {
1686
+ if (!Number.isSafeInteger(expected)) throw new SiloError(exits.input, "expected_revision_required", "_expected_revision is required for this table.");
1687
+ where.sql += ` AND ${quote(revision.column)} = ?`;
1688
+ where.values.push(Number(expected));
1689
+ row[revision.column] = Number(expected) + 1;
1690
+ }
1691
+ const columns = Object.keys(row);
1692
+ if (!columns.length) throw new SiloError(exits.input, "empty_update", "At least one field must be updated.");
1693
+ return this.mutateRows(() => ({
1694
+ command: "row.update",
1695
+ table: table.name,
1696
+ key
1697
+ }), () => {
1698
+ const result = this.database.prepare(`UPDATE ${quote(table.name)} SET ${columns.map((column) => `${quote(column)} = ?`).join(", ")} WHERE ${where.sql}`).run(...Object.values(row), ...where.values);
1699
+ if (!result.changes) throw new SiloError(revision ? exits.revision : exits.notFound, revision ? "revision_conflict" : "row_not_found", revision ? "The row revision did not match." : "No row matches the supplied key.");
1700
+ return Number(result.changes);
1701
+ });
1702
+ }
1703
+ deleteRow(name, key) {
1704
+ const table = this.table(name);
1705
+ const where = this.keyWhere(table, key);
1706
+ return this.mutateRows(() => ({
1707
+ command: "row.delete",
1708
+ table: table.name,
1709
+ key
1710
+ }), () => {
1711
+ const result = this.database.prepare(`DELETE FROM ${quote(table.name)} WHERE ${where.sql}`).run(...where.values);
1712
+ if (!result.changes) throw new SiloError(exits.notFound, "row_not_found", "No row matches the supplied key.");
1713
+ return Number(result.changes);
1714
+ });
1715
+ }
1716
+ query(sql) {
1717
+ try {
1718
+ const statement = this.database.prepare(sql);
1719
+ statement.setReturnArrays(true);
1720
+ const rawColumns = statement.columns().map((column) => column.name || "column");
1721
+ const seen = /* @__PURE__ */ new Map();
1722
+ return {
1723
+ columns: rawColumns.map((name) => {
1724
+ const count = (seen.get(name) ?? 0) + 1;
1725
+ seen.set(name, count);
1726
+ return count === 1 ? name : `${name}_${count}`;
1727
+ }),
1728
+ rows: statement.all()
1729
+ };
1730
+ } catch (error) {
1731
+ sqliteError(error);
1732
+ }
1733
+ }
1734
+ ddl() {
1735
+ return compileSchema(this.getSchema()).join("\n\n");
1736
+ }
1737
+ };
1738
+ function emptySchema() {
1739
+ return {
1740
+ format_version: 1,
1741
+ registry_version: 1,
1742
+ revision: 1,
1743
+ tables: []
1744
+ };
1745
+ }
1746
+ function schemaFromTemplate(name, template, importedAt = now()) {
1747
+ const schema = {
1748
+ ...emptySchema(),
1749
+ tables: template.tables,
1750
+ template_imports: [{
1751
+ name,
1752
+ imported_at: importedAt
1753
+ }],
1754
+ ...template.agent_instructions ? { agent_instructions: [{
1755
+ source: `template:${name}`,
1756
+ content: template.agent_instructions
1757
+ }] } : {}
1758
+ };
1759
+ validateCompiledSchema(schema);
1760
+ return schema;
1761
+ }
1762
+ function sqliteVersion() {
1763
+ const database = new DatabaseSync(":memory:");
1764
+ try {
1765
+ database.exec("CREATE TABLE _capability_probe (value TEXT) STRICT; DROP TABLE _capability_probe;");
1766
+ return String(Object.values(database.prepare("SELECT sqlite_version() AS version").get())[0]);
1767
+ } finally {
1768
+ database.close();
1769
+ }
1770
+ }
1771
+ function readTemplate(name) {
1772
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name)) throw new SiloError(exits.input, "invalid_template_name", "Template names use letters, digits, hyphens, and underscores.");
1773
+ const localPath = join(dataRoot(), "templates", `${name}.json`);
1774
+ const bundledPath = join(fileURLToPath(new URL("../templates", import.meta.url)), `${name}.json`);
1775
+ const path = existsSync(localPath) ? localPath : bundledPath;
1776
+ if (!existsSync(path)) throw new SiloError(exits.notFound, "template_not_found", `${name} does not exist.`);
1777
+ try {
1778
+ const value = JSON.parse(readFileSync(path, "utf8"));
1779
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("template must be an object");
1780
+ const unknown = Object.keys(value).find((key) => ![
1781
+ "format_version",
1782
+ "agent_instructions",
1783
+ "tables"
1784
+ ].includes(key));
1785
+ if (unknown) throw new Error(`unknown field ${unknown}`);
1786
+ if (value.format_version !== void 0 && value.format_version !== 1) throw new Error("format_version must be 1");
1787
+ if (value.agent_instructions !== void 0 && (typeof value.agent_instructions !== "string" || !value.agent_instructions.trim())) throw new Error("agent_instructions must be a non-empty string");
1788
+ if (!Array.isArray(value.tables)) throw new Error("tables must be an array");
1789
+ const tables = value.tables.map(parseTable);
1790
+ validateCompiledSchema({
1791
+ format_version: 1,
1792
+ registry_version: 1,
1793
+ revision: 1,
1794
+ tables
1795
+ });
1796
+ return {
1797
+ format_version: 1,
1798
+ ...value.agent_instructions ? { agent_instructions: value.agent_instructions } : {},
1799
+ tables
1800
+ };
1801
+ } catch (error) {
1802
+ if (error instanceof SiloError) throw error;
1803
+ throw new SiloError(exits.input, "invalid_template", error instanceof Error ? error.message : String(error));
1804
+ }
1805
+ }
1806
+ function listTemplates() {
1807
+ const roots = [fileURLToPath(new URL("../templates", import.meta.url)), join(dataRoot(), "templates")];
1808
+ return [...new Set(roots.flatMap((root) => existsSync(root) ? readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => entry.name.slice(0, -5)) : []))].sort();
1809
+ }
1810
+ function discoverDatabases() {
1811
+ const root = join(dataRoot(), "databases");
1812
+ if (!existsSync(root)) return [];
1813
+ const paths = [];
1814
+ const walk = (directory) => {
1815
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
1816
+ const path = join(directory, entry.name);
1817
+ if (entry.isDirectory()) walk(path);
1818
+ else if (entry.name.endsWith(".sqlite")) paths.push(path);
1819
+ }
1820
+ };
1821
+ walk(root);
1822
+ return paths.sort().map((path) => {
1823
+ let identity;
1824
+ let database;
1825
+ try {
1826
+ database = new DatabaseSync(path, { readOnly: true });
1827
+ configure(database, false);
1828
+ const meta = metadata(database);
1829
+ identity = meta.identity;
1830
+ verifyPhysical(database, readSchema(database));
1831
+ return {
1832
+ path,
1833
+ state: "recognized",
1834
+ identity: meta.identity
1835
+ };
1836
+ } catch (error) {
1837
+ return {
1838
+ path,
1839
+ state: error instanceof SiloError && error.code === "unrecognized_database" ? "unrecognized" : error instanceof SiloError && error.exitCode === exits.integrity ? "incompatible" : "unreadable",
1840
+ identity,
1841
+ message: error instanceof Error ? error.message : String(error)
1842
+ };
1843
+ } finally {
1844
+ database?.close();
1845
+ }
1846
+ });
1847
+ }
1848
+ //#endregion
1849
+ //#region src/report-viewer.tsx
1850
+ const stylesheet = readFileSync(new URL("./report-viewer.css", import.meta.url), "utf8");
1851
+ function ReportMarkdown({ markdown }) {
1852
+ return /* @__PURE__ */ jsx("div", {
1853
+ className: "report-markdown",
1854
+ children: /* @__PURE__ */ jsx(ReactMarkdown, {
1855
+ remarkPlugins: [remarkGfm],
1856
+ skipHtml: true,
1857
+ children: markdown
1858
+ })
1859
+ });
1860
+ }
1861
+ function renderReportHtml(markdown) {
1862
+ return renderToStaticMarkup(/* @__PURE__ */ jsx(ReportMarkdown, { markdown }));
1863
+ }
1864
+ function SavedQueries({ queries }) {
1865
+ return /* @__PURE__ */ jsxs("details", {
1866
+ className: "query-panel",
1867
+ children: [/* @__PURE__ */ jsxs("summary", { children: [
1868
+ "Saved queries (",
1869
+ queries.length,
1870
+ ")"
1871
+ ] }), /* @__PURE__ */ jsx("div", {
1872
+ className: "query-list",
1873
+ children: queries.map((query) => /* @__PURE__ */ jsxs("section", { children: [/* @__PURE__ */ jsx("h3", { children: query.name }), /* @__PURE__ */ jsx("pre", { children: /* @__PURE__ */ jsx("code", { children: query.sql }) })] }, query.name))
1874
+ })]
1875
+ });
1876
+ }
1877
+ function renderSavedQueries(queries) {
1878
+ return renderToStaticMarkup(/* @__PURE__ */ jsx(SavedQueries, { queries }));
1879
+ }
1880
+ function clientScript(slug, token) {
1881
+ return `
1882
+ const slug = ${JSON.stringify(slug)};
1883
+ const token = ${JSON.stringify(token)};
1884
+ const content = document.querySelector('[data-report-content]');
1885
+ const status = document.querySelector('[data-refresh-status]');
1886
+ const refreshed = document.querySelector('[data-refreshed-at]');
1887
+ const error = document.querySelector('[data-refresh-error]');
1888
+ const reportTitle = document.querySelector('[data-report-title]');
1889
+ const savedQueries = document.querySelector('[data-saved-queries]');
1890
+ let refreshRequest;
1891
+
1892
+ function displayTime(value) {
1893
+ return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value));
1894
+ }
1895
+
1896
+ async function refresh() {
1897
+ if (refreshRequest) return refreshRequest;
1898
+ document.body.dataset.refreshState = 'refreshing';
1899
+ status.textContent = 'Refreshing…';
1900
+ error.hidden = true;
1901
+ refreshRequest = fetch('/api/reports/' + encodeURIComponent(slug) + '/refresh', {
1902
+ method: 'POST',
1903
+ headers: { 'x-silo-token': token }
1904
+ }).then(async (response) => {
1905
+ const body = await response.json();
1906
+ if (!response.ok) throw new Error(body.error?.message || 'Refresh failed.');
1907
+ content.innerHTML = body.html;
1908
+ reportTitle.textContent = body.title;
1909
+ if (savedQueries.innerHTML !== body.queries_html) savedQueries.innerHTML = body.queries_html;
1910
+ document.title = body.title + ' · Silo';
1911
+ refreshed.dateTime = body.refreshed_at;
1912
+ refreshed.textContent = displayTime(body.refreshed_at);
1913
+ status.textContent = 'Current';
1914
+ document.body.dataset.refreshState = 'current';
1915
+ }).catch((cause) => {
1916
+ status.textContent = 'Showing last good result';
1917
+ error.textContent = cause instanceof Error ? cause.message : String(cause);
1918
+ error.hidden = false;
1919
+ document.body.dataset.refreshState = 'stale';
1920
+ }).finally(() => {
1921
+ refreshRequest = undefined;
1922
+ });
1923
+ return refreshRequest;
1924
+ }
1925
+
1926
+ window.addEventListener('focus', () => {
1927
+ if (!document.hidden) refresh();
1928
+ });
1929
+ document.addEventListener('visibilitychange', () => {
1930
+ if (!document.hidden) refresh();
1931
+ });
1932
+ refresh();
1933
+ `;
1934
+ }
1935
+ function reportDocument(report, token, nonce) {
1936
+ const script = clientScript(report.slug, token);
1937
+ return `<!doctype html>${renderToStaticMarkup(/* @__PURE__ */ jsxs("html", {
1938
+ lang: "en",
1939
+ children: [/* @__PURE__ */ jsxs("head", { children: [
1940
+ /* @__PURE__ */ jsx("meta", { charSet: "utf-8" }),
1941
+ /* @__PURE__ */ jsx("meta", {
1942
+ name: "viewport",
1943
+ content: "width=device-width, initial-scale=1"
1944
+ }),
1945
+ /* @__PURE__ */ jsx("meta", {
1946
+ name: "color-scheme",
1947
+ content: "light dark"
1948
+ }),
1949
+ /* @__PURE__ */ jsx("title", { children: `${report.title} · Silo` }),
1950
+ /* @__PURE__ */ jsx("link", {
1951
+ rel: "stylesheet",
1952
+ href: "/report-viewer.css"
1953
+ })
1954
+ ] }), /* @__PURE__ */ jsxs("body", {
1955
+ "data-refresh-state": report.last_refresh_error ? "stale" : "current",
1956
+ children: [
1957
+ /* @__PURE__ */ jsxs("header", {
1958
+ className: "site-header",
1959
+ children: [/* @__PURE__ */ jsxs("a", {
1960
+ className: "brand",
1961
+ href: `/reports/${encodeURIComponent(report.slug)}`,
1962
+ children: [/* @__PURE__ */ jsx("span", {
1963
+ className: "brand-mark",
1964
+ "aria-hidden": "true",
1965
+ children: "S"
1966
+ }), /* @__PURE__ */ jsx("span", { children: "Silo report" })]
1967
+ }), /* @__PURE__ */ jsxs("div", {
1968
+ className: "refresh-state",
1969
+ "aria-live": "polite",
1970
+ children: [/* @__PURE__ */ jsx("span", {
1971
+ className: "status-dot",
1972
+ "aria-hidden": "true"
1973
+ }), /* @__PURE__ */ jsx("span", {
1974
+ "data-refresh-status": true,
1975
+ children: report.last_refresh_error ? "Showing last good result" : "Current"
1976
+ })]
1977
+ })]
1978
+ }),
1979
+ /* @__PURE__ */ jsxs("div", {
1980
+ className: "page-shell",
1981
+ children: [/* @__PURE__ */ jsx("main", {
1982
+ className: "report-card",
1983
+ "data-report-content": true,
1984
+ children: /* @__PURE__ */ jsx(ReportMarkdown, { markdown: report.rendered_markdown })
1985
+ }), /* @__PURE__ */ jsxs("aside", {
1986
+ className: "report-sidebar",
1987
+ "aria-label": "Report details",
1988
+ children: [/* @__PURE__ */ jsxs("section", {
1989
+ className: "metadata-card",
1990
+ children: [
1991
+ /* @__PURE__ */ jsx("p", {
1992
+ className: "eyebrow",
1993
+ children: "Report"
1994
+ }),
1995
+ /* @__PURE__ */ jsx("h2", {
1996
+ "data-report-title": true,
1997
+ children: report.title
1998
+ }),
1999
+ /* @__PURE__ */ jsxs("dl", { children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", { children: "Slug" }), /* @__PURE__ */ jsx("dd", { children: /* @__PURE__ */ jsx("code", { children: report.slug }) })] }), /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", { children: "Last refreshed" }), /* @__PURE__ */ jsx("dd", { children: /* @__PURE__ */ jsx("time", {
2000
+ dateTime: report.refreshed_at,
2001
+ "data-refreshed-at": true,
2002
+ children: new Date(report.refreshed_at).toLocaleString()
2003
+ }) })] })] }),
2004
+ /* @__PURE__ */ jsx("p", {
2005
+ className: "refresh-error",
2006
+ role: "alert",
2007
+ "data-refresh-error": true,
2008
+ hidden: !report.last_refresh_error,
2009
+ children: report.last_refresh_error
2010
+ })
2011
+ ]
2012
+ }), /* @__PURE__ */ jsx("div", {
2013
+ "data-saved-queries": true,
2014
+ children: /* @__PURE__ */ jsx(SavedQueries, { queries: report.queries })
2015
+ })]
2016
+ })]
2017
+ }),
2018
+ /* @__PURE__ */ jsx("script", {
2019
+ nonce,
2020
+ dangerouslySetInnerHTML: { __html: script }
2021
+ })
2022
+ ]
2023
+ })]
2024
+ }))}`;
2025
+ }
2026
+ function send(response, status, contentType, body) {
2027
+ response.writeHead(status, {
2028
+ "content-type": contentType,
2029
+ "content-length": Buffer.byteLength(body),
2030
+ "cache-control": "no-store"
2031
+ });
2032
+ response.end(body);
2033
+ }
2034
+ function sameToken(actual, expected) {
2035
+ if (!actual) return false;
2036
+ const left = Buffer.from(actual);
2037
+ const right = Buffer.from(expected);
2038
+ return left.length === right.length && timingSafeEqual(left, right);
2039
+ }
2040
+ function closeDatabase(database, action) {
2041
+ try {
2042
+ return action(database);
2043
+ } finally {
2044
+ database.close();
2045
+ }
2046
+ }
2047
+ async function launch(url) {
2048
+ const command = process.platform === "darwin" ? {
2049
+ executable: "open",
2050
+ args: [url]
2051
+ } : process.platform === "win32" ? {
2052
+ executable: "rundll32",
2053
+ args: ["url.dll,FileProtocolHandler", url]
2054
+ } : {
2055
+ executable: "xdg-open",
2056
+ args: [url]
2057
+ };
2058
+ await new Promise((resolve, reject) => {
2059
+ const child = spawn(command.executable, command.args, {
2060
+ detached: true,
2061
+ stdio: "ignore"
2062
+ });
2063
+ child.once("error", reject);
2064
+ child.once("spawn", () => {
2065
+ child.unref();
2066
+ resolve();
2067
+ });
2068
+ });
2069
+ }
2070
+ async function startReportViewer(workspace, slug, options = {}) {
2071
+ closeDatabase(SiloDatabase.open(workspace), (database) => database.getReport(slug));
2072
+ const token = randomBytes(32).toString("base64url");
2073
+ const nonce = randomBytes(24).toString("base64url");
2074
+ let origin = "";
2075
+ const reportPath = `/reports/${encodeURIComponent(slug)}`;
2076
+ const refreshPath = `/api/reports/${encodeURIComponent(slug)}/refresh`;
2077
+ const server = createServer((request, response) => {
2078
+ (async () => {
2079
+ const url = new URL(request.url ?? "/", origin);
2080
+ if (request.method === "GET" && url.pathname === "/report-viewer.css") {
2081
+ send(response, 200, "text/css; charset=utf-8", stylesheet);
2082
+ return;
2083
+ }
2084
+ if (request.method === "GET" && url.pathname === reportPath) {
2085
+ const html = reportDocument(closeDatabase(SiloDatabase.open(workspace), (database) => database.getReport(slug)), token, nonce);
2086
+ response.setHeader("content-security-policy", `default-src 'none'; style-src 'self'; script-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`);
2087
+ response.setHeader("x-content-type-options", "nosniff");
2088
+ response.setHeader("referrer-policy", "no-referrer");
2089
+ send(response, 200, "text/html; charset=utf-8", html);
2090
+ return;
2091
+ }
2092
+ if (request.method === "POST" && url.pathname === refreshPath) {
2093
+ if (request.headers.host !== new URL(origin).host || request.headers.origin !== origin || !sameToken(Array.isArray(request.headers["x-silo-token"]) ? request.headers["x-silo-token"][0] : request.headers["x-silo-token"], token)) {
2094
+ send(response, 403, "application/json; charset=utf-8", JSON.stringify({ error: { message: "Refresh request rejected." } }));
2095
+ return;
2096
+ }
2097
+ try {
2098
+ const report = closeDatabase(SiloDatabase.open(workspace, true), (database) => database.refreshReport(slug));
2099
+ send(response, 200, "application/json; charset=utf-8", JSON.stringify({
2100
+ html: renderReportHtml(report.rendered_markdown),
2101
+ title: report.title,
2102
+ queries_html: renderSavedQueries(report.queries),
2103
+ refreshed_at: report.refreshed_at
2104
+ }));
2105
+ } catch (error) {
2106
+ const silo = error instanceof SiloError ? error : new SiloError(exits.io, "unexpected_error", error instanceof Error ? error.message : String(error));
2107
+ send(response, silo.exitCode === exits.notFound ? 404 : silo.exitCode === exits.input ? 400 : 500, "application/json; charset=utf-8", JSON.stringify({ error: {
2108
+ code: silo.code,
2109
+ message: silo.message
2110
+ } }));
2111
+ }
2112
+ return;
2113
+ }
2114
+ send(response, 404, "text/plain; charset=utf-8", "Not found.\n");
2115
+ })().catch((error) => {
2116
+ if (!response.headersSent) send(response, 500, "application/json; charset=utf-8", JSON.stringify({ error: { message: error instanceof Error ? error.message : String(error) } }));
2117
+ else response.destroy(error instanceof Error ? error : void 0);
2118
+ });
2119
+ });
2120
+ await new Promise((resolve, reject) => {
2121
+ server.once("error", reject);
2122
+ server.listen(0, "127.0.0.1", () => {
2123
+ server.off("error", reject);
2124
+ resolve();
2125
+ });
2126
+ });
2127
+ const address = server.address();
2128
+ if (!address || typeof address === "string") {
2129
+ server.close();
2130
+ throw new SiloError(exits.io, "viewer_address_unavailable", "Could not resolve the viewer address.");
2131
+ }
2132
+ origin = `http://127.0.0.1:${address.port}`;
2133
+ const url = `${origin}${reportPath}`;
2134
+ if (options.launchBrowser !== false) try {
2135
+ await launch(url);
2136
+ } catch (error) {
2137
+ server.close();
2138
+ throw new SiloError(exits.io, "browser_open_failed", error instanceof Error ? error.message : String(error));
2139
+ }
2140
+ return {
2141
+ server,
2142
+ url,
2143
+ token,
2144
+ close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
2145
+ };
2146
+ }
2147
+ //#endregion
2148
+ //#region src/sync.ts
2149
+ const exec = promisify(execFile);
2150
+ function parseRemoteUrl(value) {
2151
+ let url;
2152
+ try {
2153
+ url = new URL(value);
2154
+ } catch {
2155
+ throw new SiloError(exits.input, "invalid_sync_remote", "Expected an s3://bucket/prefix URL.");
2156
+ }
2157
+ const prefix = url.pathname.replace(/^\/+|\/+$/g, "");
2158
+ if (url.protocol !== "s3:" || !url.hostname || !prefix || url.search || url.hash) throw new SiloError(exits.input, "invalid_sync_remote", "Expected an s3://bucket/prefix URL.");
2159
+ return {
2160
+ bucket: url.hostname,
2161
+ prefix
2162
+ };
2163
+ }
2164
+ function parseManifest(value) {
2165
+ try {
2166
+ const manifest = JSON.parse(value);
2167
+ if (manifest.format_version !== 1 || typeof manifest.database_id !== "string" || typeof manifest.identity !== "string" || typeof manifest.generation !== "string" || typeof manifest.publication_id !== "string" || manifest.parent_generation !== null && typeof manifest.parent_generation !== "string" || !Number.isSafeInteger(manifest.schema_revision) || typeof manifest.database_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(manifest.database_sha256) || typeof manifest.created_at !== "string") throw new Error("required fields are missing or invalid");
2168
+ return manifest;
2169
+ } catch (error) {
2170
+ throw new SiloError(exits.integrity, "sync_manifest_invalid", `Remote HEAD is invalid: ${error instanceof Error ? error.message : String(error)}`);
2171
+ }
2172
+ }
2173
+ var S3SyncRemote = class {
2174
+ url;
2175
+ bucket;
2176
+ prefix;
2177
+ client;
2178
+ constructor(url, client) {
2179
+ this.url = url;
2180
+ const parsed = parseRemoteUrl(url);
2181
+ this.bucket = parsed.bucket;
2182
+ this.prefix = parsed.prefix;
2183
+ const endpoint = process.env.AWS_ENDPOINT_URL_S3;
2184
+ this.client = client ?? new S3Client({
2185
+ region: process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1",
2186
+ ...endpoint ? {
2187
+ endpoint,
2188
+ forcePathStyle: true
2189
+ } : {}
2190
+ });
2191
+ }
2192
+ get headKey() {
2193
+ return `${this.prefix}/HEAD`;
2194
+ }
2195
+ async readHead() {
2196
+ try {
2197
+ const response = await this.client.send(new GetObjectCommand({
2198
+ Bucket: this.bucket,
2199
+ Key: this.headKey
2200
+ }));
2201
+ if (!response.Body || !response.ETag) throw new SiloError(exits.integrity, "sync_manifest_incomplete", "Remote HEAD has no body or entity tag.");
2202
+ return {
2203
+ manifest: parseManifest(await response.Body.transformToString()),
2204
+ etag: response.ETag
2205
+ };
2206
+ } catch (error) {
2207
+ if (error.$metadata?.httpStatusCode === 404 || error.name === "NoSuchKey") return void 0;
2208
+ if (error instanceof SiloError) throw error;
2209
+ throw new SiloError(exits.io, "sync_remote_read_failed", error instanceof Error ? error.message : String(error));
2210
+ }
2211
+ }
2212
+ async publishHead(manifest, expectedEtag) {
2213
+ const input = {
2214
+ Bucket: this.bucket,
2215
+ Key: this.headKey,
2216
+ Body: `${JSON.stringify(manifest)}\n`,
2217
+ ContentType: "application/json",
2218
+ ...expectedEtag === null ? { IfNoneMatch: "*" } : { IfMatch: expectedEtag }
2219
+ };
2220
+ try {
2221
+ const response = await this.client.send(new PutObjectCommand(input));
2222
+ if (!response.ETag) throw new SiloError(exits.io, "sync_head_etag_missing", "Object storage did not return an entity tag for HEAD.");
2223
+ return response.ETag;
2224
+ } catch (error) {
2225
+ const status = error.$metadata?.httpStatusCode;
2226
+ if (status === 409 || status === 412) throw new SiloError(exits.revision, "sync_head_changed", "Remote HEAD changed during publication; pull and retry.");
2227
+ if (error instanceof SiloError) throw error;
2228
+ throw new SiloError(exits.io, "sync_remote_write_failed", error instanceof Error ? error.message : String(error));
2229
+ }
2230
+ }
2231
+ generationUrl(generation) {
2232
+ return `s3://${this.bucket}/${this.prefix}/generations/${generation}`;
2233
+ }
2234
+ };
2235
+ var LitestreamCheckpoint = class {
2236
+ executable;
2237
+ constructor(executable = process.env.LITESTREAM_PATH ?? "litestream") {
2238
+ this.executable = executable;
2239
+ }
2240
+ async check() {
2241
+ try {
2242
+ const { stdout, stderr } = await exec(this.executable, ["version"]);
2243
+ const output = `${stdout} ${stderr}`.trim();
2244
+ const version = /v?(\d+)\.(\d+)\.(\d+)/.exec(output);
2245
+ const parts = version?.slice(1).map(Number);
2246
+ if (!(parts && (parts[0] > 0 || parts[1] > 5 || parts[1] === 5 && parts[2] >= 12))) throw new SiloError(exits.io, "litestream_incompatible", `Litestream 0.5.12 or newer is required; found ${output || "an unknown version"}.`);
2247
+ return version[0];
2248
+ } catch (error) {
2249
+ if (error instanceof SiloError) throw error;
2250
+ throw new SiloError(exits.io, "litestream_unavailable", "Litestream 0.5.12 or newer is required on PATH or through LITESTREAM_PATH.");
2251
+ }
2252
+ }
2253
+ async publish(databasePath, replicaUrl) {
2254
+ await this.check();
2255
+ await new Promise((resolve, reject) => {
2256
+ const child = spawn(this.executable, [
2257
+ "replicate",
2258
+ databasePath,
2259
+ replicaUrl
2260
+ ], { stdio: [
2261
+ "ignore",
2262
+ "ignore",
2263
+ "pipe"
2264
+ ] });
2265
+ let stderr = "";
2266
+ child.stderr.on("data", (chunk) => {
2267
+ stderr = `${stderr}${String(chunk)}`.slice(-16384);
2268
+ });
2269
+ let requestedStop = false;
2270
+ const stop = setTimeout(() => {
2271
+ requestedStop = true;
2272
+ child.kill("SIGINT");
2273
+ }, 1500);
2274
+ child.once("error", (error) => {
2275
+ clearTimeout(stop);
2276
+ reject(error);
2277
+ });
2278
+ child.once("close", (code, signal) => {
2279
+ clearTimeout(stop);
2280
+ if (code === 0 || requestedStop && signal === "SIGINT") resolve();
2281
+ else reject(new SiloError(exits.io, "litestream_publish_failed", stderr.trim() || `Litestream exited with status ${code ?? signal}.`));
2282
+ });
2283
+ });
2284
+ }
2285
+ async restore(replicaUrl, outputPath) {
2286
+ await this.check();
2287
+ removeDatabase(outputPath);
2288
+ try {
2289
+ await exec(this.executable, [
2290
+ "restore",
2291
+ "-json",
2292
+ "-o",
2293
+ outputPath,
2294
+ replicaUrl
2295
+ ]);
2296
+ } catch (error) {
2297
+ throw new SiloError(exits.io, "litestream_restore_failed", error instanceof Error ? error.message : String(error));
2298
+ }
2299
+ }
2300
+ };
2301
+ const defaultServices = {
2302
+ remote: (url) => new S3SyncRemote(url),
2303
+ checkpoint: new LitestreamCheckpoint()
2304
+ };
2305
+ function temporaryPath(workspace, label) {
2306
+ return `${workspace.databasePath}.${label}.${randomUUID()}.sqlite`;
2307
+ }
2308
+ function removeDatabase(path) {
2309
+ for (const suffix of [
2310
+ "",
2311
+ "-wal",
2312
+ "-shm",
2313
+ "-journal",
2314
+ "-txid"
2315
+ ]) rmSync(`${path}${suffix}`, { force: true });
2316
+ }
2317
+ function hashDatabase(path) {
2318
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
2319
+ }
2320
+ function validateHead(workspace, head, databaseId) {
2321
+ if (head.manifest.identity !== workspace.identity) throw new SiloError(exits.integrity, "sync_identity_mismatch", "Remote HEAD belongs to a different Git workspace identity.");
2322
+ if (databaseId && head.manifest.database_id !== databaseId) throw new SiloError(exits.integrity, "sync_database_mismatch", "Remote HEAD belongs to a different Silo database.");
2323
+ }
2324
+ function installDatabase(source, destination) {
2325
+ removeDatabase(`${destination}.previous`);
2326
+ for (const suffix of [
2327
+ "-wal",
2328
+ "-shm",
2329
+ "-journal"
2330
+ ]) rmSync(`${destination}${suffix}`, { force: true });
2331
+ try {
2332
+ renameSync(source, destination);
2333
+ } catch {
2334
+ const previous = `${destination}.previous`;
2335
+ if (existsSync(destination)) renameSync(destination, previous);
2336
+ try {
2337
+ renameSync(source, destination);
2338
+ removeDatabase(previous);
2339
+ } catch (error) {
2340
+ if (existsSync(previous) && !existsSync(destination)) renameSync(previous, destination);
2341
+ throw error;
2342
+ }
2343
+ }
2344
+ }
2345
+ async function withSyncLock(workspace, handler) {
2346
+ mkdirSync(dirname(workspace.databasePath), { recursive: true });
2347
+ const release = acquireFileLock(`${workspace.databasePath}.sync-lock`, "Another synchronization operation is running.");
2348
+ try {
2349
+ if (existsSync(`${workspace.databasePath}.write-lock`)) throw new SiloError(exits.io, "sync_in_progress", "Another writer is using this database.");
2350
+ return await handler();
2351
+ } finally {
2352
+ release();
2353
+ }
2354
+ }
2355
+ var SiloSync = class {
2356
+ workspace;
2357
+ services;
2358
+ constructor(workspace, services = defaultServices) {
2359
+ this.workspace = workspace;
2360
+ this.services = services;
2361
+ }
2362
+ async initialize(remoteUrl) {
2363
+ return withSyncLock(this.workspace, async () => {
2364
+ await this.services.checkpoint.check();
2365
+ const remote = this.services.remote(remoteUrl);
2366
+ const head = await remote.readHead();
2367
+ if (head) validateHead(this.workspace, head);
2368
+ if (!existsSync(this.workspace.databasePath)) {
2369
+ if (!head) throw new SiloError(exits.absent, "sync_database_absent", "Neither a local database nor a remote HEAD exists.");
2370
+ const restored = temporaryPath(this.workspace, "bootstrap");
2371
+ try {
2372
+ await this.restoreAndVerify(remote, head, restored);
2373
+ installDatabase(restored, this.workspace.databasePath);
2374
+ const database = SiloDatabase.open(this.workspace, true, true);
2375
+ try {
2376
+ database.markSynchronized(head.manifest.generation, head.etag);
2377
+ } finally {
2378
+ database.close();
2379
+ }
2380
+ } finally {
2381
+ removeDatabase(restored);
2382
+ }
2383
+ } else {
2384
+ const database = SiloDatabase.open(this.workspace, true, true);
2385
+ try {
2386
+ const state = database.getSyncState();
2387
+ if (head && !state) throw new SiloError(exits.revision, "sync_local_diverged", "A remote database already exists; initialize from a workspace without local state.");
2388
+ const configured = database.configureSync(remoteUrl, head?.manifest.database_id);
2389
+ if (head) validateHead(this.workspace, head, configured.database_id);
2390
+ } finally {
2391
+ database.close();
2392
+ }
2393
+ }
2394
+ return this.status();
2395
+ });
2396
+ }
2397
+ async status() {
2398
+ if (!existsSync(this.workspace.databasePath)) return {
2399
+ state: "unconfigured",
2400
+ remote_url: null,
2401
+ database_id: null,
2402
+ local_generation: null,
2403
+ remote_generation: null,
2404
+ pending_transactions: 0,
2405
+ conflict_transaction_id: null
2406
+ };
2407
+ const database = SiloDatabase.open(this.workspace);
2408
+ let state;
2409
+ let pending;
2410
+ try {
2411
+ state = database.getSyncState();
2412
+ pending = database.pendingTransactions();
2413
+ } finally {
2414
+ database.close();
2415
+ }
2416
+ if (!state) return {
2417
+ state: "unconfigured",
2418
+ remote_url: null,
2419
+ database_id: null,
2420
+ local_generation: null,
2421
+ remote_generation: null,
2422
+ pending_transactions: 0,
2423
+ conflict_transaction_id: null
2424
+ };
2425
+ const head = await this.services.remote(state.remote_url).readHead();
2426
+ if (head) validateHead(this.workspace, head, state.database_id);
2427
+ const remoteGeneration = head?.manifest.generation ?? null;
2428
+ let status;
2429
+ if (state.conflict_transaction_id) status = "conflicted";
2430
+ else if (!head) status = "ahead";
2431
+ else if (state.base_generation === remoteGeneration) status = pending.length ? "ahead" : "clean";
2432
+ else if (pending.length) status = "diverged";
2433
+ else status = "behind";
2434
+ return {
2435
+ state: status,
2436
+ remote_url: state.remote_url,
2437
+ database_id: state.database_id,
2438
+ local_generation: state.base_generation,
2439
+ remote_generation: remoteGeneration,
2440
+ pending_transactions: pending.length,
2441
+ conflict_transaction_id: state.conflict_transaction_id
2442
+ };
2443
+ }
2444
+ async pull(discardTransactionId) {
2445
+ return withSyncLock(this.workspace, async () => {
2446
+ await this.pullUnlocked(discardTransactionId);
2447
+ return this.status();
2448
+ });
2449
+ }
2450
+ async pullUnlocked(discardTransactionId) {
2451
+ await this.services.checkpoint.check();
2452
+ const local = SiloDatabase.open(this.workspace, true, true);
2453
+ let state;
2454
+ let pending;
2455
+ try {
2456
+ state = local.getSyncState();
2457
+ if (!state) throw new SiloError(exits.workspace, "sync_not_configured", "Synchronization is not configured.");
2458
+ pending = local.pendingTransactions();
2459
+ if (discardTransactionId && !pending.some((item) => item.transaction_id === discardTransactionId)) throw new SiloError(exits.notFound, "sync_transaction_not_found", `${discardTransactionId} is not a pending transaction.`);
2460
+ } finally {
2461
+ local.close();
2462
+ }
2463
+ const remote = this.services.remote(state.remote_url);
2464
+ const head = await remote.readHead();
2465
+ if (!head) throw new SiloError(exits.absent, "sync_remote_absent", "Remote HEAD does not exist.");
2466
+ validateHead(this.workspace, head, state.database_id);
2467
+ if (state.base_generation === head.manifest.generation && !discardTransactionId) return;
2468
+ const restored = temporaryPath(this.workspace, "pull");
2469
+ try {
2470
+ await this.restoreAndVerify(remote, head, restored);
2471
+ const rebased = SiloDatabase.open({
2472
+ ...this.workspace,
2473
+ databasePath: restored
2474
+ }, true, true);
2475
+ let conflict;
2476
+ try {
2477
+ conflict = rebased.rebasePending(pending, head.manifest.generation, head.etag, discardTransactionId);
2478
+ } finally {
2479
+ rebased.close();
2480
+ }
2481
+ if (conflict) {
2482
+ const original = SiloDatabase.open(this.workspace, true, true);
2483
+ try {
2484
+ original.setSyncConflict(conflict);
2485
+ } finally {
2486
+ original.close();
2487
+ }
2488
+ const operation = pending.find((item) => item.transaction_id === conflict)?.operation;
2489
+ const context = [
2490
+ String(operation?.command ?? "unknown operation"),
2491
+ operation?.table ? `table ${String(operation.table)}` : "",
2492
+ operation?.key !== void 0 ? `key ${JSON.stringify(operation.key)}` : ""
2493
+ ].filter(Boolean).join(", ");
2494
+ throw new SiloError(exits.revision, "sync_changeset_conflict", `Transaction ${conflict} (${context}) conflicts with remote generation ${head.manifest.generation}.`);
2495
+ }
2496
+ installDatabase(restored, this.workspace.databasePath);
2497
+ } finally {
2498
+ removeDatabase(restored);
2499
+ }
2500
+ }
2501
+ async push() {
2502
+ return withSyncLock(this.workspace, async () => {
2503
+ await this.services.checkpoint.check();
2504
+ let database = SiloDatabase.open(this.workspace, true, true);
2505
+ let state;
2506
+ let pendingCount;
2507
+ try {
2508
+ state = database.getSyncState();
2509
+ pendingCount = database.pendingTransactions().length;
2510
+ } finally {
2511
+ database.close();
2512
+ }
2513
+ if (!state) throw new SiloError(exits.workspace, "sync_not_configured", "Synchronization is not configured.");
2514
+ const remote = this.services.remote(state.remote_url);
2515
+ let head = await remote.readHead();
2516
+ if (head) validateHead(this.workspace, head, state.database_id);
2517
+ if (!head && state.base_generation) throw new SiloError(exits.integrity, "sync_remote_head_missing", "Remote HEAD disappeared after this database was synchronized.");
2518
+ if (head && state.base_generation === head.manifest.generation && !pendingCount) return this.status();
2519
+ if (head && state.base_generation !== head.manifest.generation) {
2520
+ await this.pullUnlocked();
2521
+ const refreshed = SiloDatabase.open(this.workspace);
2522
+ try {
2523
+ state = refreshed.getSyncState();
2524
+ } finally {
2525
+ refreshed.close();
2526
+ }
2527
+ head = await remote.readHead();
2528
+ if (head) validateHead(this.workspace, head, state.database_id);
2529
+ }
2530
+ database = SiloDatabase.open(this.workspace, true, true);
2531
+ const publicationId = randomUUID();
2532
+ const generation = randomUUID();
2533
+ const candidate = temporaryPath(this.workspace, "publish");
2534
+ const verified = temporaryPath(this.workspace, "verify");
2535
+ try {
2536
+ await database.backupCanonical(candidate, generation);
2537
+ const manifest = {
2538
+ format_version: 1,
2539
+ database_id: state.database_id,
2540
+ identity: this.workspace.identity,
2541
+ generation,
2542
+ publication_id: publicationId,
2543
+ parent_generation: head?.manifest.generation ?? null,
2544
+ schema_revision: database.getSchema().revision,
2545
+ database_sha256: hashDatabase(candidate),
2546
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
2547
+ };
2548
+ database.close();
2549
+ await this.services.checkpoint.publish(candidate, remote.generationUrl(generation));
2550
+ await this.services.checkpoint.restore(remote.generationUrl(generation), verified);
2551
+ if (hashDatabase(verified) !== manifest.database_sha256) throw new SiloError(exits.integrity, "sync_checkpoint_hash_mismatch", "The restored checkpoint does not match the published database.");
2552
+ const verification = SiloDatabase.open({
2553
+ ...this.workspace,
2554
+ databasePath: verified
2555
+ }, false, true);
2556
+ try {
2557
+ if (verification.getSchema().revision !== manifest.schema_revision) throw new SiloError(exits.integrity, "sync_checkpoint_schema_mismatch", "The published checkpoint schema does not match its manifest.");
2558
+ const integrity = verification.query("PRAGMA integrity_check");
2559
+ if (integrity.rows.length !== 1 || integrity.rows[0]?.[0] !== "ok") throw new SiloError(exits.integrity, "integrity_check_failed", "The published checkpoint failed SQLite integrity checking.");
2560
+ } finally {
2561
+ verification.close();
2562
+ }
2563
+ let etag;
2564
+ try {
2565
+ etag = await remote.publishHead(manifest, head?.etag ?? null);
2566
+ } catch (error) {
2567
+ if (!(error instanceof SiloError) || error.code !== "sync_head_changed") throw error;
2568
+ const current = await remote.readHead();
2569
+ if (!current || current.manifest.publication_id !== publicationId) throw error;
2570
+ etag = current.etag;
2571
+ }
2572
+ const updated = SiloDatabase.open(this.workspace, true, true);
2573
+ try {
2574
+ updated.markSynchronized(generation, etag);
2575
+ } finally {
2576
+ updated.close();
2577
+ }
2578
+ } finally {
2579
+ try {
2580
+ database.close();
2581
+ } catch {}
2582
+ removeDatabase(candidate);
2583
+ removeDatabase(verified);
2584
+ }
2585
+ return this.status();
2586
+ });
2587
+ }
2588
+ async restoreAndVerify(remote, head, outputPath) {
2589
+ await this.services.checkpoint.restore(remote.generationUrl(head.manifest.generation), outputPath);
2590
+ if (hashDatabase(outputPath) !== head.manifest.database_sha256) throw new SiloError(exits.integrity, "sync_checkpoint_hash_mismatch", "The restored checkpoint does not match remote HEAD.");
2591
+ const database = SiloDatabase.open({
2592
+ ...this.workspace,
2593
+ databasePath: outputPath
2594
+ });
2595
+ try {
2596
+ const state = database.getSyncState();
2597
+ if (!state || state.database_id !== head.manifest.database_id) throw new SiloError(exits.integrity, "sync_checkpoint_identity_mismatch", "The restored checkpoint does not match remote HEAD.");
2598
+ if (database.getSchema().revision !== head.manifest.schema_revision) throw new SiloError(exits.integrity, "sync_checkpoint_schema_mismatch", "The restored checkpoint schema does not match remote HEAD.");
2599
+ const integrity = database.query("PRAGMA integrity_check");
2600
+ if (integrity.rows.length !== 1 || integrity.rows[0]?.[0] !== "ok") throw new SiloError(exits.integrity, "integrity_check_failed", "The restored checkpoint failed SQLite integrity checking.");
2601
+ } finally {
2602
+ database.close();
2603
+ }
2604
+ }
2605
+ };
2606
+ //#endregion
2607
+ //#region src/cli.ts
2608
+ const inputFile = option({
2609
+ type: optional(File),
2610
+ long: "file",
2611
+ short: "f",
2612
+ description: "Read the JSON request from this file instead of stdin."
2613
+ });
2614
+ function readInput(file) {
2615
+ const source = file ? readFileSync(file, "utf8") : readFileSync(0, "utf8");
2616
+ if (!source.trim()) throw new SiloError(exits.input, "empty_input", "Expected a JSON request on stdin or through --file.");
2617
+ try {
2618
+ return JSON.parse(source);
2619
+ } catch (error) {
2620
+ throw new SiloError(exits.input, "invalid_json", error instanceof Error ? error.message : String(error));
2621
+ }
2622
+ }
2623
+ function output(value) {
2624
+ process.stdout.write(value.endsWith("\n") ? value : `${value}\n`);
2625
+ }
2626
+ function renderSchema(schema) {
2627
+ const summary = table(["Property", "Value"], [
2628
+ ["Format", schema.format_version],
2629
+ ["Registry", schema.registry_version],
2630
+ ["Revision", schema.revision],
2631
+ ["Templates", schema.template_imports?.map((item) => item.name).join(", ") ?? null]
2632
+ ]);
2633
+ const tables = schema.tables.length ? table([
2634
+ "Table",
2635
+ "Comment",
2636
+ "Columns"
2637
+ ], schema.tables.map((item) => [
2638
+ item.name,
2639
+ item.comment,
2640
+ item.columns.length
2641
+ ])) : "_No tables._";
2642
+ return `${summary}\n\n## Agent instructions\n\n${schema.agent_instructions?.length ? schema.agent_instructions.map((item) => `### ${item.source}\n\n${item.content}`).join("\n\n") : "_No agent instructions._"}\n\n## Tables\n\n${tables}`;
2643
+ }
2644
+ function renderTable(definition) {
2645
+ const properties = table(["Property", "Value"], [
2646
+ ["Strict", definition.strict !== false],
2647
+ ["Without rowid", definition.without_rowid ?? false],
2648
+ ["Primary key", definition.primary_key?.join(", ") ?? null]
2649
+ ]);
2650
+ const columns = table([
2651
+ "Column",
2652
+ "Semantic type",
2653
+ "Physical type",
2654
+ "Nullable",
2655
+ "Default",
2656
+ "Comment"
2657
+ ], definition.columns.map((column) => [
2658
+ column.name,
2659
+ column.type,
2660
+ column.type.split("/")[0].toUpperCase(),
2661
+ column.nullable !== false,
2662
+ column.default ? JSON.stringify(column.default) : null,
2663
+ column.comment
2664
+ ]));
2665
+ const policies = definition.policies?.length ? table([
2666
+ "Policy",
2667
+ "Enforcement",
2668
+ "Parameters"
2669
+ ], definition.policies.map((item) => [
2670
+ item.type,
2671
+ [
2672
+ "immutable_rows",
2673
+ "immutable_columns",
2674
+ "append_only",
2675
+ "timestamps"
2676
+ ].includes(item.type) ? "trigger + cli" : "cli",
2677
+ JSON.stringify(Object.fromEntries(Object.entries(item).filter(([key]) => key !== "type")))
2678
+ ])) : "_No policies._";
2679
+ const foreignKeys = definition.foreign_keys?.length ? table([
2680
+ "Columns",
2681
+ "References",
2682
+ "On update",
2683
+ "On delete"
2684
+ ], definition.foreign_keys.map((item) => [
2685
+ item.columns.join(", "),
2686
+ `${item.references.table}(${item.references.columns.join(", ")})`,
2687
+ item.on_update ?? "NO ACTION",
2688
+ item.on_delete ?? "NO ACTION"
2689
+ ])) : "_No foreign keys._";
2690
+ const indexes = definition.indexes?.length ? table([
2691
+ "Name",
2692
+ "Unique",
2693
+ "Parts",
2694
+ "Where"
2695
+ ], definition.indexes.map((item) => [
2696
+ item.name ?? null,
2697
+ item.unique ?? false,
2698
+ item.columns.map((part) => part.column ?? part.expression).join(", "),
2699
+ item.where ?? null
2700
+ ])) : "_No explicit indexes._";
2701
+ const checks = definition.checks?.length ? table([
2702
+ "Name",
2703
+ "Expression",
2704
+ "Comment"
2705
+ ], definition.checks.map((item) => [
2706
+ item.name ?? null,
2707
+ item.expression,
2708
+ item.comment ?? null
2709
+ ])) : "_No table checks._";
2710
+ return `${definition.comment}\n\n${properties}\n\n## Columns\n\n${columns}\n\n## Foreign Keys\n\n${foreignKeys}\n\n## Indexes\n\n${indexes}\n\n## Checks\n\n${checks}\n\n## Policies\n\n${policies}`;
2711
+ }
2712
+ function withErrors(handler) {
2713
+ return async (args) => {
2714
+ try {
2715
+ await handler(args);
2716
+ } catch (error) {
2717
+ const silo = error instanceof SiloError ? error : new SiloError(exits.io, "unexpected_error", error instanceof Error ? error.message : String(error));
2718
+ process.stderr.write(heading("Error", errorMarkdown(silo)));
2719
+ process.exitCode = silo.exitCode;
2720
+ }
2721
+ };
2722
+ }
2723
+ async function useDatabase(database, handler) {
2724
+ try {
2725
+ return await handler(database);
2726
+ } finally {
2727
+ database.close();
2728
+ }
2729
+ }
2730
+ const status = command({
2731
+ name: "status",
2732
+ description: "Resolve the Git workspace and report its Silo database state.",
2733
+ args: {},
2734
+ handler: withErrors(async () => {
2735
+ const workspace = resolveWorkspace();
2736
+ let state = "absent";
2737
+ let revision = null;
2738
+ try {
2739
+ await useDatabase(SiloDatabase.open(workspace), (database) => {
2740
+ state = "recognized";
2741
+ revision = database.getSchema().revision;
2742
+ });
2743
+ } catch (error) {
2744
+ if (!(error instanceof SiloError) || error.exitCode !== exits.absent) throw error;
2745
+ }
2746
+ output(heading("Silo Status", table(["Property", "Value"], [
2747
+ ["Workspace", workspace.root],
2748
+ ["Identity", workspace.identity],
2749
+ ["Database", workspace.databasePath],
2750
+ ["State", state],
2751
+ ["Schema revision", revision],
2752
+ ["SQLite", sqliteVersion()]
2753
+ ])));
2754
+ })
2755
+ });
2756
+ const databaseList = command({
2757
+ name: "list",
2758
+ description: "Discover Silo databases in the application-data catalog.",
2759
+ args: {},
2760
+ handler: withErrors(async () => {
2761
+ const entries = discoverDatabases();
2762
+ output(heading("Databases", entries.length ? table([
2763
+ "Identity",
2764
+ "State",
2765
+ "Path",
2766
+ "Message"
2767
+ ], entries.map((entry) => [
2768
+ entry.identity ?? null,
2769
+ entry.state,
2770
+ entry.path,
2771
+ entry.message ?? null
2772
+ ])) : "_No databases._"));
2773
+ })
2774
+ });
2775
+ const templateList = command({
2776
+ name: "list",
2777
+ description: "List globally installed JSON schema templates.",
2778
+ args: {},
2779
+ handler: withErrors(async () => {
2780
+ const names = listTemplates();
2781
+ output(heading("Templates", names.length ? names.map((name) => `- \`${name}\``).join("\n") : "_No templates._"));
2782
+ })
2783
+ });
2784
+ const templateShow = command({
2785
+ name: "show",
2786
+ description: "Validate and show a schema template.",
2787
+ args: { name: positional({
2788
+ type: string,
2789
+ displayName: "name",
2790
+ description: "Template filename without .json."
2791
+ }) },
2792
+ handler: withErrors(async ({ name }) => output(heading(`Template: ${name}`, `\`\`\`json\n${JSON.stringify(readTemplate(name), null, 2)}\n\`\`\``)))
2793
+ });
2794
+ const schemaShow = command({
2795
+ name: "show",
2796
+ description: "Show the authoritative logical schema.",
2797
+ args: {},
2798
+ handler: withErrors(async () => {
2799
+ await useDatabase(SiloDatabase.open(resolveWorkspace()), (database) => {
2800
+ const meta = database.getMetadata();
2801
+ output(heading("Schema", `${table(["Property", "Value"], [
2802
+ ["Identity", meta.identity],
2803
+ ["Database", database.workspace.databasePath],
2804
+ ["Metadata format", meta.format_version],
2805
+ ["Tool version", meta.tool_version]
2806
+ ])}\n\n${renderSchema(database.getSchema())}`));
2807
+ });
2808
+ })
2809
+ });
2810
+ const schemaExport = command({
2811
+ name: "export",
2812
+ description: "Export the canonical logical schema as fenced JSON.",
2813
+ args: {},
2814
+ handler: withErrors(async () => {
2815
+ await useDatabase(SiloDatabase.open(resolveWorkspace()), (database) => output(heading("Schema Export", `\`\`\`json\n${JSON.stringify(database.getSchema(), null, 2)}\n\`\`\``)));
2816
+ })
2817
+ });
2818
+ const schemaDdl = command({
2819
+ name: "ddl",
2820
+ description: "Show diagnostic compiled SQLite DDL. Logical metadata remains authoritative.",
2821
+ args: {},
2822
+ handler: withErrors(async () => {
2823
+ await useDatabase(SiloDatabase.open(resolveWorkspace()), (database) => output(heading("Compiled SQLite DDL", `\`\`\`sql\n${database.ddl()}\n\`\`\``)));
2824
+ })
2825
+ });
2826
+ const schemaImport = command({
2827
+ name: "import",
2828
+ description: "Import a validated template into this workspace schema.",
2829
+ examples: [{
2830
+ description: "Import the tasks template",
2831
+ command: "silo schema import tasks"
2832
+ }],
2833
+ args: { template: positional({
2834
+ type: string,
2835
+ displayName: "template"
2836
+ }) },
2837
+ handler: withErrors(async ({ template }) => {
2838
+ const source = readTemplate(template);
2839
+ const workspace = resolveWorkspace();
2840
+ let database;
2841
+ let schema;
2842
+ try {
2843
+ try {
2844
+ database = SiloDatabase.open(workspace, true);
2845
+ schema = database.importTemplate(template, source);
2846
+ } catch (error) {
2847
+ if (!(error instanceof SiloError) || error.code !== "database_absent") throw error;
2848
+ schema = schemaFromTemplate(template, source);
2849
+ database = SiloDatabase.createWithSchema(workspace, schema);
2850
+ }
2851
+ output(heading("Schema Template Imported", table([
2852
+ "Template",
2853
+ "Tables",
2854
+ "Revision"
2855
+ ], [[
2856
+ template,
2857
+ schema.tables.length,
2858
+ schema.revision
2859
+ ]])));
2860
+ } finally {
2861
+ database?.close();
2862
+ }
2863
+ })
2864
+ });
2865
+ const tableList = command({
2866
+ name: "list",
2867
+ description: "List tables from authoritative logical metadata.",
2868
+ args: {},
2869
+ handler: withErrors(async () => {
2870
+ await useDatabase(SiloDatabase.open(resolveWorkspace()), (database) => {
2871
+ const tables = database.getSchema().tables;
2872
+ output(heading("Tables", tables.length ? table(["Table", "Comment"], tables.map((item) => [item.name, item.comment])) : "_No tables._"));
2873
+ });
2874
+ })
2875
+ });
2876
+ const tableShow = command({
2877
+ name: "show",
2878
+ description: "Show a table's semantic types and policy enforcement.",
2879
+ args: { table: positional({
2880
+ type: string,
2881
+ displayName: "table"
2882
+ }) },
2883
+ handler: withErrors(async ({ table }) => {
2884
+ await useDatabase(SiloDatabase.open(resolveWorkspace()), (database) => output(heading(`Table: ${table}`, renderTable(database.table(table)))));
2885
+ })
2886
+ });
2887
+ const tableCreate = command({
2888
+ name: "create",
2889
+ description: "Create a STRICT table from JSON, creating the workspace database on first use.",
2890
+ examples: [{
2891
+ description: "Read a request from stdin",
2892
+ command: "silo table create < table.json"
2893
+ }],
2894
+ args: { file: inputFile },
2895
+ handler: withErrors(async ({ file }) => {
2896
+ const workspace = resolveWorkspace();
2897
+ const input = readInput(file);
2898
+ let database;
2899
+ let definition;
2900
+ try {
2901
+ try {
2902
+ database = SiloDatabase.open(workspace, true);
2903
+ } catch (error) {
2904
+ if (!(error instanceof SiloError) || error.exitCode !== exits.absent) throw error;
2905
+ definition = parseTable(input);
2906
+ database = SiloDatabase.createWithSchema(workspace, {
2907
+ ...emptySchema(),
2908
+ tables: [definition]
2909
+ });
2910
+ }
2911
+ definition ??= database.createTable(input);
2912
+ output(heading("Table Created", table(["Table", "Columns"], [[definition.name, definition.columns.length]])));
2913
+ } finally {
2914
+ database?.close();
2915
+ }
2916
+ })
2917
+ });
2918
+ const tableAlter = command({
2919
+ name: "alter",
2920
+ description: "Add nullable/defaulted columns or indexes from JSON.",
2921
+ args: {
2922
+ table: positional({
2923
+ type: string,
2924
+ displayName: "table"
2925
+ }),
2926
+ file: inputFile
2927
+ },
2928
+ handler: withErrors(async ({ table: table$1, file }) => {
2929
+ await useDatabase(SiloDatabase.open(resolveWorkspace(), true), (database) => {
2930
+ const result = database.alterTable(table$1, readInput(file));
2931
+ output(heading("Table Altered", table([
2932
+ "Table",
2933
+ "Columns",
2934
+ "Indexes"
2935
+ ], [[
2936
+ result.name,
2937
+ result.columns.length,
2938
+ result.indexes?.length ?? 0
2939
+ ]])));
2940
+ });
2941
+ })
2942
+ });
2943
+ const tableDrop = command({
2944
+ name: "drop",
2945
+ description: "Permanently drop a table; the explicit command is destructive intent.",
2946
+ args: { table: positional({
2947
+ type: string,
2948
+ displayName: "table"
2949
+ }) },
2950
+ handler: withErrors(async ({ table }) => {
2951
+ await useDatabase(SiloDatabase.open(resolveWorkspace(), true), (database) => {
2952
+ database.dropTable(table);
2953
+ output(heading("Table Dropped", `\`${table}\` was dropped.`));
2954
+ });
2955
+ })
2956
+ });
2957
+ const rowAdd = command({
2958
+ name: "add",
2959
+ description: "Atomically insert one JSON object or an array of objects.",
2960
+ args: {
2961
+ table: positional({
2962
+ type: string,
2963
+ displayName: "table"
2964
+ }),
2965
+ file: inputFile
2966
+ },
2967
+ handler: withErrors(async ({ table: table$2, file }) => {
2968
+ await useDatabase(SiloDatabase.open(resolveWorkspace(), true), (database) => {
2969
+ const rows = database.addRows(table$2, readInput(file));
2970
+ output(heading("Rows Added", table(Object.keys(rows[0]), rows.map(Object.values))));
2971
+ });
2972
+ })
2973
+ });
2974
+ const rowUpsert = command({
2975
+ name: "upsert",
2976
+ description: "Insert or update through the declared natural-key policy.",
2977
+ args: {
2978
+ table: positional({
2979
+ type: string,
2980
+ displayName: "table"
2981
+ }),
2982
+ file: inputFile
2983
+ },
2984
+ handler: withErrors(async ({ table: table$3, file }) => {
2985
+ await useDatabase(SiloDatabase.open(resolveWorkspace(), true), (database) => {
2986
+ const rows = database.addRows(table$3, readInput(file), true);
2987
+ output(heading("Rows Upserted", table(Object.keys(rows[0]), rows.map(Object.values))));
2988
+ });
2989
+ })
2990
+ });
2991
+ const rowGet = command({
2992
+ name: "get",
2993
+ description: "Get a row by a schema-decoded key; composite keys use a JSON array.",
2994
+ args: {
2995
+ table: positional({
2996
+ type: string,
2997
+ displayName: "table"
2998
+ }),
2999
+ key: positional({
3000
+ type: string,
3001
+ displayName: "key"
3002
+ })
3003
+ },
3004
+ handler: withErrors(async ({ table: table$4, key: value }) => {
3005
+ await useDatabase(SiloDatabase.open(resolveWorkspace()), (database) => {
3006
+ const row = database.getRow(table$4, value);
3007
+ output(heading("Row", table(Object.keys(row), [Object.values(row)])));
3008
+ });
3009
+ })
3010
+ });
3011
+ const rowList = command({
3012
+ name: "list",
3013
+ description: "List rows deterministically by primary key or rowid.",
3014
+ args: {
3015
+ table: positional({
3016
+ type: string,
3017
+ displayName: "table"
3018
+ }),
3019
+ limit: option({
3020
+ type: number,
3021
+ long: "limit",
3022
+ defaultValue: () => 100,
3023
+ description: "Maximum rows."
3024
+ }),
3025
+ offset: option({
3026
+ type: number,
3027
+ long: "offset",
3028
+ defaultValue: () => 0,
3029
+ description: "Rows to skip."
3030
+ })
3031
+ },
3032
+ handler: withErrors(async ({ table: table$5, limit, offset }) => {
3033
+ if (!Number.isSafeInteger(limit) || limit < 0 || !Number.isSafeInteger(offset) || offset < 0) throw new SiloError(exits.input, "invalid_pagination", "limit and offset must be nonnegative integers.");
3034
+ await useDatabase(SiloDatabase.open(resolveWorkspace()), (database) => {
3035
+ const rows = database.listRows(table$5, limit, offset);
3036
+ output(heading("Rows", rows.length ? table(Object.keys(rows[0]), rows.map(Object.values)) : "_No rows._"));
3037
+ });
3038
+ })
3039
+ });
3040
+ const rowUpdate = command({
3041
+ name: "update",
3042
+ description: "Update by key; revisioned tables require _expected_revision.",
3043
+ args: {
3044
+ table: positional({
3045
+ type: string,
3046
+ displayName: "table"
3047
+ }),
3048
+ key: positional({
3049
+ type: string,
3050
+ displayName: "key"
3051
+ }),
3052
+ file: inputFile
3053
+ },
3054
+ handler: withErrors(async ({ table: table$6, key: value, file }) => {
3055
+ await useDatabase(SiloDatabase.open(resolveWorkspace(), true), (database) => {
3056
+ output(heading("Row Updated", table(["Changes"], [[database.updateRow(table$6, value, readInput(file))]])));
3057
+ });
3058
+ })
3059
+ });
3060
+ const rowDelete = command({
3061
+ name: "delete",
3062
+ description: "Permanently delete a row by key.",
3063
+ args: {
3064
+ table: positional({
3065
+ type: string,
3066
+ displayName: "table"
3067
+ }),
3068
+ key: positional({
3069
+ type: string,
3070
+ displayName: "key"
3071
+ })
3072
+ },
3073
+ handler: withErrors(async ({ table: table$7, key: value }) => {
3074
+ await useDatabase(SiloDatabase.open(resolveWorkspace(), true), (database) => {
3075
+ output(heading("Row Deleted", table(["Changes"], [[database.deleteRow(table$7, value)]])));
3076
+ });
3077
+ })
3078
+ });
3079
+ const reportPut = command({
3080
+ name: "put",
3081
+ description: "Create or atomically replace and refresh a Markdown report from JSON.",
3082
+ examples: [{
3083
+ description: "Read a report definition",
3084
+ command: "silo report put < report.json"
3085
+ }],
3086
+ args: { file: inputFile },
3087
+ handler: withErrors(async ({ file }) => {
3088
+ await useDatabase(SiloDatabase.open(resolveWorkspace(), true), (database) => {
3089
+ const report = database.putReport(readInput(file));
3090
+ output(heading("Report Saved", table([
3091
+ "Slug",
3092
+ "Title",
3093
+ "Queries",
3094
+ "Refreshed"
3095
+ ], [[
3096
+ report.slug,
3097
+ report.title,
3098
+ report.queries.length,
3099
+ report.refreshed_at
3100
+ ]])));
3101
+ });
3102
+ })
3103
+ });
3104
+ const reportList = command({
3105
+ name: "list",
3106
+ description: "List saved Markdown reports and their refresh state.",
3107
+ args: {},
3108
+ handler: withErrors(async () => {
3109
+ await useDatabase(SiloDatabase.open(resolveWorkspace()), (database) => {
3110
+ const reports = database.listReports();
3111
+ output(heading("Reports", reports.length ? table([
3112
+ "Slug",
3113
+ "Title",
3114
+ "Refreshed",
3115
+ "Last refresh error"
3116
+ ], reports.map((report) => [
3117
+ report.slug,
3118
+ report.title,
3119
+ report.refreshed_at,
3120
+ report.last_refresh_error
3121
+ ])) : "_No reports._"));
3122
+ });
3123
+ })
3124
+ });
3125
+ const reportShow = command({
3126
+ name: "show",
3127
+ description: "Show the last successful rendering and saved query definitions.",
3128
+ args: { slug: positional({
3129
+ type: string,
3130
+ displayName: "slug"
3131
+ }) },
3132
+ handler: withErrors(async ({ slug }) => {
3133
+ await useDatabase(SiloDatabase.open(resolveWorkspace()), (database) => {
3134
+ const report = database.getReport(slug);
3135
+ const queries = report.queries.map((query) => `### ${query.name}\n\n\`\`\`sql\n${query.sql}\n\`\`\``).join("\n\n");
3136
+ output(heading(`Report: ${report.title}`, `${table(["Property", "Value"], [
3137
+ ["Slug", report.slug],
3138
+ ["Updated", report.updated_at],
3139
+ ["Refreshed", report.refreshed_at],
3140
+ ["Last refresh error", report.last_refresh_error]
3141
+ ])}\n\n## Rendered report\n\n${report.rendered_markdown}\n\n## Saved queries\n\n${queries}`));
3142
+ });
3143
+ })
3144
+ });
3145
+ const reportRefresh = command({
3146
+ name: "refresh",
3147
+ description: "Rerun saved queries and atomically replace a report rendering.",
3148
+ args: { slug: positional({
3149
+ type: string,
3150
+ displayName: "slug"
3151
+ }) },
3152
+ handler: withErrors(async ({ slug }) => {
3153
+ await useDatabase(SiloDatabase.open(resolveWorkspace(), true), (database) => {
3154
+ const report = database.refreshReport(slug);
3155
+ output(heading(`Report Refreshed: ${report.title}`, report.rendered_markdown));
3156
+ });
3157
+ })
3158
+ });
3159
+ const reportDelete = command({
3160
+ name: "delete",
3161
+ description: "Permanently delete a report and its saved queries.",
3162
+ args: { slug: positional({
3163
+ type: string,
3164
+ displayName: "slug"
3165
+ }) },
3166
+ handler: withErrors(async ({ slug }) => {
3167
+ await useDatabase(SiloDatabase.open(resolveWorkspace(), true), (database) => {
3168
+ database.deleteReport(slug);
3169
+ output(heading("Report Deleted", `\`${slug}\` was deleted.`));
3170
+ });
3171
+ })
3172
+ });
3173
+ const reportOpen = command({
3174
+ name: "open",
3175
+ description: "Open a report in the local viewer and serve refresh requests until interrupted.",
3176
+ args: { slug: positional({
3177
+ type: string,
3178
+ displayName: "slug"
3179
+ }) },
3180
+ handler: withErrors(async ({ slug }) => {
3181
+ output(heading("Report Viewer", `${(await startReportViewer(resolveWorkspace(), slug)).url}\n\nThe loopback server remains active until this command is interrupted.`));
3182
+ })
3183
+ });
3184
+ const sql = command({
3185
+ name: "sql",
3186
+ description: "Run one query through a read-only SQLite connection; omit it to read stdin.",
3187
+ examples: [{
3188
+ description: "Query a table",
3189
+ command: "silo sql 'SELECT * FROM issues'"
3190
+ }],
3191
+ args: { query: positional({
3192
+ type: optional(string),
3193
+ displayName: "query"
3194
+ }) },
3195
+ handler: withErrors(async ({ query }) => {
3196
+ const source = query ?? readFileSync(0, "utf8");
3197
+ if (!source.trim()) throw new SiloError(exits.input, "empty_query", "Expected a SQL query argument or stdin.");
3198
+ await useDatabase(SiloDatabase.open(resolveWorkspace()), (database) => {
3199
+ const result = database.query(source);
3200
+ output(heading("Query Result", result.rows.length ? table(result.columns, result.rows) : result.columns.length ? table(result.columns, []) : "_Query returned no result columns._"));
3201
+ });
3202
+ })
3203
+ });
3204
+ function renderSyncStatus(status) {
3205
+ return heading("Synchronization", table(["Property", "Value"], [
3206
+ ["State", status.state],
3207
+ ["Remote", status.remote_url],
3208
+ ["Database ID", status.database_id],
3209
+ ["Local generation", status.local_generation],
3210
+ ["Remote generation", status.remote_generation],
3211
+ ["Pending transactions", status.pending_transactions],
3212
+ ["Conflict transaction", status.conflict_transaction_id]
3213
+ ]));
3214
+ }
3215
+ const syncInit = command({
3216
+ name: "init",
3217
+ description: "Connect this workspace to an S3-compatible synchronization remote.",
3218
+ args: { remote: positional({
3219
+ type: string,
3220
+ displayName: "s3-url"
3221
+ }) },
3222
+ handler: withErrors(async ({ remote }) => {
3223
+ output(renderSyncStatus(await new SiloSync(resolveWorkspace()).initialize(remote)));
3224
+ })
3225
+ });
3226
+ const syncStatus = command({
3227
+ name: "status",
3228
+ description: "Compare local synchronization state with remote HEAD.",
3229
+ args: {},
3230
+ handler: withErrors(async () => {
3231
+ output(renderSyncStatus(await new SiloSync(resolveWorkspace()).status()));
3232
+ })
3233
+ });
3234
+ const syncDiscard = command({
3235
+ name: "discard",
3236
+ description: "Discard one pending transaction by rebuilding from remote state.",
3237
+ args: { transaction: positional({
3238
+ type: string,
3239
+ displayName: "transaction-id"
3240
+ }) },
3241
+ handler: withErrors(async ({ transaction }) => {
3242
+ output(renderSyncStatus(await new SiloSync(resolveWorkspace()).pull(transaction)));
3243
+ })
3244
+ });
3245
+ const pull = command({
3246
+ name: "pull",
3247
+ description: "Restore remote HEAD and reapply pending local changesets.",
3248
+ args: {},
3249
+ handler: withErrors(async () => {
3250
+ output(renderSyncStatus(await new SiloSync(resolveWorkspace()).pull()));
3251
+ })
3252
+ });
3253
+ //#endregion
3254
+ //#region src/bin.ts
3255
+ const result = await runSafely(binary(subcommands({
3256
+ name: "silo",
3257
+ version: "0.1.0",
3258
+ cmds: {
3259
+ status,
3260
+ push: command({
3261
+ name: "push",
3262
+ description: "Publish a merged immutable checkpoint and conditionally advance remote HEAD.",
3263
+ args: {},
3264
+ handler: withErrors(async () => {
3265
+ output(renderSyncStatus(await new SiloSync(resolveWorkspace()).push()));
3266
+ })
3267
+ }),
3268
+ pull,
3269
+ sync: subcommands({
3270
+ name: "sync",
3271
+ cmds: {
3272
+ init: syncInit,
3273
+ status: syncStatus,
3274
+ discard: syncDiscard
3275
+ }
3276
+ }),
3277
+ database: subcommands({
3278
+ name: "database",
3279
+ cmds: { list: databaseList }
3280
+ }),
3281
+ template: subcommands({
3282
+ name: "template",
3283
+ cmds: {
3284
+ list: templateList,
3285
+ show: templateShow
3286
+ }
3287
+ }),
3288
+ schema: subcommands({
3289
+ name: "schema",
3290
+ cmds: {
3291
+ show: schemaShow,
3292
+ export: schemaExport,
3293
+ ddl: schemaDdl,
3294
+ import: schemaImport
3295
+ }
3296
+ }),
3297
+ table: subcommands({
3298
+ name: "table",
3299
+ cmds: {
3300
+ list: tableList,
3301
+ show: tableShow,
3302
+ create: tableCreate,
3303
+ alter: tableAlter,
3304
+ drop: tableDrop
3305
+ }
3306
+ }),
3307
+ row: subcommands({
3308
+ name: "row",
3309
+ cmds: {
3310
+ add: rowAdd,
3311
+ get: rowGet,
3312
+ list: rowList,
3313
+ update: rowUpdate,
3314
+ delete: rowDelete,
3315
+ upsert: rowUpsert
3316
+ }
3317
+ }),
3318
+ report: subcommands({
3319
+ name: "report",
3320
+ cmds: {
3321
+ put: reportPut,
3322
+ list: reportList,
3323
+ show: reportShow,
3324
+ refresh: reportRefresh,
3325
+ delete: reportDelete,
3326
+ open: reportOpen
3327
+ }
3328
+ }),
3329
+ sql
3330
+ }
3331
+ })), process.argv);
3332
+ if (result._tag === "error") {
3333
+ const { message, into, exitCode } = result.error.config;
3334
+ (into === "stdout" ? process.stdout : process.stderr).write(message.endsWith("\n") ? message : `${message}\n`);
3335
+ process.exitCode = exitCode === 1 ? 2 : exitCode;
3336
+ }
3337
+ //#endregion
3338
+ export {};