latticesql 0.5.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/cli.js ADDED
@@ -0,0 +1,1880 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { resolve as resolve3, dirname as dirname5 } from "path";
5
+ import { readFileSync as readFileSync5 } from "fs";
6
+ import { parse as parse2 } from "yaml";
7
+
8
+ // src/codegen/generate.ts
9
+ import { writeFileSync, mkdirSync, existsSync } from "fs";
10
+ import { join, dirname as dirname2, resolve as resolve2 } from "path";
11
+
12
+ // src/config/parser.ts
13
+ import { readFileSync } from "fs";
14
+ import { resolve, dirname } from "path";
15
+ import { parse } from "yaml";
16
+ function parseConfigFile(configPath) {
17
+ const absPath = resolve(configPath);
18
+ const configDir = dirname(absPath);
19
+ let raw;
20
+ try {
21
+ raw = readFileSync(absPath, "utf-8");
22
+ } catch (e) {
23
+ throw new Error(`Lattice: cannot read config file at "${absPath}": ${e.message}`);
24
+ }
25
+ let parsed;
26
+ try {
27
+ parsed = parse(raw);
28
+ } catch (e) {
29
+ throw new Error(`Lattice: YAML parse error in "${absPath}": ${e.message}`);
30
+ }
31
+ return buildParsedConfig(parsed, absPath, configDir);
32
+ }
33
+ function buildParsedConfig(raw, sourceName, configDir) {
34
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
35
+ throw new Error(
36
+ `Lattice: config "${sourceName}" must be a YAML object with "db" and "entities" keys`
37
+ );
38
+ }
39
+ const cfg = raw;
40
+ if (typeof cfg.db !== "string") {
41
+ throw new Error(`Lattice: config.db must be a string path (got ${typeof cfg.db})`);
42
+ }
43
+ if (!cfg.entities || typeof cfg.entities !== "object" || Array.isArray(cfg.entities)) {
44
+ throw new Error(`Lattice: config.entities must be an object`);
45
+ }
46
+ const config = raw;
47
+ const dbPath = resolve(configDir, config.db);
48
+ const tables = [];
49
+ for (const [entityName, entityDef] of Object.entries(config.entities)) {
50
+ const definition = entityToTableDef(entityName, entityDef, configDir);
51
+ tables.push({ name: entityName, definition });
52
+ }
53
+ const entityContexts = parseEntityContexts(config.entityContexts);
54
+ return { dbPath, tables, entityContexts };
55
+ }
56
+ function entityToTableDef(entityName, entity, configDir) {
57
+ const rawFields = entity.fields;
58
+ if (!rawFields || typeof rawFields !== "object" || Array.isArray(rawFields)) {
59
+ throw new Error(`Lattice: entity "${entityName}" must have a "fields" object`);
60
+ }
61
+ const columns = {};
62
+ const relations = {};
63
+ let pkFromField;
64
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
65
+ columns[fieldName] = fieldToSqliteSpec(field);
66
+ if (field.primaryKey) {
67
+ pkFromField = fieldName;
68
+ }
69
+ if (field.ref) {
70
+ const relName = fieldName.endsWith("_id") ? fieldName.slice(0, -3) : fieldName;
71
+ relations[relName] = {
72
+ type: "belongsTo",
73
+ table: field.ref,
74
+ foreignKey: fieldName
75
+ };
76
+ }
77
+ }
78
+ const primaryKey = entity.primaryKey ?? pkFromField;
79
+ const render = parseEntityRender(entity.render);
80
+ const outputFile = resolve(configDir, entity.outputFile);
81
+ return {
82
+ columns,
83
+ render,
84
+ outputFile,
85
+ ...primaryKey !== void 0 ? { primaryKey } : {},
86
+ ...Object.keys(relations).length > 0 ? { relations } : {}
87
+ };
88
+ }
89
+ function fieldToSqliteSpec(field) {
90
+ const parts = [fieldToSqliteBaseType(field.type)];
91
+ if (field.primaryKey) {
92
+ parts.push("PRIMARY KEY");
93
+ } else if (field.required) {
94
+ parts.push("NOT NULL");
95
+ }
96
+ if (field.default !== void 0) {
97
+ const dv = field.default;
98
+ if (typeof dv === "string") {
99
+ parts.push(`DEFAULT '${dv.replace(/'/g, "''")}'`);
100
+ } else {
101
+ parts.push(`DEFAULT ${String(dv)}`);
102
+ }
103
+ }
104
+ return parts.join(" ");
105
+ }
106
+ function fieldToSqliteBaseType(type) {
107
+ switch (type) {
108
+ case "uuid":
109
+ case "text":
110
+ case "datetime":
111
+ case "date":
112
+ return "TEXT";
113
+ case "integer":
114
+ case "int":
115
+ case "boolean":
116
+ case "bool":
117
+ return "INTEGER";
118
+ case "real":
119
+ case "float":
120
+ return "REAL";
121
+ case "blob":
122
+ return "BLOB";
123
+ }
124
+ }
125
+ function parseEntityRender(render) {
126
+ if (!render) {
127
+ return "default-list";
128
+ }
129
+ if (typeof render === "string") {
130
+ return render;
131
+ }
132
+ const spec = render;
133
+ if (spec.formatRow) {
134
+ return {
135
+ template: spec.template,
136
+ hooks: { formatRow: spec.formatRow }
137
+ };
138
+ }
139
+ return spec.template;
140
+ }
141
+ function renderFnForTemplate(templateName) {
142
+ switch (templateName) {
143
+ case "default-list":
144
+ return (rows) => {
145
+ if (rows.length === 0) return "";
146
+ return rows.map(
147
+ (row) => `- ${Object.entries(row).map(([k, v]) => `${k}: ${v == null ? "" : String(v)}`).join(", ")}`
148
+ ).join("\n");
149
+ };
150
+ case "default-table":
151
+ return (rows) => {
152
+ if (rows.length === 0) return "";
153
+ const firstRow = rows[0];
154
+ if (!firstRow) return "";
155
+ const headers = Object.keys(firstRow);
156
+ const headerRow = `| ${headers.join(" | ")} |`;
157
+ const separatorRow = `| ${headers.map(() => "---").join(" | ")} |`;
158
+ const bodyRows = rows.map(
159
+ (row) => `| ${headers.map((h) => {
160
+ const v = row[h];
161
+ return v == null ? "" : String(v);
162
+ }).join(" | ")} |`
163
+ ).join("\n");
164
+ return [headerRow, separatorRow, bodyRows].join("\n");
165
+ };
166
+ case "default-detail":
167
+ return (rows) => {
168
+ if (rows.length === 0) return "";
169
+ return rows.map((row) => {
170
+ const body = Object.entries(row).map(([k, v]) => `${k}: ${v == null ? "" : String(v)}`).join("\n");
171
+ return `## ${String(Object.values(row)[0] ?? "")}
172
+
173
+ ${body}`;
174
+ }).join("\n\n---\n\n");
175
+ };
176
+ case "default-json":
177
+ return (rows) => JSON.stringify(rows, null, 2);
178
+ default:
179
+ return (rows) => JSON.stringify(rows, null, 2);
180
+ }
181
+ }
182
+ function parseEntitySource(sourceDef) {
183
+ if (sourceDef === "self") {
184
+ return { type: "self" };
185
+ }
186
+ return sourceDef;
187
+ }
188
+ function extractSlugField(slugTemplate) {
189
+ const match = /^\{\{(\w+)\}\}$/.exec(slugTemplate);
190
+ if (match?.[1]) {
191
+ const field = match[1];
192
+ return (row) => row[field];
193
+ }
194
+ return () => slugTemplate;
195
+ }
196
+ function parseEntityContexts(entityContexts) {
197
+ if (!entityContexts) return [];
198
+ const result = [];
199
+ for (const [tableName, ctxDef] of Object.entries(entityContexts)) {
200
+ const slugFn = extractSlugField(ctxDef.slug);
201
+ const files = {};
202
+ for (const [filename, fileDef] of Object.entries(ctxDef.files)) {
203
+ files[filename] = {
204
+ source: parseEntitySource(fileDef.source),
205
+ render: renderFnForTemplate(fileDef.template),
206
+ ...fileDef.budget !== void 0 ? { budget: fileDef.budget } : {},
207
+ ...fileDef.omitIfEmpty !== void 0 ? { omitIfEmpty: fileDef.omitIfEmpty } : {}
208
+ };
209
+ }
210
+ const definition = {
211
+ slug: slugFn,
212
+ files,
213
+ ...ctxDef.directoryRoot !== void 0 ? { directoryRoot: ctxDef.directoryRoot } : {},
214
+ ...ctxDef.protectedFiles !== void 0 ? { protectedFiles: ctxDef.protectedFiles } : {},
215
+ ...ctxDef.index !== void 0 ? {
216
+ index: {
217
+ outputFile: ctxDef.index.outputFile,
218
+ render: renderFnForTemplate(ctxDef.index.render)
219
+ }
220
+ } : {},
221
+ ...ctxDef.combined !== void 0 ? { combined: ctxDef.combined } : {}
222
+ };
223
+ result.push({ table: tableName, definition });
224
+ }
225
+ return result;
226
+ }
227
+
228
+ // src/codegen/generate.ts
229
+ function generateAll(opts) {
230
+ const { config, configDir, outDir, scaffold } = opts;
231
+ const filesWritten = [];
232
+ mkdirSync(outDir, { recursive: true });
233
+ mkdirSync(join(outDir, "migrations"), { recursive: true });
234
+ const tsPath = join(outDir, "types.ts");
235
+ writeFileSync(tsPath, generateTypes(config), "utf-8");
236
+ filesWritten.push(tsPath);
237
+ const sqlPath = join(outDir, "migrations", "0001_initial.sql");
238
+ writeFileSync(sqlPath, generateMigration(config), "utf-8");
239
+ filesWritten.push(sqlPath);
240
+ if (scaffold) {
241
+ for (const [, entityDef] of Object.entries(config.entities)) {
242
+ const outPath = resolve2(configDir, entityDef.outputFile);
243
+ if (!existsSync(outPath)) {
244
+ mkdirSync(dirname2(outPath), { recursive: true });
245
+ writeFileSync(outPath, "", "utf-8");
246
+ filesWritten.push(outPath);
247
+ }
248
+ }
249
+ }
250
+ return { filesWritten };
251
+ }
252
+ function generateTypes(config) {
253
+ const lines = ["// Auto-generated by `lattice generate`. Do not edit manually.", ""];
254
+ for (const [entityName, entityDef] of Object.entries(config.entities)) {
255
+ const typeName = toPascalCase(entityName);
256
+ lines.push(`export interface ${typeName} {`);
257
+ for (const [fieldName, field] of Object.entries(entityDef.fields)) {
258
+ const tsType = toTsType(field.type);
259
+ const optional = !field.primaryKey && !field.required;
260
+ const mark = optional ? "?" : "";
261
+ const comment = field.ref ? ` // \u2192 ${field.ref}` : "";
262
+ lines.push(` ${fieldName}${mark}: ${tsType};${comment}`);
263
+ }
264
+ lines.push("}", "");
265
+ }
266
+ return lines.join("\n");
267
+ }
268
+ function generateMigration(config) {
269
+ const lines = ["-- Auto-generated by `lattice generate`. Do not edit manually.", ""];
270
+ for (const [entityName, entityDef] of Object.entries(config.entities)) {
271
+ lines.push(`CREATE TABLE IF NOT EXISTS "${entityName}" (`);
272
+ const colDefs = entityColDefs(entityDef);
273
+ lines.push(colDefs.map((c) => ` ${c}`).join(",\n"));
274
+ lines.push(");", "");
275
+ }
276
+ return lines.join("\n");
277
+ }
278
+ function entityColDefs(entity) {
279
+ return Object.entries(entity.fields).map(([fieldName, field]) => {
280
+ const parts = [`"${fieldName}"`, fieldToSqliteBaseType(field.type)];
281
+ if (field.primaryKey) parts.push("PRIMARY KEY");
282
+ else if (field.required) parts.push("NOT NULL");
283
+ if (field.default !== void 0) {
284
+ const dv = field.default;
285
+ parts.push(
286
+ typeof dv === "string" ? `DEFAULT '${dv.replace(/'/g, "''")}'` : `DEFAULT ${String(dv)}`
287
+ );
288
+ }
289
+ return parts.join(" ");
290
+ });
291
+ }
292
+ function toTsType(type) {
293
+ switch (type) {
294
+ case "uuid":
295
+ case "text":
296
+ case "datetime":
297
+ case "date":
298
+ return "string";
299
+ case "integer":
300
+ case "int":
301
+ case "real":
302
+ case "float":
303
+ return "number";
304
+ case "boolean":
305
+ case "bool":
306
+ return "boolean";
307
+ case "blob":
308
+ return "Buffer";
309
+ }
310
+ }
311
+ function toPascalCase(s) {
312
+ return s.replace(/(^|[_-])([a-z])/g, (_, __, c) => c.toUpperCase());
313
+ }
314
+
315
+ // src/lattice.ts
316
+ import { v4 as uuidv4 } from "uuid";
317
+
318
+ // src/lifecycle/manifest.ts
319
+ import { join as join3 } from "path";
320
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
321
+
322
+ // src/render/writer.ts
323
+ import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, renameSync, existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
324
+ import { createHash } from "crypto";
325
+ import { dirname as dirname3, join as join2 } from "path";
326
+ import { tmpdir } from "os";
327
+ import { randomBytes } from "crypto";
328
+ function atomicWrite(filePath, content) {
329
+ const dir = dirname3(filePath);
330
+ mkdirSync2(dir, { recursive: true });
331
+ const currentHash = existingHash(filePath);
332
+ const newHash = contentHash(content);
333
+ if (currentHash === newHash) return false;
334
+ const tmp = join2(tmpdir(), `lattice-${randomBytes(8).toString("hex")}.tmp`);
335
+ writeFileSync2(tmp, content, "utf8");
336
+ renameSync(tmp, filePath);
337
+ return true;
338
+ }
339
+ function existingHash(filePath) {
340
+ if (!existsSync2(filePath)) return null;
341
+ try {
342
+ return contentHash(readFileSync2(filePath, "utf8"));
343
+ } catch {
344
+ return null;
345
+ }
346
+ }
347
+ function contentHash(content) {
348
+ return createHash("sha256").update(content).digest("hex");
349
+ }
350
+
351
+ // src/lifecycle/manifest.ts
352
+ function manifestPath(outputDir) {
353
+ return join3(outputDir, ".lattice", "manifest.json");
354
+ }
355
+ function readManifest(outputDir) {
356
+ const path = manifestPath(outputDir);
357
+ if (!existsSync3(path)) return null;
358
+ try {
359
+ return JSON.parse(readFileSync3(path, "utf8"));
360
+ } catch {
361
+ return null;
362
+ }
363
+ }
364
+ function writeManifest(outputDir, manifest) {
365
+ atomicWrite(manifestPath(outputDir), JSON.stringify(manifest, null, 2));
366
+ }
367
+
368
+ // src/db/sqlite.ts
369
+ import Database from "better-sqlite3";
370
+ var SQLiteAdapter = class {
371
+ _db = null;
372
+ _path;
373
+ _wal;
374
+ _busyTimeout;
375
+ constructor(path, options) {
376
+ this._path = path;
377
+ this._wal = options?.wal ?? true;
378
+ this._busyTimeout = options?.busyTimeout ?? 5e3;
379
+ }
380
+ get db() {
381
+ if (!this._db) throw new Error("SQLiteAdapter: not open \u2014 call open() first");
382
+ return this._db;
383
+ }
384
+ open() {
385
+ this._db = new Database(this._path);
386
+ this._db.pragma(`busy_timeout = ${this._busyTimeout.toString()}`);
387
+ if (this._wal) {
388
+ this._db.pragma("journal_mode = WAL");
389
+ }
390
+ }
391
+ close() {
392
+ this._db?.close();
393
+ this._db = null;
394
+ }
395
+ run(sql, params = []) {
396
+ this.db.prepare(sql).run(...params);
397
+ }
398
+ get(sql, params = []) {
399
+ return this.db.prepare(sql).get(...params);
400
+ }
401
+ all(sql, params = []) {
402
+ return this.db.prepare(sql).all(...params);
403
+ }
404
+ prepare(sql) {
405
+ const stmt = this.db.prepare(sql);
406
+ return {
407
+ run: (...params) => stmt.run(...params),
408
+ get: (...params) => stmt.get(...params),
409
+ all: (...params) => stmt.all(...params)
410
+ };
411
+ }
412
+ };
413
+
414
+ // src/schema/manager.ts
415
+ var SchemaManager = class {
416
+ _tables = /* @__PURE__ */ new Map();
417
+ /** Normalised primary key columns per table (always an array). */
418
+ _tablePK = /* @__PURE__ */ new Map();
419
+ _multis = /* @__PURE__ */ new Map();
420
+ _entityContexts = /* @__PURE__ */ new Map();
421
+ define(table, def) {
422
+ if (this._tables.has(table)) {
423
+ throw new Error(`Table "${table}" is already defined`);
424
+ }
425
+ this._tables.set(table, def);
426
+ if (def.primaryKey === void 0 || def.primaryKey === "id") {
427
+ this._tablePK.set(table, ["id"]);
428
+ } else if (Array.isArray(def.primaryKey)) {
429
+ if (def.primaryKey.length === 0) {
430
+ throw new Error(`Table "${table}": primaryKey array must not be empty`);
431
+ }
432
+ this._tablePK.set(table, def.primaryKey);
433
+ } else {
434
+ this._tablePK.set(table, [def.primaryKey]);
435
+ }
436
+ }
437
+ defineMulti(name, def) {
438
+ if (this._multis.has(name)) {
439
+ throw new Error(`Multi-render "${name}" is already defined`);
440
+ }
441
+ this._multis.set(name, def);
442
+ }
443
+ /**
444
+ * Register an entity context definition.
445
+ * Throws if a context for the same table has already been registered.
446
+ */
447
+ defineEntityContext(table, def) {
448
+ if (this._entityContexts.has(table)) {
449
+ throw new Error(`Entity context for table "${table}" is already defined`);
450
+ }
451
+ this._entityContexts.set(table, def);
452
+ }
453
+ getTables() {
454
+ return this._tables;
455
+ }
456
+ getMultis() {
457
+ return this._multis;
458
+ }
459
+ getEntityContexts() {
460
+ return this._entityContexts;
461
+ }
462
+ /**
463
+ * Return the normalised primary key column list for a table.
464
+ * Falls back to `['id']` for tables that were not registered via `define()`
465
+ * (e.g. tables accessed through the raw `.db` escape hatch).
466
+ */
467
+ getPrimaryKey(table) {
468
+ return this._tablePK.get(table) ?? ["id"];
469
+ }
470
+ /**
471
+ * Return the declared relationships for a table, keyed by relation name.
472
+ * Returns an empty object for tables with no `relations` definition.
473
+ */
474
+ getRelations(table) {
475
+ return this._tables.get(table)?.relations ?? {};
476
+ }
477
+ /**
478
+ * Apply schema: create missing tables, add missing columns.
479
+ * Never drops tables or columns.
480
+ */
481
+ applySchema(adapter) {
482
+ for (const [name, def] of this._tables) {
483
+ this._ensureTable(adapter, name, def.columns, def.tableConstraints);
484
+ }
485
+ this._ensureTable(adapter, "__lattice_migrations", {
486
+ version: "INTEGER PRIMARY KEY",
487
+ applied_at: "TEXT NOT NULL"
488
+ });
489
+ }
490
+ /** Run explicit versioned migrations in order, idempotently */
491
+ applyMigrations(adapter, migrations) {
492
+ const sorted = [...migrations].sort((a, b) => a.version - b.version);
493
+ for (const m of sorted) {
494
+ const exists = adapter.get("SELECT 1 FROM __lattice_migrations WHERE version = ?", [
495
+ m.version
496
+ ]);
497
+ if (!exists) {
498
+ adapter.run(m.sql);
499
+ adapter.run("INSERT INTO __lattice_migrations (version, applied_at) VALUES (?, ?)", [
500
+ m.version,
501
+ (/* @__PURE__ */ new Date()).toISOString()
502
+ ]);
503
+ }
504
+ }
505
+ }
506
+ /** Query all rows from a registered table */
507
+ queryTable(adapter, name) {
508
+ if (!this._tables.has(name)) {
509
+ throw new Error(`Unknown table: "${name}"`);
510
+ }
511
+ return adapter.all(`SELECT * FROM "${name}"`);
512
+ }
513
+ _ensureTable(adapter, name, columns, tableConstraints) {
514
+ const colDefs = Object.entries(columns).map(([col, type]) => `"${col}" ${type}`).join(", ");
515
+ const constraintDefs = tableConstraints && tableConstraints.length > 0 ? ", " + tableConstraints.join(", ") : "";
516
+ adapter.run(`CREATE TABLE IF NOT EXISTS "${name}" (${colDefs}${constraintDefs})`);
517
+ this._addMissingColumns(adapter, name, columns);
518
+ }
519
+ _addMissingColumns(adapter, table, columns) {
520
+ const existing = adapter.all(`PRAGMA table_info("${table}")`).map((r) => r.name);
521
+ for (const [col, type] of Object.entries(columns)) {
522
+ if (!existing.includes(col)) {
523
+ adapter.run(`ALTER TABLE "${table}" ADD COLUMN "${col}" ${type}`);
524
+ }
525
+ }
526
+ }
527
+ };
528
+
529
+ // src/security/sanitize.ts
530
+ var SUSPICIOUS_PATTERNS = [
531
+ /(\bdrop\b|\bdelete\b|\btruncate\b|\binsert\b|\bupdate\b)\s+\b(table|from|into)\b/i,
532
+ /<script[\s\S]*?>/i,
533
+ /javascript:/i,
534
+ /\.\.[/\\]/,
535
+ // Intentional null-byte check — eslint-disable to allow control char in regex
536
+ // eslint-disable-next-line no-control-regex
537
+ /\x00/
538
+ ];
539
+ var NULL_BYTE_RE = /\x00/g;
540
+ var CONTROL_CHAR_RE = /[\x01-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
541
+ var Sanitizer = class {
542
+ _options;
543
+ _auditHandlers = [];
544
+ constructor(options = {}) {
545
+ this._options = {
546
+ sanitize: options.sanitize ?? true,
547
+ auditTables: options.auditTables ?? [],
548
+ fieldLimits: options.fieldLimits ?? {}
549
+ };
550
+ }
551
+ onAudit(handler) {
552
+ this._auditHandlers.push(handler);
553
+ }
554
+ sanitizeRow(row) {
555
+ if (!this._options.sanitize) return row;
556
+ const out = {};
557
+ for (const [key, val] of Object.entries(row)) {
558
+ if (typeof val === "string") {
559
+ let s = val.replace(NULL_BYTE_RE, "").replace(CONTROL_CHAR_RE, "");
560
+ const limit = this._options.fieldLimits[key];
561
+ if (limit !== void 0 && s.length > limit) {
562
+ s = s.slice(0, limit);
563
+ }
564
+ for (const pattern of SUSPICIOUS_PATTERNS) {
565
+ if (pattern.test(s)) {
566
+ console.warn(`[lattice/security] Suspicious content in field "${key}"`);
567
+ break;
568
+ }
569
+ }
570
+ out[key] = s;
571
+ } else {
572
+ out[key] = val;
573
+ }
574
+ }
575
+ return out;
576
+ }
577
+ emitAudit(table, operation, id) {
578
+ if (!this._options.auditTables.includes(table)) return;
579
+ const event = {
580
+ table,
581
+ operation,
582
+ id,
583
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
584
+ };
585
+ for (const handler of this._auditHandlers) {
586
+ handler(event);
587
+ }
588
+ }
589
+ isAuditTable(table) {
590
+ return this._options.auditTables.includes(table);
591
+ }
592
+ };
593
+
594
+ // src/render/engine.ts
595
+ import { join as join5 } from "path";
596
+ import { mkdirSync as mkdirSync3 } from "fs";
597
+
598
+ // src/render/entity-query.ts
599
+ function resolveEntitySource(source, entityRow, entityPk, adapter) {
600
+ switch (source.type) {
601
+ case "self":
602
+ return [entityRow];
603
+ case "hasMany": {
604
+ const ref = source.references ?? entityPk;
605
+ const pkVal = entityRow[ref];
606
+ return adapter.all(
607
+ `SELECT * FROM "${source.table}" WHERE "${source.foreignKey}" = ?`,
608
+ [pkVal]
609
+ );
610
+ }
611
+ case "manyToMany": {
612
+ const pkVal = entityRow[entityPk];
613
+ const remotePk = source.references ?? "id";
614
+ return adapter.all(
615
+ `SELECT r.* FROM "${source.remoteTable}" r
616
+ JOIN "${source.junctionTable}" j ON j."${source.remoteKey}" = r."${remotePk}"
617
+ WHERE j."${source.localKey}" = ?`,
618
+ [pkVal]
619
+ );
620
+ }
621
+ case "belongsTo": {
622
+ const fkVal = entityRow[source.foreignKey];
623
+ if (fkVal == null) return [];
624
+ const related = adapter.get(
625
+ `SELECT * FROM "${source.table}" WHERE "${source.references ?? "id"}" = ?`,
626
+ [fkVal]
627
+ );
628
+ return related ? [related] : [];
629
+ }
630
+ case "custom":
631
+ return source.query(entityRow, adapter);
632
+ }
633
+ }
634
+ function truncateContent(content, budget) {
635
+ if (budget === void 0 || content.length <= budget) return content;
636
+ return content.slice(0, budget) + "\n\n*[truncated \u2014 context budget exceeded]*";
637
+ }
638
+
639
+ // src/lifecycle/cleanup.ts
640
+ import { join as join4 } from "path";
641
+ import { existsSync as existsSync4, readdirSync, unlinkSync, rmdirSync, statSync } from "fs";
642
+ function cleanupEntityContexts(outputDir, entityContexts, currentSlugsByTable, manifest, options = {}, newManifest) {
643
+ const result = {
644
+ directoriesRemoved: [],
645
+ filesRemoved: [],
646
+ directoriesSkipped: [],
647
+ warnings: []
648
+ };
649
+ if (manifest === null) return result;
650
+ for (const [table, def] of entityContexts) {
651
+ const entry = manifest.entityContexts[table];
652
+ if (!entry) continue;
653
+ const directoryRoot = entry.directoryRoot;
654
+ const currentSlugs = currentSlugsByTable.get(table) ?? /* @__PURE__ */ new Set();
655
+ const globalProtected = /* @__PURE__ */ new Set([
656
+ ...def.protectedFiles ?? [],
657
+ ...options.protectedFiles ?? []
658
+ ]);
659
+ const rootPath = join4(outputDir, directoryRoot);
660
+ if (!existsSync4(rootPath)) continue;
661
+ if (options.removeOrphanedDirectories !== false && !def.directory) {
662
+ let actualDirs;
663
+ try {
664
+ actualDirs = readdirSync(rootPath).filter((name) => {
665
+ try {
666
+ return statSync(join4(rootPath, name)).isDirectory();
667
+ } catch {
668
+ return false;
669
+ }
670
+ });
671
+ } catch {
672
+ actualDirs = [];
673
+ }
674
+ for (const dirName of actualDirs) {
675
+ if (currentSlugs.has(dirName)) continue;
676
+ if (!Object.prototype.hasOwnProperty.call(entry.entities, dirName)) continue;
677
+ const entityDir = join4(rootPath, dirName);
678
+ const managedFiles = entry.entities[dirName] ?? [];
679
+ for (const filename of managedFiles) {
680
+ if (globalProtected.has(filename)) continue;
681
+ const filePath = join4(entityDir, filename);
682
+ if (!existsSync4(filePath)) continue;
683
+ if (!options.dryRun) unlinkSync(filePath);
684
+ options.onOrphan?.(filePath, "file");
685
+ result.filesRemoved.push(filePath);
686
+ }
687
+ let remaining;
688
+ try {
689
+ remaining = existsSync4(entityDir) ? readdirSync(entityDir) : [];
690
+ } catch {
691
+ remaining = [];
692
+ }
693
+ if (remaining.length === 0) {
694
+ if (!options.dryRun) {
695
+ try {
696
+ rmdirSync(entityDir);
697
+ } catch {
698
+ }
699
+ }
700
+ options.onOrphan?.(entityDir, "directory");
701
+ result.directoriesRemoved.push(entityDir);
702
+ } else {
703
+ result.directoriesSkipped.push(entityDir);
704
+ result.warnings.push(
705
+ `${entityDir}: left in place (contains user files: ${remaining.join(", ")})`
706
+ );
707
+ }
708
+ }
709
+ }
710
+ if (options.removeOrphanedFiles !== false) {
711
+ const newEntry = newManifest?.entityContexts[table];
712
+ const declaredFiles = new Set(Object.keys(def.files));
713
+ if (def.combined) declaredFiles.add(def.combined.outputFile);
714
+ for (const slug of currentSlugs) {
715
+ const entityDir = def.directory ? null : join4(rootPath, slug);
716
+ if (!entityDir || !existsSync4(entityDir)) continue;
717
+ const previouslyWritten = entry.entities[slug] ?? [];
718
+ const currentlyWritten = new Set(newEntry?.entities[slug] ?? []);
719
+ for (const filename of previouslyWritten) {
720
+ if (newEntry !== void 0) {
721
+ if (currentlyWritten.has(filename)) continue;
722
+ } else {
723
+ if (declaredFiles.has(filename)) continue;
724
+ }
725
+ if (globalProtected.has(filename)) continue;
726
+ const filePath = join4(entityDir, filename);
727
+ if (!existsSync4(filePath)) continue;
728
+ if (!options.dryRun) unlinkSync(filePath);
729
+ options.onOrphan?.(filePath, "file");
730
+ result.filesRemoved.push(filePath);
731
+ }
732
+ }
733
+ }
734
+ }
735
+ return result;
736
+ }
737
+
738
+ // src/render/engine.ts
739
+ var RenderEngine = class {
740
+ _schema;
741
+ _adapter;
742
+ constructor(schema, adapter) {
743
+ this._schema = schema;
744
+ this._adapter = adapter;
745
+ }
746
+ async render(outputDir) {
747
+ const start = Date.now();
748
+ const filesWritten = [];
749
+ const counters = { skipped: 0 };
750
+ for (const [name, def] of this._schema.getTables()) {
751
+ let rows = this._schema.queryTable(this._adapter, name);
752
+ if (def.filter) rows = def.filter(rows);
753
+ const content = def.render(rows);
754
+ const filePath = join5(outputDir, def.outputFile);
755
+ if (atomicWrite(filePath, content)) {
756
+ filesWritten.push(filePath);
757
+ } else {
758
+ counters.skipped++;
759
+ }
760
+ }
761
+ for (const [, def] of this._schema.getMultis()) {
762
+ const keys = await def.keys();
763
+ const tables = {};
764
+ if (def.tables) {
765
+ for (const t of def.tables) {
766
+ tables[t] = this._schema.queryTable(this._adapter, t);
767
+ }
768
+ }
769
+ for (const key of keys) {
770
+ const content = def.render(key, tables);
771
+ const filePath = join5(outputDir, def.outputFile(key));
772
+ if (atomicWrite(filePath, content)) {
773
+ filesWritten.push(filePath);
774
+ } else {
775
+ counters.skipped++;
776
+ }
777
+ }
778
+ }
779
+ const entityContextManifest = this._renderEntityContexts(outputDir, filesWritten, counters);
780
+ if (this._schema.getEntityContexts().size > 0) {
781
+ writeManifest(outputDir, {
782
+ version: 1,
783
+ generated_at: (/* @__PURE__ */ new Date()).toISOString(),
784
+ entityContexts: entityContextManifest
785
+ });
786
+ }
787
+ return {
788
+ filesWritten,
789
+ filesSkipped: counters.skipped,
790
+ durationMs: Date.now() - start
791
+ };
792
+ }
793
+ /**
794
+ * Run orphan cleanup using the previous manifest.
795
+ * Called by reconcile() and optionally by the watch loop.
796
+ *
797
+ * @param newManifest - Optional: the manifest just written by render().
798
+ * When provided, step 2 (stale files in surviving entity dirs) compares
799
+ * old vs new manifest entries, catching omitIfEmpty files that were written
800
+ * before but skipped in the current render cycle.
801
+ */
802
+ cleanup(outputDir, prevManifest, options = {}, newManifest) {
803
+ const entityContexts = this._schema.getEntityContexts();
804
+ const currentSlugsByTable = /* @__PURE__ */ new Map();
805
+ for (const [table, def] of entityContexts) {
806
+ const rows = this._schema.queryTable(this._adapter, table);
807
+ const slugs = new Set(rows.map((row) => def.slug(row)));
808
+ currentSlugsByTable.set(table, slugs);
809
+ }
810
+ return cleanupEntityContexts(outputDir, entityContexts, currentSlugsByTable, prevManifest, options, newManifest);
811
+ }
812
+ /**
813
+ * Render all entity context definitions.
814
+ * Mutates `filesWritten` and `counters` in place.
815
+ * Returns manifest data for the entity contexts rendered this cycle.
816
+ */
817
+ _renderEntityContexts(outputDir, filesWritten, counters) {
818
+ const manifestData = {};
819
+ for (const [table, def] of this._schema.getEntityContexts()) {
820
+ const entityPk = this._schema.getPrimaryKey(table)[0] ?? "id";
821
+ const allRows = this._schema.queryTable(this._adapter, table);
822
+ const directoryRoot = def.directoryRoot ?? table;
823
+ const manifestEntry = {
824
+ directoryRoot,
825
+ ...def.index ? { indexFile: def.index.outputFile } : {},
826
+ declaredFiles: Object.keys(def.files),
827
+ protectedFiles: def.protectedFiles ?? [],
828
+ entities: {}
829
+ };
830
+ if (def.index) {
831
+ const indexPath = join5(outputDir, def.index.outputFile);
832
+ if (atomicWrite(indexPath, def.index.render(allRows))) {
833
+ filesWritten.push(indexPath);
834
+ } else {
835
+ counters.skipped++;
836
+ }
837
+ }
838
+ for (const entityRow of allRows) {
839
+ const slug = def.slug(entityRow);
840
+ const entityDir = def.directory ? join5(outputDir, def.directory(entityRow)) : join5(outputDir, directoryRoot, slug);
841
+ mkdirSync3(entityDir, { recursive: true });
842
+ const renderedFiles = /* @__PURE__ */ new Map();
843
+ for (const [filename, spec] of Object.entries(def.files)) {
844
+ const rows = resolveEntitySource(spec.source, entityRow, entityPk, this._adapter);
845
+ if (spec.omitIfEmpty && rows.length === 0) continue;
846
+ const content = truncateContent(spec.render(rows), spec.budget);
847
+ renderedFiles.set(filename, content);
848
+ const filePath = join5(entityDir, filename);
849
+ if (atomicWrite(filePath, content)) {
850
+ filesWritten.push(filePath);
851
+ } else {
852
+ counters.skipped++;
853
+ }
854
+ }
855
+ if (def.combined && renderedFiles.size > 0) {
856
+ const excluded = new Set(def.combined.exclude ?? []);
857
+ const parts = [];
858
+ for (const filename of Object.keys(def.files)) {
859
+ if (!excluded.has(filename) && renderedFiles.has(filename)) {
860
+ parts.push(renderedFiles.get(filename));
861
+ }
862
+ }
863
+ if (parts.length > 0) {
864
+ const combinedPath = join5(entityDir, def.combined.outputFile);
865
+ if (atomicWrite(combinedPath, parts.join("\n\n---\n\n"))) {
866
+ filesWritten.push(combinedPath);
867
+ } else {
868
+ counters.skipped++;
869
+ }
870
+ renderedFiles.set(def.combined.outputFile, parts.join("\n\n---\n\n"));
871
+ }
872
+ }
873
+ manifestEntry.entities[slug] = [...renderedFiles.keys()];
874
+ }
875
+ manifestData[table] = manifestEntry;
876
+ }
877
+ return manifestData;
878
+ }
879
+ };
880
+
881
+ // src/sync/loop.ts
882
+ var SyncLoop = class {
883
+ _engine;
884
+ constructor(engine) {
885
+ this._engine = engine;
886
+ }
887
+ watch(outputDir, options = {}) {
888
+ const interval = options.interval ?? 5e3;
889
+ let timer = null;
890
+ let stopped = false;
891
+ const tick = () => {
892
+ if (stopped) return;
893
+ const prevManifest = options.cleanup ? readManifest(outputDir) : null;
894
+ void this._engine.render(outputDir).then((result) => {
895
+ options.onRender?.(result);
896
+ if (options.cleanup) {
897
+ const cleanupResult = this._engine.cleanup(outputDir, prevManifest, options.cleanup);
898
+ options.onCleanup?.(cleanupResult);
899
+ }
900
+ }).catch((err) => {
901
+ options.onError?.(err instanceof Error ? err : new Error(String(err)));
902
+ }).finally(() => {
903
+ if (!stopped) {
904
+ timer = setTimeout(tick, interval);
905
+ }
906
+ });
907
+ };
908
+ timer = setTimeout(tick, interval);
909
+ return () => {
910
+ stopped = true;
911
+ if (timer !== null) {
912
+ clearTimeout(timer);
913
+ timer = null;
914
+ }
915
+ };
916
+ }
917
+ };
918
+
919
+ // src/writeback/pipeline.ts
920
+ import { readFileSync as readFileSync4, statSync as statSync2, existsSync as existsSync5, readdirSync as readdirSync2 } from "fs";
921
+ import { join as join6, dirname as dirname4, basename } from "path";
922
+ var WritebackPipeline = class {
923
+ _definitions = [];
924
+ _fileState = /* @__PURE__ */ new Map();
925
+ _seen = /* @__PURE__ */ new Map();
926
+ define(def) {
927
+ this._definitions.push(def);
928
+ }
929
+ async process() {
930
+ let total = 0;
931
+ for (const def of this._definitions) {
932
+ total += await this._processDef(def);
933
+ }
934
+ return total;
935
+ }
936
+ async _processDef(def) {
937
+ const paths = this._expandGlob(def.file);
938
+ let processed = 0;
939
+ for (const filePath of paths) {
940
+ if (!existsSync5(filePath)) continue;
941
+ const stat = statSync2(filePath);
942
+ const currentSize = stat.size;
943
+ const state = this._fileState.get(filePath) ?? { offset: 0, size: 0 };
944
+ if (currentSize < state.size) {
945
+ this._fileState.set(filePath, { offset: 0, size: 0 });
946
+ state.offset = 0;
947
+ }
948
+ if (currentSize === state.offset) continue;
949
+ const content = readFileSync4(filePath, "utf8");
950
+ const { entries, nextOffset } = def.parse(content, state.offset);
951
+ this._fileState.set(filePath, { offset: nextOffset, size: currentSize });
952
+ for (const entry of entries) {
953
+ const key = def.dedupeKey ? def.dedupeKey(entry) : null;
954
+ if (key !== null) {
955
+ const seenForFile = this._seen.get(filePath) ?? /* @__PURE__ */ new Set();
956
+ if (seenForFile.has(key)) continue;
957
+ seenForFile.add(key);
958
+ this._seen.set(filePath, seenForFile);
959
+ }
960
+ await def.persist(entry, filePath);
961
+ processed++;
962
+ }
963
+ }
964
+ return processed;
965
+ }
966
+ _expandGlob(pattern) {
967
+ if (!pattern.includes("*") && !pattern.includes("?")) {
968
+ return [pattern];
969
+ }
970
+ const dir = dirname4(pattern);
971
+ const filePattern = basename(pattern);
972
+ if (!existsSync5(dir)) return [];
973
+ const regexStr = filePattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
974
+ const regex = new RegExp(`^${regexStr}$`);
975
+ return readdirSync2(dir).filter((f) => regex.test(f)).map((f) => join6(dir, f));
976
+ }
977
+ };
978
+
979
+ // src/render/interpolate.ts
980
+ function interpolate(template, row) {
981
+ return template.replace(/\{\{([^}]+)\}\}/g, (_, path) => {
982
+ const parts = path.trim().split(".");
983
+ let val = row;
984
+ for (const part of parts) {
985
+ if (val == null || typeof val !== "object") return "";
986
+ val = val[part];
987
+ }
988
+ return val == null ? "" : String(val);
989
+ });
990
+ }
991
+
992
+ // src/render/templates.ts
993
+ function compileRender(def, tableName, schema, adapter) {
994
+ const { renderFn, templateName, hooks } = _normalizeSpec(def.render);
995
+ if (renderFn) {
996
+ if (hooks?.beforeRender) {
997
+ const bh = hooks.beforeRender;
998
+ return (rows) => renderFn(bh(rows));
999
+ }
1000
+ return renderFn;
1001
+ }
1002
+ return (rows) => {
1003
+ const processed = hooks?.beforeRender ? hooks.beforeRender(rows) : rows;
1004
+ const enriched = processed.map((row) => _enrichRow(row, def, schema, adapter));
1005
+ switch (templateName) {
1006
+ case "default-list":
1007
+ return _renderList(enriched, hooks?.formatRow);
1008
+ case "default-table":
1009
+ return _renderTable(enriched);
1010
+ case "default-detail":
1011
+ return _renderDetail(enriched, tableName, schema, hooks?.formatRow);
1012
+ case "default-json":
1013
+ return JSON.stringify(processed, null, 2);
1014
+ default:
1015
+ return "";
1016
+ }
1017
+ };
1018
+ }
1019
+ function _normalizeSpec(render) {
1020
+ if (typeof render === "function") {
1021
+ return { renderFn: render };
1022
+ }
1023
+ if (typeof render === "string") {
1024
+ return { templateName: render };
1025
+ }
1026
+ const spec = render;
1027
+ return { templateName: spec.template, hooks: spec.hooks };
1028
+ }
1029
+ function _enrichRow(row, def, schema, adapter) {
1030
+ if (!def.relations) return row;
1031
+ const enriched = { ...row };
1032
+ for (const [relName, rel] of Object.entries(def.relations)) {
1033
+ if (rel.type !== "belongsTo") continue;
1034
+ const fkValue = row[rel.foreignKey];
1035
+ if (fkValue == null) continue;
1036
+ try {
1037
+ const refCol = rel.references ?? schema.getPrimaryKey(rel.table)[0] ?? "id";
1038
+ const relRow = adapter.get(`SELECT * FROM "${rel.table}" WHERE "${refCol}" = ?`, [fkValue]);
1039
+ if (relRow) {
1040
+ enriched[relName] = relRow;
1041
+ }
1042
+ } catch {
1043
+ }
1044
+ }
1045
+ return enriched;
1046
+ }
1047
+ function _applyFormatRow(row, formatRow) {
1048
+ if (formatRow == null) {
1049
+ return Object.entries(row).map(([k, v]) => `${k}: ${v == null ? "" : String(v)}`).join(", ");
1050
+ }
1051
+ if (typeof formatRow === "function") return formatRow(row);
1052
+ return interpolate(formatRow, row);
1053
+ }
1054
+ function _renderList(rows, formatRow) {
1055
+ if (rows.length === 0) return "";
1056
+ return rows.map((row) => `- ${_applyFormatRow(row, formatRow)}`).join("\n");
1057
+ }
1058
+ function _renderTable(rows) {
1059
+ if (rows.length === 0) return "";
1060
+ const firstRow = rows[0];
1061
+ if (!firstRow) return "";
1062
+ const headers = Object.keys(firstRow);
1063
+ const headerRow = `| ${headers.join(" | ")} |`;
1064
+ const separatorRow = `| ${headers.map(() => "---").join(" | ")} |`;
1065
+ const bodyRows = rows.map(
1066
+ (row) => `| ${headers.map((h) => {
1067
+ const v = row[h];
1068
+ return v == null ? "" : String(v);
1069
+ }).join(" | ")} |`
1070
+ ).join("\n");
1071
+ return [headerRow, separatorRow, bodyRows].join("\n");
1072
+ }
1073
+ function _renderDetail(rows, tableName, schema, formatRow) {
1074
+ if (rows.length === 0) return "";
1075
+ const pkCols = schema.getPrimaryKey(tableName);
1076
+ return rows.map((row) => {
1077
+ const pkVal = pkCols.map((col) => {
1078
+ const v = row[col];
1079
+ return v == null ? "" : String(v);
1080
+ }).join(":");
1081
+ const heading = `## ${pkVal}`;
1082
+ let body;
1083
+ if (formatRow != null) {
1084
+ body = typeof formatRow === "function" ? formatRow(row) : interpolate(formatRow, row);
1085
+ } else {
1086
+ body = Object.entries(row).map(([k, v]) => `${k}: ${v == null ? "" : String(v)}`).join("\n");
1087
+ }
1088
+ return `${heading}
1089
+
1090
+ ${body}`;
1091
+ }).join("\n\n---\n\n");
1092
+ }
1093
+
1094
+ // src/lattice.ts
1095
+ var Lattice = class {
1096
+ _adapter;
1097
+ _schema;
1098
+ _sanitizer;
1099
+ _render;
1100
+ _loop;
1101
+ _writeback;
1102
+ _initialized = false;
1103
+ /** Cache of actual table columns (from PRAGMA), populated after init(). */
1104
+ _columnCache = /* @__PURE__ */ new Map();
1105
+ _auditHandlers = [];
1106
+ _renderHandlers = [];
1107
+ _writebackHandlers = [];
1108
+ _errorHandlers = [];
1109
+ constructor(pathOrConfig, options = {}) {
1110
+ let dbPath;
1111
+ let configTables;
1112
+ let configEntityContexts;
1113
+ if (typeof pathOrConfig === "string") {
1114
+ dbPath = pathOrConfig;
1115
+ } else {
1116
+ const parsed = parseConfigFile(pathOrConfig.config);
1117
+ dbPath = parsed.dbPath;
1118
+ configTables = [...parsed.tables];
1119
+ configEntityContexts = [...parsed.entityContexts];
1120
+ if (pathOrConfig.options) {
1121
+ options = { ...pathOrConfig.options, ...options };
1122
+ }
1123
+ }
1124
+ const adapterOpts = {};
1125
+ if (options.wal !== void 0) adapterOpts.wal = options.wal;
1126
+ if (options.busyTimeout !== void 0) adapterOpts.busyTimeout = options.busyTimeout;
1127
+ this._adapter = new SQLiteAdapter(dbPath, adapterOpts);
1128
+ this._schema = new SchemaManager();
1129
+ this._sanitizer = new Sanitizer(options.security);
1130
+ this._render = new RenderEngine(this._schema, this._adapter);
1131
+ this._loop = new SyncLoop(this._render);
1132
+ this._writeback = new WritebackPipeline();
1133
+ this._sanitizer.onAudit((event) => {
1134
+ for (const h of this._auditHandlers) h(event);
1135
+ });
1136
+ if (configTables) {
1137
+ for (const { name, definition } of configTables) {
1138
+ this.define(name, definition);
1139
+ }
1140
+ }
1141
+ if (configEntityContexts) {
1142
+ for (const { table, definition } of configEntityContexts) {
1143
+ this.defineEntityContext(table, definition);
1144
+ }
1145
+ }
1146
+ }
1147
+ // -------------------------------------------------------------------------
1148
+ // Setup
1149
+ // -------------------------------------------------------------------------
1150
+ define(table, def) {
1151
+ this._assertNotInit("define");
1152
+ const compiledDef = {
1153
+ ...def,
1154
+ render: compileRender(def, table, this._schema, this._adapter)
1155
+ };
1156
+ this._schema.define(table, compiledDef);
1157
+ return this;
1158
+ }
1159
+ defineMulti(name, def) {
1160
+ this._assertNotInit("defineMulti");
1161
+ this._schema.defineMulti(name, def);
1162
+ return this;
1163
+ }
1164
+ defineEntityContext(table, def) {
1165
+ this._assertNotInit("defineEntityContext");
1166
+ this._schema.defineEntityContext(table, def);
1167
+ return this;
1168
+ }
1169
+ defineWriteback(def) {
1170
+ this._writeback.define(def);
1171
+ return this;
1172
+ }
1173
+ init(options = {}) {
1174
+ if (this._initialized) {
1175
+ return Promise.reject(new Error("Lattice: init() has already been called"));
1176
+ }
1177
+ this._adapter.open();
1178
+ this._schema.applySchema(this._adapter);
1179
+ if (options.migrations?.length) {
1180
+ this._schema.applyMigrations(this._adapter, options.migrations);
1181
+ }
1182
+ for (const tableName of this._schema.getTables().keys()) {
1183
+ const rows = this._adapter.all(`PRAGMA table_info("${tableName}")`);
1184
+ this._columnCache.set(tableName, new Set(rows.map((r) => r.name)));
1185
+ }
1186
+ this._initialized = true;
1187
+ return Promise.resolve();
1188
+ }
1189
+ close() {
1190
+ this._adapter.close();
1191
+ this._columnCache.clear();
1192
+ this._initialized = false;
1193
+ }
1194
+ // -------------------------------------------------------------------------
1195
+ // CRUD
1196
+ // -------------------------------------------------------------------------
1197
+ insert(table, row) {
1198
+ const notInit = this._notInitError();
1199
+ if (notInit) return notInit;
1200
+ const sanitized = this._filterToSchemaColumns(table, this._sanitizer.sanitizeRow(row));
1201
+ const pkCols = this._schema.getPrimaryKey(table);
1202
+ const isDefaultPk = pkCols.length === 1 && pkCols[0] === "id";
1203
+ let rowWithPk;
1204
+ if (isDefaultPk) {
1205
+ const id = sanitized.id ?? uuidv4();
1206
+ rowWithPk = { ...sanitized, id };
1207
+ } else {
1208
+ rowWithPk = sanitized;
1209
+ }
1210
+ const cols = Object.keys(rowWithPk).map((c) => `"${c}"`).join(", ");
1211
+ const placeholders = Object.keys(rowWithPk).map(() => "?").join(", ");
1212
+ const values = Object.values(rowWithPk);
1213
+ this._adapter.run(`INSERT INTO "${table}" (${cols}) VALUES (${placeholders})`, values);
1214
+ const pkCol = pkCols[0] ?? "id";
1215
+ const rawPk = rowWithPk[pkCol];
1216
+ const pkValue = rawPk != null ? String(rawPk) : "";
1217
+ this._sanitizer.emitAudit(table, "insert", pkValue);
1218
+ return Promise.resolve(pkValue);
1219
+ }
1220
+ upsert(table, row) {
1221
+ const notInit = this._notInitError();
1222
+ if (notInit) return notInit;
1223
+ const sanitized = this._filterToSchemaColumns(table, this._sanitizer.sanitizeRow(row));
1224
+ const pkCols = this._schema.getPrimaryKey(table);
1225
+ const isDefaultPk = pkCols.length === 1 && pkCols[0] === "id";
1226
+ let rowWithPk;
1227
+ if (isDefaultPk) {
1228
+ const id = sanitized.id ?? uuidv4();
1229
+ rowWithPk = { ...sanitized, id };
1230
+ } else {
1231
+ rowWithPk = sanitized;
1232
+ }
1233
+ const cols = Object.keys(rowWithPk).map((c) => `"${c}"`).join(", ");
1234
+ const placeholders = Object.keys(rowWithPk).map(() => "?").join(", ");
1235
+ const conflictCols = pkCols.map((c) => `"${c}"`).join(", ");
1236
+ const updateCols = Object.keys(rowWithPk).filter((c) => !pkCols.includes(c)).map((c) => `"${c}" = excluded."${c}"`).join(", ");
1237
+ const values = Object.values(rowWithPk);
1238
+ this._adapter.run(
1239
+ `INSERT INTO "${table}" (${cols}) VALUES (${placeholders}) ON CONFLICT(${conflictCols}) DO UPDATE SET ${updateCols}`,
1240
+ values
1241
+ );
1242
+ const pkCol = pkCols[0] ?? "id";
1243
+ const rawPk = rowWithPk[pkCol];
1244
+ const pkValue = rawPk != null ? String(rawPk) : "";
1245
+ this._sanitizer.emitAudit(table, "update", pkValue);
1246
+ return Promise.resolve(pkValue);
1247
+ }
1248
+ upsertBy(table, col, val, row) {
1249
+ const notInit = this._notInitError();
1250
+ if (notInit) return notInit;
1251
+ const existing = this._adapter.get(`SELECT * FROM "${table}" WHERE "${col}" = ?`, [val]);
1252
+ if (existing) {
1253
+ const pkCols = this._schema.getPrimaryKey(table);
1254
+ const pkLookup = pkCols.length === 1 ? String(existing[pkCols[0] ?? "id"]) : Object.fromEntries(pkCols.map((c) => [c, existing[c]]));
1255
+ return this.update(table, pkLookup, row).then(
1256
+ () => typeof pkLookup === "string" ? pkLookup : JSON.stringify(pkLookup)
1257
+ );
1258
+ }
1259
+ return this.insert(table, { ...row, [col]: val });
1260
+ }
1261
+ update(table, id, row) {
1262
+ const notInit = this._notInitError();
1263
+ if (notInit) return notInit;
1264
+ const sanitized = this._filterToSchemaColumns(table, this._sanitizer.sanitizeRow(row));
1265
+ const setCols = Object.keys(sanitized).map((c) => `"${c}" = ?`).join(", ");
1266
+ const { clause, params: pkParams } = this._pkWhere(table, id);
1267
+ const values = [...Object.values(sanitized), ...pkParams];
1268
+ this._adapter.run(`UPDATE "${table}" SET ${setCols} WHERE ${clause}`, values);
1269
+ const auditId = typeof id === "string" ? id : JSON.stringify(id);
1270
+ this._sanitizer.emitAudit(table, "update", auditId);
1271
+ return Promise.resolve();
1272
+ }
1273
+ delete(table, id) {
1274
+ const notInit = this._notInitError();
1275
+ if (notInit) return notInit;
1276
+ const { clause, params } = this._pkWhere(table, id);
1277
+ this._adapter.run(`DELETE FROM "${table}" WHERE ${clause}`, params);
1278
+ const auditId = typeof id === "string" ? id : JSON.stringify(id);
1279
+ this._sanitizer.emitAudit(table, "delete", auditId);
1280
+ return Promise.resolve();
1281
+ }
1282
+ get(table, id) {
1283
+ const notInit = this._notInitError();
1284
+ if (notInit) return notInit;
1285
+ const { clause, params } = this._pkWhere(table, id);
1286
+ return Promise.resolve(
1287
+ this._adapter.get(`SELECT * FROM "${table}" WHERE ${clause}`, params) ?? null
1288
+ );
1289
+ }
1290
+ query(table, opts = {}) {
1291
+ const notInit = this._notInitError();
1292
+ if (notInit) return notInit;
1293
+ const colErr = this._invalidColumnError(table, [
1294
+ ...Object.keys(opts.where ?? {}),
1295
+ ...(opts.filters ?? []).map((f) => f.col),
1296
+ ...opts.orderBy ? [opts.orderBy] : []
1297
+ ]);
1298
+ if (colErr) return colErr;
1299
+ let sql = `SELECT * FROM "${table}"`;
1300
+ const params = [];
1301
+ const whereClauses = [];
1302
+ if (opts.where && Object.keys(opts.where).length > 0) {
1303
+ for (const [col, val] of Object.entries(opts.where)) {
1304
+ whereClauses.push(`"${col}" = ?`);
1305
+ params.push(val);
1306
+ }
1307
+ }
1308
+ if (opts.filters && opts.filters.length > 0) {
1309
+ const { clauses, params: fp } = this._buildFilters(opts.filters);
1310
+ whereClauses.push(...clauses);
1311
+ params.push(...fp);
1312
+ }
1313
+ if (whereClauses.length > 0) {
1314
+ sql += ` WHERE ${whereClauses.join(" AND ")}`;
1315
+ }
1316
+ if (opts.orderBy) {
1317
+ const dir = opts.orderDir === "desc" ? "DESC" : "ASC";
1318
+ sql += ` ORDER BY "${opts.orderBy}" ${dir}`;
1319
+ }
1320
+ if (opts.limit !== void 0) {
1321
+ sql += ` LIMIT ${opts.limit.toString()}`;
1322
+ }
1323
+ if (opts.offset !== void 0) {
1324
+ if (opts.limit === void 0) sql += " LIMIT -1";
1325
+ sql += ` OFFSET ${opts.offset.toString()}`;
1326
+ }
1327
+ return Promise.resolve(this._adapter.all(sql, params));
1328
+ }
1329
+ count(table, opts = {}) {
1330
+ const notInit = this._notInitError();
1331
+ if (notInit) return notInit;
1332
+ const colErr = this._invalidColumnError(table, [
1333
+ ...Object.keys(opts.where ?? {}),
1334
+ ...(opts.filters ?? []).map((f) => f.col)
1335
+ ]);
1336
+ if (colErr) return colErr;
1337
+ let sql = `SELECT COUNT(*) as n FROM "${table}"`;
1338
+ const params = [];
1339
+ const whereClauses = [];
1340
+ if (opts.where && Object.keys(opts.where).length > 0) {
1341
+ for (const [col, val] of Object.entries(opts.where)) {
1342
+ whereClauses.push(`"${col}" = ?`);
1343
+ params.push(val);
1344
+ }
1345
+ }
1346
+ if (opts.filters && opts.filters.length > 0) {
1347
+ const { clauses, params: fp } = this._buildFilters(opts.filters);
1348
+ whereClauses.push(...clauses);
1349
+ params.push(...fp);
1350
+ }
1351
+ if (whereClauses.length > 0) {
1352
+ sql += ` WHERE ${whereClauses.join(" AND ")}`;
1353
+ }
1354
+ const row = this._adapter.get(sql, params);
1355
+ return Promise.resolve(Number(row?.n ?? 0));
1356
+ }
1357
+ // -------------------------------------------------------------------------
1358
+ // Sync
1359
+ // -------------------------------------------------------------------------
1360
+ async render(outputDir) {
1361
+ const notInit = this._notInitError();
1362
+ if (notInit) return notInit;
1363
+ const result = await this._render.render(outputDir);
1364
+ for (const h of this._renderHandlers) h(result);
1365
+ return result;
1366
+ }
1367
+ async sync(outputDir) {
1368
+ const notInit = this._notInitError();
1369
+ if (notInit) return notInit;
1370
+ const renderResult = await this._render.render(outputDir);
1371
+ for (const h of this._renderHandlers) h(renderResult);
1372
+ const writebackProcessed = await this._writeback.process();
1373
+ return { ...renderResult, writebackProcessed };
1374
+ }
1375
+ async reconcile(outputDir, options = {}) {
1376
+ const notInit = this._notInitError();
1377
+ if (notInit) return notInit;
1378
+ const prevManifest = readManifest(outputDir);
1379
+ const renderResult = await this._render.render(outputDir);
1380
+ for (const h of this._renderHandlers) h(renderResult);
1381
+ const newManifest = readManifest(outputDir);
1382
+ const cleanup = this._render.cleanup(outputDir, prevManifest, options, newManifest);
1383
+ return { ...renderResult, cleanup };
1384
+ }
1385
+ watch(outputDir, opts = {}) {
1386
+ const notInit = this._notInitError();
1387
+ if (notInit) return notInit;
1388
+ const stop = this._loop.watch(outputDir, {
1389
+ ...opts,
1390
+ onRender: (result) => {
1391
+ opts.onRender?.(result);
1392
+ for (const h of this._renderHandlers) h(result);
1393
+ },
1394
+ onError: (err) => {
1395
+ opts.onError?.(err);
1396
+ for (const h of this._errorHandlers) h(err);
1397
+ }
1398
+ });
1399
+ return Promise.resolve(stop);
1400
+ }
1401
+ on(event, handler) {
1402
+ switch (event) {
1403
+ case "audit":
1404
+ this._auditHandlers.push(handler);
1405
+ break;
1406
+ case "render":
1407
+ this._renderHandlers.push(handler);
1408
+ break;
1409
+ case "writeback":
1410
+ this._writebackHandlers.push(
1411
+ handler
1412
+ );
1413
+ break;
1414
+ case "error":
1415
+ this._errorHandlers.push(handler);
1416
+ break;
1417
+ }
1418
+ return this;
1419
+ }
1420
+ // -------------------------------------------------------------------------
1421
+ // Escape hatch
1422
+ // -------------------------------------------------------------------------
1423
+ get db() {
1424
+ return this._adapter.db;
1425
+ }
1426
+ // -------------------------------------------------------------------------
1427
+ // Private helpers
1428
+ // -------------------------------------------------------------------------
1429
+ /**
1430
+ * Filter a sanitized row to only include columns that actually exist in the
1431
+ * table (verified via PRAGMA after init). Unregistered tables (accessed
1432
+ * through the raw `.db` handle) are passed through unchanged.
1433
+ *
1434
+ * This is a defence-in-depth guard: column names from caller-supplied `row`
1435
+ * objects are interpolated into SQL, so stripping unknown keys eliminates
1436
+ * any theoretical injection vector from crafted object keys.
1437
+ */
1438
+ _filterToSchemaColumns(table, row) {
1439
+ const cols = this._columnCache.get(table);
1440
+ if (!cols) return row;
1441
+ const keys = Object.keys(row);
1442
+ if (keys.every((k) => cols.has(k))) return row;
1443
+ return Object.fromEntries(keys.filter((k) => cols.has(k)).map((k) => [k, row[k]]));
1444
+ }
1445
+ /**
1446
+ * Build the WHERE clause and params for a PK lookup.
1447
+ * - `string` → matches against the table's first PK column.
1448
+ * - `Record` → matches every PK column; all must be present in the object.
1449
+ */
1450
+ _pkWhere(table, id) {
1451
+ const pkCols = this._schema.getPrimaryKey(table);
1452
+ if (typeof id === "string") {
1453
+ const firstCol = pkCols[0] ?? "id";
1454
+ return { clause: `"${firstCol}" = ?`, params: [id] };
1455
+ }
1456
+ const clauses = pkCols.map((col) => `"${col}" = ?`);
1457
+ const params = pkCols.map((col) => id[col]);
1458
+ return { clause: clauses.join(" AND "), params };
1459
+ }
1460
+ /**
1461
+ * Convert Filter objects into SQL clause strings and bound params.
1462
+ * An `in` filter with an empty array is silently ignored (produces no clause).
1463
+ */
1464
+ _buildFilters(filters) {
1465
+ const clauses = [];
1466
+ const params = [];
1467
+ for (const f of filters) {
1468
+ const col = `"${f.col}"`;
1469
+ switch (f.op) {
1470
+ case "eq":
1471
+ clauses.push(`${col} = ?`);
1472
+ params.push(f.val);
1473
+ break;
1474
+ case "ne":
1475
+ clauses.push(`${col} != ?`);
1476
+ params.push(f.val);
1477
+ break;
1478
+ case "gt":
1479
+ clauses.push(`${col} > ?`);
1480
+ params.push(f.val);
1481
+ break;
1482
+ case "gte":
1483
+ clauses.push(`${col} >= ?`);
1484
+ params.push(f.val);
1485
+ break;
1486
+ case "lt":
1487
+ clauses.push(`${col} < ?`);
1488
+ params.push(f.val);
1489
+ break;
1490
+ case "lte":
1491
+ clauses.push(`${col} <= ?`);
1492
+ params.push(f.val);
1493
+ break;
1494
+ case "like":
1495
+ clauses.push(`${col} LIKE ?`);
1496
+ params.push(f.val);
1497
+ break;
1498
+ case "in": {
1499
+ const list = f.val;
1500
+ if (Array.isArray(list) && list.length > 0) {
1501
+ clauses.push(`${col} IN (${list.map(() => "?").join(", ")})`);
1502
+ params.push(...list);
1503
+ }
1504
+ break;
1505
+ }
1506
+ case "isNull":
1507
+ clauses.push(`${col} IS NULL`);
1508
+ break;
1509
+ case "isNotNull":
1510
+ clauses.push(`${col} IS NOT NULL`);
1511
+ break;
1512
+ }
1513
+ }
1514
+ return { clauses, params };
1515
+ }
1516
+ /** Returns a rejected Promise if not initialized; null if ready. */
1517
+ _notInitError() {
1518
+ if (!this._initialized) {
1519
+ return Promise.reject(
1520
+ new Error("Lattice: call await db.init() before using CRUD or sync methods")
1521
+ );
1522
+ }
1523
+ return null;
1524
+ }
1525
+ /**
1526
+ * Returns a rejected Promise if any of the given column names are not present
1527
+ * in the table's schema; null if all columns are valid.
1528
+ *
1529
+ * Applied on the read path (query/count) to validate WHERE and filter column
1530
+ * names before they are interpolated into SQL. The write path strips unknown
1531
+ * columns via _filterToSchemaColumns; the read path rejects instead to avoid
1532
+ * silently discarding intended filter conditions.
1533
+ *
1534
+ * Unregistered tables (accessed via the raw `.db` handle) are passed through.
1535
+ */
1536
+ _invalidColumnError(table, cols) {
1537
+ const known = this._columnCache.get(table);
1538
+ if (!known) return null;
1539
+ for (const col of cols) {
1540
+ if (!known.has(col)) {
1541
+ return Promise.reject(
1542
+ new Error(`Lattice: unknown column "${col}" in table "${table}"`)
1543
+ );
1544
+ }
1545
+ }
1546
+ return null;
1547
+ }
1548
+ _assertNotInit(method) {
1549
+ if (this._initialized) {
1550
+ throw new Error(`Lattice: ${method}() must be called before init()`);
1551
+ }
1552
+ }
1553
+ };
1554
+
1555
+ // src/cli.ts
1556
+ function parseArgs(argv) {
1557
+ let command;
1558
+ let config = "./lattice.config.yml";
1559
+ let out = "./generated";
1560
+ let output = "./context";
1561
+ let scaffold = false;
1562
+ let help = false;
1563
+ let version = false;
1564
+ let dryRun = false;
1565
+ let noOrphanDirs = false;
1566
+ let noOrphanFiles = false;
1567
+ const protectedFiles = [];
1568
+ let interval = 5e3;
1569
+ let cleanup = false;
1570
+ let i = 0;
1571
+ if (argv[0] !== void 0 && !argv[0].startsWith("-")) {
1572
+ command = argv[0];
1573
+ i = 1;
1574
+ }
1575
+ while (i < argv.length) {
1576
+ const arg = argv[i];
1577
+ if (arg === "--help" || arg === "-h") {
1578
+ help = true;
1579
+ } else if (arg === "--version" || arg === "-v") {
1580
+ version = true;
1581
+ } else if ((arg === "--config" || arg === "-c") && i + 1 < argv.length) {
1582
+ i++;
1583
+ config = argv[i] ?? config;
1584
+ } else if ((arg === "--out" || arg === "-o") && i + 1 < argv.length) {
1585
+ i++;
1586
+ out = argv[i] ?? out;
1587
+ } else if ((arg === "--output" || arg === "--output-dir") && i + 1 < argv.length) {
1588
+ i++;
1589
+ output = argv[i] ?? output;
1590
+ } else if (arg === "--scaffold") {
1591
+ scaffold = true;
1592
+ } else if (arg === "--dry-run") {
1593
+ dryRun = true;
1594
+ } else if (arg === "--no-orphan-dirs") {
1595
+ noOrphanDirs = true;
1596
+ } else if (arg === "--no-orphan-files") {
1597
+ noOrphanFiles = true;
1598
+ } else if (arg === "--protected" && i + 1 < argv.length) {
1599
+ i++;
1600
+ const csv = argv[i] ?? "";
1601
+ protectedFiles.push(...csv.split(",").filter(Boolean));
1602
+ } else if (arg === "--interval" && i + 1 < argv.length) {
1603
+ i++;
1604
+ const parsed = parseInt(argv[i] ?? "5000", 10);
1605
+ if (!isNaN(parsed)) interval = parsed;
1606
+ } else if (arg === "--cleanup") {
1607
+ cleanup = true;
1608
+ }
1609
+ i++;
1610
+ }
1611
+ return {
1612
+ command,
1613
+ config,
1614
+ out,
1615
+ output,
1616
+ scaffold,
1617
+ help,
1618
+ version,
1619
+ dryRun,
1620
+ noOrphanDirs,
1621
+ noOrphanFiles,
1622
+ protected: protectedFiles,
1623
+ interval,
1624
+ cleanup
1625
+ };
1626
+ }
1627
+ function printHelp() {
1628
+ console.log(
1629
+ [
1630
+ "lattice \u2014 latticesql CLI",
1631
+ "",
1632
+ "Usage:",
1633
+ " lattice <command> [options]",
1634
+ "",
1635
+ "Commands:",
1636
+ " generate Generate TypeScript types, SQL migration, and scaffold files",
1637
+ " render One-shot context generation (writes entity context directories)",
1638
+ " reconcile Render + cleanup orphaned entity directories and files",
1639
+ " status Dry-run reconcile \u2014 show what would change without writing",
1640
+ " watch Poll for changes and re-render on each cycle",
1641
+ "",
1642
+ "Options (generate):",
1643
+ " --config, -c <path> Path to config file (default: ./lattice.config.yml)",
1644
+ " --out, -o <dir> Output directory for generated files (default: ./generated)",
1645
+ " --scaffold Also create empty scaffold render output files",
1646
+ "",
1647
+ "Options (render):",
1648
+ " --config, -c <path> Path to config file (default: ./lattice.config.yml)",
1649
+ " --output <dir> Output directory for rendered context (default: ./context)",
1650
+ "",
1651
+ "Options (reconcile):",
1652
+ " --config, -c <path> Path to config file (default: ./lattice.config.yml)",
1653
+ " --output <dir> Output directory for rendered context (default: ./context)",
1654
+ " --dry-run Report orphans but do not delete anything",
1655
+ " --no-orphan-dirs Skip removal of orphaned entity directories",
1656
+ " --no-orphan-files Skip removal of orphaned files inside entity dirs",
1657
+ " --protected <csv> Comma-separated list of protected filenames",
1658
+ "",
1659
+ "Options (status):",
1660
+ " --config, -c <path> Path to config file (default: ./lattice.config.yml)",
1661
+ " --output <dir> Output directory for rendered context (default: ./context)",
1662
+ "",
1663
+ "Options (watch):",
1664
+ " --config, -c <path> Path to config file (default: ./lattice.config.yml)",
1665
+ " --output <dir> Output directory for rendered context (default: ./context)",
1666
+ " --interval <ms> Poll interval in milliseconds (default: 5000)",
1667
+ " --cleanup Enable orphan cleanup after each render cycle",
1668
+ " --no-orphan-dirs Skip removal of orphaned entity directories (with --cleanup)",
1669
+ " --no-orphan-files Skip removal of orphaned files inside entity dirs (with --cleanup)",
1670
+ " --protected <csv> Comma-separated list of protected filenames (with --cleanup)",
1671
+ "",
1672
+ "Options (global):",
1673
+ " --help, -h Show this help message",
1674
+ " --version, -v Print the version number"
1675
+ ].join("\n")
1676
+ );
1677
+ }
1678
+ function printVersion() {
1679
+ try {
1680
+ const pkgPath = new URL("../package.json", import.meta.url).pathname;
1681
+ const pkg = JSON.parse(readFileSync5(pkgPath, "utf-8"));
1682
+ console.log(pkg.version);
1683
+ } catch {
1684
+ console.log("unknown");
1685
+ }
1686
+ }
1687
+ function runGenerate(args) {
1688
+ const configPath = resolve3(args.config);
1689
+ let raw;
1690
+ try {
1691
+ raw = readFileSync5(configPath, "utf-8");
1692
+ } catch {
1693
+ console.error(`Error: cannot read config file at "${configPath}"`);
1694
+ process.exit(1);
1695
+ }
1696
+ let config;
1697
+ try {
1698
+ config = parse2(raw);
1699
+ } catch (e) {
1700
+ console.error(`Error: YAML parse error in "${configPath}": ${e.message}`);
1701
+ process.exit(1);
1702
+ }
1703
+ if (!config.entities) {
1704
+ console.error('Error: config must have an "entities" key');
1705
+ process.exit(1);
1706
+ }
1707
+ const configDir = dirname5(configPath);
1708
+ const outDir = resolve3(args.out);
1709
+ try {
1710
+ const result = generateAll({ config, configDir, outDir, scaffold: args.scaffold });
1711
+ console.log(`Generated ${String(result.filesWritten.length)} file(s):`);
1712
+ for (const f of result.filesWritten) {
1713
+ console.log(` \u2713 ${f}`);
1714
+ }
1715
+ } catch (e) {
1716
+ console.error(`Error: ${e.message}`);
1717
+ process.exit(1);
1718
+ }
1719
+ }
1720
+ async function runRender(args) {
1721
+ const outputDir = resolve3(args.output);
1722
+ let parsed;
1723
+ try {
1724
+ parsed = parseConfigFile(resolve3(args.config));
1725
+ } catch (e) {
1726
+ console.error(`Error: ${e.message}`);
1727
+ process.exit(1);
1728
+ }
1729
+ const db = new Lattice({ config: resolve3(args.config) });
1730
+ try {
1731
+ await db.init();
1732
+ const start = Date.now();
1733
+ const result = await db.render(outputDir);
1734
+ const durationMs = Date.now() - start;
1735
+ console.log(`Rendered ${String(result.filesWritten.length)} files in ${String(durationMs)}ms`);
1736
+ for (const f of result.filesWritten) {
1737
+ console.log(` \u2713 ${f}`);
1738
+ }
1739
+ } catch (e) {
1740
+ console.error(`Error: ${e.message}`);
1741
+ process.exit(1);
1742
+ } finally {
1743
+ db.close();
1744
+ }
1745
+ void parsed;
1746
+ }
1747
+ async function runReconcile(args, isDryRun) {
1748
+ const outputDir = resolve3(args.output);
1749
+ const db = new Lattice({ config: resolve3(args.config) });
1750
+ try {
1751
+ await db.init();
1752
+ const start = Date.now();
1753
+ const reconcileOpts = {
1754
+ dryRun: isDryRun,
1755
+ removeOrphanedDirectories: !args.noOrphanDirs,
1756
+ removeOrphanedFiles: !args.noOrphanFiles
1757
+ };
1758
+ if (args.protected.length > 0) {
1759
+ reconcileOpts.protectedFiles = args.protected;
1760
+ }
1761
+ const result = await db.reconcile(outputDir, reconcileOpts);
1762
+ const durationMs = Date.now() - start;
1763
+ if (isDryRun) {
1764
+ console.log("DRY RUN \u2014 no changes made");
1765
+ }
1766
+ console.log(`Rendered ${String(result.filesWritten.length)} files in ${String(durationMs)}ms`);
1767
+ for (const f of result.filesWritten) {
1768
+ console.log(` \u2713 ${f}`);
1769
+ }
1770
+ const { cleanup } = result;
1771
+ const totalRemoved = cleanup.directoriesRemoved.length + cleanup.filesRemoved.length;
1772
+ if (totalRemoved > 0 || cleanup.directoriesSkipped.length > 0) {
1773
+ console.log(
1774
+ `Cleanup: removed ${String(cleanup.directoriesRemoved.length)} directories, ${String(cleanup.filesRemoved.length)} files`
1775
+ );
1776
+ for (const d of cleanup.directoriesRemoved) {
1777
+ console.log(` \u2713 Removed ${d}`);
1778
+ }
1779
+ for (const f of cleanup.filesRemoved) {
1780
+ console.log(` \u2713 Removed ${f}`);
1781
+ }
1782
+ for (const d of cleanup.directoriesSkipped) {
1783
+ console.log(` \u2717 Left ${d} (protected files remain)`);
1784
+ }
1785
+ }
1786
+ if (cleanup.warnings.length > 0) {
1787
+ console.log(`Warnings: ${String(cleanup.warnings.length)}`);
1788
+ for (const w of cleanup.warnings) {
1789
+ console.warn(` ! ${w}`);
1790
+ }
1791
+ process.exit(1);
1792
+ }
1793
+ } catch (e) {
1794
+ console.error(`Error: ${e.message}`);
1795
+ process.exit(1);
1796
+ } finally {
1797
+ db.close();
1798
+ }
1799
+ }
1800
+ function formatTimestamp() {
1801
+ const now = /* @__PURE__ */ new Date();
1802
+ const hh = String(now.getHours()).padStart(2, "0");
1803
+ const mm = String(now.getMinutes()).padStart(2, "0");
1804
+ const ss = String(now.getSeconds()).padStart(2, "0");
1805
+ return `${hh}:${mm}:${ss}`;
1806
+ }
1807
+ async function runWatch(args) {
1808
+ const outputDir = resolve3(args.output);
1809
+ const db = new Lattice({ config: resolve3(args.config) });
1810
+ try {
1811
+ await db.init();
1812
+ } catch (e) {
1813
+ console.error(`Error: ${e.message}`);
1814
+ process.exit(1);
1815
+ }
1816
+ const cleanupOpts = args.cleanup ? {
1817
+ removeOrphanedDirectories: !args.noOrphanDirs,
1818
+ removeOrphanedFiles: !args.noOrphanFiles,
1819
+ ...args.protected.length > 0 ? { protectedFiles: args.protected } : {}
1820
+ } : void 0;
1821
+ const stop = await db.watch(outputDir, {
1822
+ interval: args.interval,
1823
+ onRender: (result) => {
1824
+ console.log(
1825
+ `[${formatTimestamp()}] Rendered ${String(result.filesWritten.length)} files in ${String(result.durationMs)}ms`
1826
+ );
1827
+ },
1828
+ onError: (err) => {
1829
+ console.error(`[${formatTimestamp()}] Error: ${err.message}`);
1830
+ },
1831
+ ...cleanupOpts !== void 0 ? { cleanup: cleanupOpts } : {},
1832
+ ...cleanupOpts !== void 0 ? {
1833
+ onCleanup: (result) => {
1834
+ console.log(
1835
+ `[${formatTimestamp()}] Cleanup: removed ${String(result.directoriesRemoved.length)} dirs, ${String(result.filesRemoved.length)} files`
1836
+ );
1837
+ }
1838
+ } : {}
1839
+ });
1840
+ const shutdown = () => {
1841
+ stop();
1842
+ db.close();
1843
+ process.exit(0);
1844
+ };
1845
+ process.on("SIGINT", shutdown);
1846
+ process.on("SIGTERM", shutdown);
1847
+ }
1848
+ function main() {
1849
+ const args = parseArgs(process.argv.slice(2));
1850
+ if (args.version) {
1851
+ printVersion();
1852
+ return;
1853
+ }
1854
+ if (args.help || args.command === void 0) {
1855
+ printHelp();
1856
+ process.exit(args.command === void 0 && !args.help ? 1 : 0);
1857
+ }
1858
+ switch (args.command) {
1859
+ case "generate":
1860
+ runGenerate(args);
1861
+ break;
1862
+ case "render":
1863
+ void runRender(args);
1864
+ break;
1865
+ case "reconcile":
1866
+ void runReconcile(args, args.dryRun);
1867
+ break;
1868
+ case "status":
1869
+ void runReconcile(args, true);
1870
+ break;
1871
+ case "watch":
1872
+ void runWatch(args);
1873
+ break;
1874
+ default:
1875
+ console.error(`Unknown command: ${args.command}`);
1876
+ printHelp();
1877
+ process.exit(1);
1878
+ }
1879
+ }
1880
+ main();