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/LICENSE +182 -0
- package/README.md +1360 -0
- package/dist/cli.js +1880 -0
- package/dist/index.cjs +1514 -0
- package/dist/index.d.cts +869 -0
- package/dist/index.d.ts +869 -0
- package/dist/index.js +1472 -0
- package/package.json +79 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1514 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
Lattice: () => Lattice,
|
|
34
|
+
manifestPath: () => manifestPath,
|
|
35
|
+
parseConfigFile: () => parseConfigFile,
|
|
36
|
+
parseConfigString: () => parseConfigString,
|
|
37
|
+
readManifest: () => readManifest,
|
|
38
|
+
writeManifest: () => writeManifest
|
|
39
|
+
});
|
|
40
|
+
module.exports = __toCommonJS(index_exports);
|
|
41
|
+
|
|
42
|
+
// src/lattice.ts
|
|
43
|
+
var import_uuid = require("uuid");
|
|
44
|
+
|
|
45
|
+
// src/lifecycle/manifest.ts
|
|
46
|
+
var import_node_path2 = require("path");
|
|
47
|
+
var import_node_fs2 = require("fs");
|
|
48
|
+
|
|
49
|
+
// src/render/writer.ts
|
|
50
|
+
var import_node_fs = require("fs");
|
|
51
|
+
var import_node_crypto = require("crypto");
|
|
52
|
+
var import_node_path = require("path");
|
|
53
|
+
var import_node_os = require("os");
|
|
54
|
+
var import_node_crypto2 = require("crypto");
|
|
55
|
+
function atomicWrite(filePath, content) {
|
|
56
|
+
const dir = (0, import_node_path.dirname)(filePath);
|
|
57
|
+
(0, import_node_fs.mkdirSync)(dir, { recursive: true });
|
|
58
|
+
const currentHash = existingHash(filePath);
|
|
59
|
+
const newHash = contentHash(content);
|
|
60
|
+
if (currentHash === newHash) return false;
|
|
61
|
+
const tmp = (0, import_node_path.join)((0, import_node_os.tmpdir)(), `lattice-${(0, import_node_crypto2.randomBytes)(8).toString("hex")}.tmp`);
|
|
62
|
+
(0, import_node_fs.writeFileSync)(tmp, content, "utf8");
|
|
63
|
+
(0, import_node_fs.renameSync)(tmp, filePath);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
function existingHash(filePath) {
|
|
67
|
+
if (!(0, import_node_fs.existsSync)(filePath)) return null;
|
|
68
|
+
try {
|
|
69
|
+
return contentHash((0, import_node_fs.readFileSync)(filePath, "utf8"));
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function contentHash(content) {
|
|
75
|
+
return (0, import_node_crypto.createHash)("sha256").update(content).digest("hex");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// src/lifecycle/manifest.ts
|
|
79
|
+
function manifestPath(outputDir) {
|
|
80
|
+
return (0, import_node_path2.join)(outputDir, ".lattice", "manifest.json");
|
|
81
|
+
}
|
|
82
|
+
function readManifest(outputDir) {
|
|
83
|
+
const path = manifestPath(outputDir);
|
|
84
|
+
if (!(0, import_node_fs2.existsSync)(path)) return null;
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse((0, import_node_fs2.readFileSync)(path, "utf8"));
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function writeManifest(outputDir, manifest) {
|
|
92
|
+
atomicWrite(manifestPath(outputDir), JSON.stringify(manifest, null, 2));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/db/sqlite.ts
|
|
96
|
+
var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1);
|
|
97
|
+
var SQLiteAdapter = class {
|
|
98
|
+
_db = null;
|
|
99
|
+
_path;
|
|
100
|
+
_wal;
|
|
101
|
+
_busyTimeout;
|
|
102
|
+
constructor(path, options) {
|
|
103
|
+
this._path = path;
|
|
104
|
+
this._wal = options?.wal ?? true;
|
|
105
|
+
this._busyTimeout = options?.busyTimeout ?? 5e3;
|
|
106
|
+
}
|
|
107
|
+
get db() {
|
|
108
|
+
if (!this._db) throw new Error("SQLiteAdapter: not open \u2014 call open() first");
|
|
109
|
+
return this._db;
|
|
110
|
+
}
|
|
111
|
+
open() {
|
|
112
|
+
this._db = new import_better_sqlite3.default(this._path);
|
|
113
|
+
this._db.pragma(`busy_timeout = ${this._busyTimeout.toString()}`);
|
|
114
|
+
if (this._wal) {
|
|
115
|
+
this._db.pragma("journal_mode = WAL");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
close() {
|
|
119
|
+
this._db?.close();
|
|
120
|
+
this._db = null;
|
|
121
|
+
}
|
|
122
|
+
run(sql, params = []) {
|
|
123
|
+
this.db.prepare(sql).run(...params);
|
|
124
|
+
}
|
|
125
|
+
get(sql, params = []) {
|
|
126
|
+
return this.db.prepare(sql).get(...params);
|
|
127
|
+
}
|
|
128
|
+
all(sql, params = []) {
|
|
129
|
+
return this.db.prepare(sql).all(...params);
|
|
130
|
+
}
|
|
131
|
+
prepare(sql) {
|
|
132
|
+
const stmt = this.db.prepare(sql);
|
|
133
|
+
return {
|
|
134
|
+
run: (...params) => stmt.run(...params),
|
|
135
|
+
get: (...params) => stmt.get(...params),
|
|
136
|
+
all: (...params) => stmt.all(...params)
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// src/schema/manager.ts
|
|
142
|
+
var SchemaManager = class {
|
|
143
|
+
_tables = /* @__PURE__ */ new Map();
|
|
144
|
+
/** Normalised primary key columns per table (always an array). */
|
|
145
|
+
_tablePK = /* @__PURE__ */ new Map();
|
|
146
|
+
_multis = /* @__PURE__ */ new Map();
|
|
147
|
+
_entityContexts = /* @__PURE__ */ new Map();
|
|
148
|
+
define(table, def) {
|
|
149
|
+
if (this._tables.has(table)) {
|
|
150
|
+
throw new Error(`Table "${table}" is already defined`);
|
|
151
|
+
}
|
|
152
|
+
this._tables.set(table, def);
|
|
153
|
+
if (def.primaryKey === void 0 || def.primaryKey === "id") {
|
|
154
|
+
this._tablePK.set(table, ["id"]);
|
|
155
|
+
} else if (Array.isArray(def.primaryKey)) {
|
|
156
|
+
if (def.primaryKey.length === 0) {
|
|
157
|
+
throw new Error(`Table "${table}": primaryKey array must not be empty`);
|
|
158
|
+
}
|
|
159
|
+
this._tablePK.set(table, def.primaryKey);
|
|
160
|
+
} else {
|
|
161
|
+
this._tablePK.set(table, [def.primaryKey]);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
defineMulti(name, def) {
|
|
165
|
+
if (this._multis.has(name)) {
|
|
166
|
+
throw new Error(`Multi-render "${name}" is already defined`);
|
|
167
|
+
}
|
|
168
|
+
this._multis.set(name, def);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Register an entity context definition.
|
|
172
|
+
* Throws if a context for the same table has already been registered.
|
|
173
|
+
*/
|
|
174
|
+
defineEntityContext(table, def) {
|
|
175
|
+
if (this._entityContexts.has(table)) {
|
|
176
|
+
throw new Error(`Entity context for table "${table}" is already defined`);
|
|
177
|
+
}
|
|
178
|
+
this._entityContexts.set(table, def);
|
|
179
|
+
}
|
|
180
|
+
getTables() {
|
|
181
|
+
return this._tables;
|
|
182
|
+
}
|
|
183
|
+
getMultis() {
|
|
184
|
+
return this._multis;
|
|
185
|
+
}
|
|
186
|
+
getEntityContexts() {
|
|
187
|
+
return this._entityContexts;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Return the normalised primary key column list for a table.
|
|
191
|
+
* Falls back to `['id']` for tables that were not registered via `define()`
|
|
192
|
+
* (e.g. tables accessed through the raw `.db` escape hatch).
|
|
193
|
+
*/
|
|
194
|
+
getPrimaryKey(table) {
|
|
195
|
+
return this._tablePK.get(table) ?? ["id"];
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Return the declared relationships for a table, keyed by relation name.
|
|
199
|
+
* Returns an empty object for tables with no `relations` definition.
|
|
200
|
+
*/
|
|
201
|
+
getRelations(table) {
|
|
202
|
+
return this._tables.get(table)?.relations ?? {};
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Apply schema: create missing tables, add missing columns.
|
|
206
|
+
* Never drops tables or columns.
|
|
207
|
+
*/
|
|
208
|
+
applySchema(adapter) {
|
|
209
|
+
for (const [name, def] of this._tables) {
|
|
210
|
+
this._ensureTable(adapter, name, def.columns, def.tableConstraints);
|
|
211
|
+
}
|
|
212
|
+
this._ensureTable(adapter, "__lattice_migrations", {
|
|
213
|
+
version: "INTEGER PRIMARY KEY",
|
|
214
|
+
applied_at: "TEXT NOT NULL"
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
/** Run explicit versioned migrations in order, idempotently */
|
|
218
|
+
applyMigrations(adapter, migrations) {
|
|
219
|
+
const sorted = [...migrations].sort((a, b) => a.version - b.version);
|
|
220
|
+
for (const m of sorted) {
|
|
221
|
+
const exists = adapter.get("SELECT 1 FROM __lattice_migrations WHERE version = ?", [
|
|
222
|
+
m.version
|
|
223
|
+
]);
|
|
224
|
+
if (!exists) {
|
|
225
|
+
adapter.run(m.sql);
|
|
226
|
+
adapter.run("INSERT INTO __lattice_migrations (version, applied_at) VALUES (?, ?)", [
|
|
227
|
+
m.version,
|
|
228
|
+
(/* @__PURE__ */ new Date()).toISOString()
|
|
229
|
+
]);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** Query all rows from a registered table */
|
|
234
|
+
queryTable(adapter, name) {
|
|
235
|
+
if (!this._tables.has(name)) {
|
|
236
|
+
throw new Error(`Unknown table: "${name}"`);
|
|
237
|
+
}
|
|
238
|
+
return adapter.all(`SELECT * FROM "${name}"`);
|
|
239
|
+
}
|
|
240
|
+
_ensureTable(adapter, name, columns, tableConstraints) {
|
|
241
|
+
const colDefs = Object.entries(columns).map(([col, type]) => `"${col}" ${type}`).join(", ");
|
|
242
|
+
const constraintDefs = tableConstraints && tableConstraints.length > 0 ? ", " + tableConstraints.join(", ") : "";
|
|
243
|
+
adapter.run(`CREATE TABLE IF NOT EXISTS "${name}" (${colDefs}${constraintDefs})`);
|
|
244
|
+
this._addMissingColumns(adapter, name, columns);
|
|
245
|
+
}
|
|
246
|
+
_addMissingColumns(adapter, table, columns) {
|
|
247
|
+
const existing = adapter.all(`PRAGMA table_info("${table}")`).map((r) => r.name);
|
|
248
|
+
for (const [col, type] of Object.entries(columns)) {
|
|
249
|
+
if (!existing.includes(col)) {
|
|
250
|
+
adapter.run(`ALTER TABLE "${table}" ADD COLUMN "${col}" ${type}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// src/security/sanitize.ts
|
|
257
|
+
var SUSPICIOUS_PATTERNS = [
|
|
258
|
+
/(\bdrop\b|\bdelete\b|\btruncate\b|\binsert\b|\bupdate\b)\s+\b(table|from|into)\b/i,
|
|
259
|
+
/<script[\s\S]*?>/i,
|
|
260
|
+
/javascript:/i,
|
|
261
|
+
/\.\.[/\\]/,
|
|
262
|
+
// Intentional null-byte check — eslint-disable to allow control char in regex
|
|
263
|
+
// eslint-disable-next-line no-control-regex
|
|
264
|
+
/\x00/
|
|
265
|
+
];
|
|
266
|
+
var NULL_BYTE_RE = /\x00/g;
|
|
267
|
+
var CONTROL_CHAR_RE = /[\x01-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
|
|
268
|
+
var Sanitizer = class {
|
|
269
|
+
_options;
|
|
270
|
+
_auditHandlers = [];
|
|
271
|
+
constructor(options = {}) {
|
|
272
|
+
this._options = {
|
|
273
|
+
sanitize: options.sanitize ?? true,
|
|
274
|
+
auditTables: options.auditTables ?? [],
|
|
275
|
+
fieldLimits: options.fieldLimits ?? {}
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
onAudit(handler) {
|
|
279
|
+
this._auditHandlers.push(handler);
|
|
280
|
+
}
|
|
281
|
+
sanitizeRow(row) {
|
|
282
|
+
if (!this._options.sanitize) return row;
|
|
283
|
+
const out = {};
|
|
284
|
+
for (const [key, val] of Object.entries(row)) {
|
|
285
|
+
if (typeof val === "string") {
|
|
286
|
+
let s = val.replace(NULL_BYTE_RE, "").replace(CONTROL_CHAR_RE, "");
|
|
287
|
+
const limit = this._options.fieldLimits[key];
|
|
288
|
+
if (limit !== void 0 && s.length > limit) {
|
|
289
|
+
s = s.slice(0, limit);
|
|
290
|
+
}
|
|
291
|
+
for (const pattern of SUSPICIOUS_PATTERNS) {
|
|
292
|
+
if (pattern.test(s)) {
|
|
293
|
+
console.warn(`[lattice/security] Suspicious content in field "${key}"`);
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
out[key] = s;
|
|
298
|
+
} else {
|
|
299
|
+
out[key] = val;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
emitAudit(table, operation, id) {
|
|
305
|
+
if (!this._options.auditTables.includes(table)) return;
|
|
306
|
+
const event = {
|
|
307
|
+
table,
|
|
308
|
+
operation,
|
|
309
|
+
id,
|
|
310
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
311
|
+
};
|
|
312
|
+
for (const handler of this._auditHandlers) {
|
|
313
|
+
handler(event);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
isAuditTable(table) {
|
|
317
|
+
return this._options.auditTables.includes(table);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// src/render/engine.ts
|
|
322
|
+
var import_node_path4 = require("path");
|
|
323
|
+
var import_node_fs4 = require("fs");
|
|
324
|
+
|
|
325
|
+
// src/render/entity-query.ts
|
|
326
|
+
function resolveEntitySource(source, entityRow, entityPk, adapter) {
|
|
327
|
+
switch (source.type) {
|
|
328
|
+
case "self":
|
|
329
|
+
return [entityRow];
|
|
330
|
+
case "hasMany": {
|
|
331
|
+
const ref = source.references ?? entityPk;
|
|
332
|
+
const pkVal = entityRow[ref];
|
|
333
|
+
return adapter.all(
|
|
334
|
+
`SELECT * FROM "${source.table}" WHERE "${source.foreignKey}" = ?`,
|
|
335
|
+
[pkVal]
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
case "manyToMany": {
|
|
339
|
+
const pkVal = entityRow[entityPk];
|
|
340
|
+
const remotePk = source.references ?? "id";
|
|
341
|
+
return adapter.all(
|
|
342
|
+
`SELECT r.* FROM "${source.remoteTable}" r
|
|
343
|
+
JOIN "${source.junctionTable}" j ON j."${source.remoteKey}" = r."${remotePk}"
|
|
344
|
+
WHERE j."${source.localKey}" = ?`,
|
|
345
|
+
[pkVal]
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
case "belongsTo": {
|
|
349
|
+
const fkVal = entityRow[source.foreignKey];
|
|
350
|
+
if (fkVal == null) return [];
|
|
351
|
+
const related = adapter.get(
|
|
352
|
+
`SELECT * FROM "${source.table}" WHERE "${source.references ?? "id"}" = ?`,
|
|
353
|
+
[fkVal]
|
|
354
|
+
);
|
|
355
|
+
return related ? [related] : [];
|
|
356
|
+
}
|
|
357
|
+
case "custom":
|
|
358
|
+
return source.query(entityRow, adapter);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function truncateContent(content, budget) {
|
|
362
|
+
if (budget === void 0 || content.length <= budget) return content;
|
|
363
|
+
return content.slice(0, budget) + "\n\n*[truncated \u2014 context budget exceeded]*";
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// src/lifecycle/cleanup.ts
|
|
367
|
+
var import_node_path3 = require("path");
|
|
368
|
+
var import_node_fs3 = require("fs");
|
|
369
|
+
function cleanupEntityContexts(outputDir, entityContexts, currentSlugsByTable, manifest, options = {}, newManifest) {
|
|
370
|
+
const result = {
|
|
371
|
+
directoriesRemoved: [],
|
|
372
|
+
filesRemoved: [],
|
|
373
|
+
directoriesSkipped: [],
|
|
374
|
+
warnings: []
|
|
375
|
+
};
|
|
376
|
+
if (manifest === null) return result;
|
|
377
|
+
for (const [table, def] of entityContexts) {
|
|
378
|
+
const entry = manifest.entityContexts[table];
|
|
379
|
+
if (!entry) continue;
|
|
380
|
+
const directoryRoot = entry.directoryRoot;
|
|
381
|
+
const currentSlugs = currentSlugsByTable.get(table) ?? /* @__PURE__ */ new Set();
|
|
382
|
+
const globalProtected = /* @__PURE__ */ new Set([
|
|
383
|
+
...def.protectedFiles ?? [],
|
|
384
|
+
...options.protectedFiles ?? []
|
|
385
|
+
]);
|
|
386
|
+
const rootPath = (0, import_node_path3.join)(outputDir, directoryRoot);
|
|
387
|
+
if (!(0, import_node_fs3.existsSync)(rootPath)) continue;
|
|
388
|
+
if (options.removeOrphanedDirectories !== false && !def.directory) {
|
|
389
|
+
let actualDirs;
|
|
390
|
+
try {
|
|
391
|
+
actualDirs = (0, import_node_fs3.readdirSync)(rootPath).filter((name) => {
|
|
392
|
+
try {
|
|
393
|
+
return (0, import_node_fs3.statSync)((0, import_node_path3.join)(rootPath, name)).isDirectory();
|
|
394
|
+
} catch {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
} catch {
|
|
399
|
+
actualDirs = [];
|
|
400
|
+
}
|
|
401
|
+
for (const dirName of actualDirs) {
|
|
402
|
+
if (currentSlugs.has(dirName)) continue;
|
|
403
|
+
if (!Object.prototype.hasOwnProperty.call(entry.entities, dirName)) continue;
|
|
404
|
+
const entityDir = (0, import_node_path3.join)(rootPath, dirName);
|
|
405
|
+
const managedFiles = entry.entities[dirName] ?? [];
|
|
406
|
+
for (const filename of managedFiles) {
|
|
407
|
+
if (globalProtected.has(filename)) continue;
|
|
408
|
+
const filePath = (0, import_node_path3.join)(entityDir, filename);
|
|
409
|
+
if (!(0, import_node_fs3.existsSync)(filePath)) continue;
|
|
410
|
+
if (!options.dryRun) (0, import_node_fs3.unlinkSync)(filePath);
|
|
411
|
+
options.onOrphan?.(filePath, "file");
|
|
412
|
+
result.filesRemoved.push(filePath);
|
|
413
|
+
}
|
|
414
|
+
let remaining;
|
|
415
|
+
try {
|
|
416
|
+
remaining = (0, import_node_fs3.existsSync)(entityDir) ? (0, import_node_fs3.readdirSync)(entityDir) : [];
|
|
417
|
+
} catch {
|
|
418
|
+
remaining = [];
|
|
419
|
+
}
|
|
420
|
+
if (remaining.length === 0) {
|
|
421
|
+
if (!options.dryRun) {
|
|
422
|
+
try {
|
|
423
|
+
(0, import_node_fs3.rmdirSync)(entityDir);
|
|
424
|
+
} catch {
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
options.onOrphan?.(entityDir, "directory");
|
|
428
|
+
result.directoriesRemoved.push(entityDir);
|
|
429
|
+
} else {
|
|
430
|
+
result.directoriesSkipped.push(entityDir);
|
|
431
|
+
result.warnings.push(
|
|
432
|
+
`${entityDir}: left in place (contains user files: ${remaining.join(", ")})`
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (options.removeOrphanedFiles !== false) {
|
|
438
|
+
const newEntry = newManifest?.entityContexts[table];
|
|
439
|
+
const declaredFiles = new Set(Object.keys(def.files));
|
|
440
|
+
if (def.combined) declaredFiles.add(def.combined.outputFile);
|
|
441
|
+
for (const slug of currentSlugs) {
|
|
442
|
+
const entityDir = def.directory ? null : (0, import_node_path3.join)(rootPath, slug);
|
|
443
|
+
if (!entityDir || !(0, import_node_fs3.existsSync)(entityDir)) continue;
|
|
444
|
+
const previouslyWritten = entry.entities[slug] ?? [];
|
|
445
|
+
const currentlyWritten = new Set(newEntry?.entities[slug] ?? []);
|
|
446
|
+
for (const filename of previouslyWritten) {
|
|
447
|
+
if (newEntry !== void 0) {
|
|
448
|
+
if (currentlyWritten.has(filename)) continue;
|
|
449
|
+
} else {
|
|
450
|
+
if (declaredFiles.has(filename)) continue;
|
|
451
|
+
}
|
|
452
|
+
if (globalProtected.has(filename)) continue;
|
|
453
|
+
const filePath = (0, import_node_path3.join)(entityDir, filename);
|
|
454
|
+
if (!(0, import_node_fs3.existsSync)(filePath)) continue;
|
|
455
|
+
if (!options.dryRun) (0, import_node_fs3.unlinkSync)(filePath);
|
|
456
|
+
options.onOrphan?.(filePath, "file");
|
|
457
|
+
result.filesRemoved.push(filePath);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return result;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// src/render/engine.ts
|
|
466
|
+
var RenderEngine = class {
|
|
467
|
+
_schema;
|
|
468
|
+
_adapter;
|
|
469
|
+
constructor(schema, adapter) {
|
|
470
|
+
this._schema = schema;
|
|
471
|
+
this._adapter = adapter;
|
|
472
|
+
}
|
|
473
|
+
async render(outputDir) {
|
|
474
|
+
const start = Date.now();
|
|
475
|
+
const filesWritten = [];
|
|
476
|
+
const counters = { skipped: 0 };
|
|
477
|
+
for (const [name, def] of this._schema.getTables()) {
|
|
478
|
+
let rows = this._schema.queryTable(this._adapter, name);
|
|
479
|
+
if (def.filter) rows = def.filter(rows);
|
|
480
|
+
const content = def.render(rows);
|
|
481
|
+
const filePath = (0, import_node_path4.join)(outputDir, def.outputFile);
|
|
482
|
+
if (atomicWrite(filePath, content)) {
|
|
483
|
+
filesWritten.push(filePath);
|
|
484
|
+
} else {
|
|
485
|
+
counters.skipped++;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
for (const [, def] of this._schema.getMultis()) {
|
|
489
|
+
const keys = await def.keys();
|
|
490
|
+
const tables = {};
|
|
491
|
+
if (def.tables) {
|
|
492
|
+
for (const t of def.tables) {
|
|
493
|
+
tables[t] = this._schema.queryTable(this._adapter, t);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
for (const key of keys) {
|
|
497
|
+
const content = def.render(key, tables);
|
|
498
|
+
const filePath = (0, import_node_path4.join)(outputDir, def.outputFile(key));
|
|
499
|
+
if (atomicWrite(filePath, content)) {
|
|
500
|
+
filesWritten.push(filePath);
|
|
501
|
+
} else {
|
|
502
|
+
counters.skipped++;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const entityContextManifest = this._renderEntityContexts(outputDir, filesWritten, counters);
|
|
507
|
+
if (this._schema.getEntityContexts().size > 0) {
|
|
508
|
+
writeManifest(outputDir, {
|
|
509
|
+
version: 1,
|
|
510
|
+
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
511
|
+
entityContexts: entityContextManifest
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
filesWritten,
|
|
516
|
+
filesSkipped: counters.skipped,
|
|
517
|
+
durationMs: Date.now() - start
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Run orphan cleanup using the previous manifest.
|
|
522
|
+
* Called by reconcile() and optionally by the watch loop.
|
|
523
|
+
*
|
|
524
|
+
* @param newManifest - Optional: the manifest just written by render().
|
|
525
|
+
* When provided, step 2 (stale files in surviving entity dirs) compares
|
|
526
|
+
* old vs new manifest entries, catching omitIfEmpty files that were written
|
|
527
|
+
* before but skipped in the current render cycle.
|
|
528
|
+
*/
|
|
529
|
+
cleanup(outputDir, prevManifest, options = {}, newManifest) {
|
|
530
|
+
const entityContexts = this._schema.getEntityContexts();
|
|
531
|
+
const currentSlugsByTable = /* @__PURE__ */ new Map();
|
|
532
|
+
for (const [table, def] of entityContexts) {
|
|
533
|
+
const rows = this._schema.queryTable(this._adapter, table);
|
|
534
|
+
const slugs = new Set(rows.map((row) => def.slug(row)));
|
|
535
|
+
currentSlugsByTable.set(table, slugs);
|
|
536
|
+
}
|
|
537
|
+
return cleanupEntityContexts(outputDir, entityContexts, currentSlugsByTable, prevManifest, options, newManifest);
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Render all entity context definitions.
|
|
541
|
+
* Mutates `filesWritten` and `counters` in place.
|
|
542
|
+
* Returns manifest data for the entity contexts rendered this cycle.
|
|
543
|
+
*/
|
|
544
|
+
_renderEntityContexts(outputDir, filesWritten, counters) {
|
|
545
|
+
const manifestData = {};
|
|
546
|
+
for (const [table, def] of this._schema.getEntityContexts()) {
|
|
547
|
+
const entityPk = this._schema.getPrimaryKey(table)[0] ?? "id";
|
|
548
|
+
const allRows = this._schema.queryTable(this._adapter, table);
|
|
549
|
+
const directoryRoot = def.directoryRoot ?? table;
|
|
550
|
+
const manifestEntry = {
|
|
551
|
+
directoryRoot,
|
|
552
|
+
...def.index ? { indexFile: def.index.outputFile } : {},
|
|
553
|
+
declaredFiles: Object.keys(def.files),
|
|
554
|
+
protectedFiles: def.protectedFiles ?? [],
|
|
555
|
+
entities: {}
|
|
556
|
+
};
|
|
557
|
+
if (def.index) {
|
|
558
|
+
const indexPath = (0, import_node_path4.join)(outputDir, def.index.outputFile);
|
|
559
|
+
if (atomicWrite(indexPath, def.index.render(allRows))) {
|
|
560
|
+
filesWritten.push(indexPath);
|
|
561
|
+
} else {
|
|
562
|
+
counters.skipped++;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
for (const entityRow of allRows) {
|
|
566
|
+
const slug = def.slug(entityRow);
|
|
567
|
+
const entityDir = def.directory ? (0, import_node_path4.join)(outputDir, def.directory(entityRow)) : (0, import_node_path4.join)(outputDir, directoryRoot, slug);
|
|
568
|
+
(0, import_node_fs4.mkdirSync)(entityDir, { recursive: true });
|
|
569
|
+
const renderedFiles = /* @__PURE__ */ new Map();
|
|
570
|
+
for (const [filename, spec] of Object.entries(def.files)) {
|
|
571
|
+
const rows = resolveEntitySource(spec.source, entityRow, entityPk, this._adapter);
|
|
572
|
+
if (spec.omitIfEmpty && rows.length === 0) continue;
|
|
573
|
+
const content = truncateContent(spec.render(rows), spec.budget);
|
|
574
|
+
renderedFiles.set(filename, content);
|
|
575
|
+
const filePath = (0, import_node_path4.join)(entityDir, filename);
|
|
576
|
+
if (atomicWrite(filePath, content)) {
|
|
577
|
+
filesWritten.push(filePath);
|
|
578
|
+
} else {
|
|
579
|
+
counters.skipped++;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (def.combined && renderedFiles.size > 0) {
|
|
583
|
+
const excluded = new Set(def.combined.exclude ?? []);
|
|
584
|
+
const parts = [];
|
|
585
|
+
for (const filename of Object.keys(def.files)) {
|
|
586
|
+
if (!excluded.has(filename) && renderedFiles.has(filename)) {
|
|
587
|
+
parts.push(renderedFiles.get(filename));
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (parts.length > 0) {
|
|
591
|
+
const combinedPath = (0, import_node_path4.join)(entityDir, def.combined.outputFile);
|
|
592
|
+
if (atomicWrite(combinedPath, parts.join("\n\n---\n\n"))) {
|
|
593
|
+
filesWritten.push(combinedPath);
|
|
594
|
+
} else {
|
|
595
|
+
counters.skipped++;
|
|
596
|
+
}
|
|
597
|
+
renderedFiles.set(def.combined.outputFile, parts.join("\n\n---\n\n"));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
manifestEntry.entities[slug] = [...renderedFiles.keys()];
|
|
601
|
+
}
|
|
602
|
+
manifestData[table] = manifestEntry;
|
|
603
|
+
}
|
|
604
|
+
return manifestData;
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
// src/sync/loop.ts
|
|
609
|
+
var SyncLoop = class {
|
|
610
|
+
_engine;
|
|
611
|
+
constructor(engine) {
|
|
612
|
+
this._engine = engine;
|
|
613
|
+
}
|
|
614
|
+
watch(outputDir, options = {}) {
|
|
615
|
+
const interval = options.interval ?? 5e3;
|
|
616
|
+
let timer = null;
|
|
617
|
+
let stopped = false;
|
|
618
|
+
const tick = () => {
|
|
619
|
+
if (stopped) return;
|
|
620
|
+
const prevManifest = options.cleanup ? readManifest(outputDir) : null;
|
|
621
|
+
void this._engine.render(outputDir).then((result) => {
|
|
622
|
+
options.onRender?.(result);
|
|
623
|
+
if (options.cleanup) {
|
|
624
|
+
const cleanupResult = this._engine.cleanup(outputDir, prevManifest, options.cleanup);
|
|
625
|
+
options.onCleanup?.(cleanupResult);
|
|
626
|
+
}
|
|
627
|
+
}).catch((err) => {
|
|
628
|
+
options.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
629
|
+
}).finally(() => {
|
|
630
|
+
if (!stopped) {
|
|
631
|
+
timer = setTimeout(tick, interval);
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
};
|
|
635
|
+
timer = setTimeout(tick, interval);
|
|
636
|
+
return () => {
|
|
637
|
+
stopped = true;
|
|
638
|
+
if (timer !== null) {
|
|
639
|
+
clearTimeout(timer);
|
|
640
|
+
timer = null;
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
// src/writeback/pipeline.ts
|
|
647
|
+
var import_node_fs5 = require("fs");
|
|
648
|
+
var import_node_path5 = require("path");
|
|
649
|
+
var WritebackPipeline = class {
|
|
650
|
+
_definitions = [];
|
|
651
|
+
_fileState = /* @__PURE__ */ new Map();
|
|
652
|
+
_seen = /* @__PURE__ */ new Map();
|
|
653
|
+
define(def) {
|
|
654
|
+
this._definitions.push(def);
|
|
655
|
+
}
|
|
656
|
+
async process() {
|
|
657
|
+
let total = 0;
|
|
658
|
+
for (const def of this._definitions) {
|
|
659
|
+
total += await this._processDef(def);
|
|
660
|
+
}
|
|
661
|
+
return total;
|
|
662
|
+
}
|
|
663
|
+
async _processDef(def) {
|
|
664
|
+
const paths = this._expandGlob(def.file);
|
|
665
|
+
let processed = 0;
|
|
666
|
+
for (const filePath of paths) {
|
|
667
|
+
if (!(0, import_node_fs5.existsSync)(filePath)) continue;
|
|
668
|
+
const stat = (0, import_node_fs5.statSync)(filePath);
|
|
669
|
+
const currentSize = stat.size;
|
|
670
|
+
const state = this._fileState.get(filePath) ?? { offset: 0, size: 0 };
|
|
671
|
+
if (currentSize < state.size) {
|
|
672
|
+
this._fileState.set(filePath, { offset: 0, size: 0 });
|
|
673
|
+
state.offset = 0;
|
|
674
|
+
}
|
|
675
|
+
if (currentSize === state.offset) continue;
|
|
676
|
+
const content = (0, import_node_fs5.readFileSync)(filePath, "utf8");
|
|
677
|
+
const { entries, nextOffset } = def.parse(content, state.offset);
|
|
678
|
+
this._fileState.set(filePath, { offset: nextOffset, size: currentSize });
|
|
679
|
+
for (const entry of entries) {
|
|
680
|
+
const key = def.dedupeKey ? def.dedupeKey(entry) : null;
|
|
681
|
+
if (key !== null) {
|
|
682
|
+
const seenForFile = this._seen.get(filePath) ?? /* @__PURE__ */ new Set();
|
|
683
|
+
if (seenForFile.has(key)) continue;
|
|
684
|
+
seenForFile.add(key);
|
|
685
|
+
this._seen.set(filePath, seenForFile);
|
|
686
|
+
}
|
|
687
|
+
await def.persist(entry, filePath);
|
|
688
|
+
processed++;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return processed;
|
|
692
|
+
}
|
|
693
|
+
_expandGlob(pattern) {
|
|
694
|
+
if (!pattern.includes("*") && !pattern.includes("?")) {
|
|
695
|
+
return [pattern];
|
|
696
|
+
}
|
|
697
|
+
const dir = (0, import_node_path5.dirname)(pattern);
|
|
698
|
+
const filePattern = (0, import_node_path5.basename)(pattern);
|
|
699
|
+
if (!(0, import_node_fs5.existsSync)(dir)) return [];
|
|
700
|
+
const regexStr = filePattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
701
|
+
const regex = new RegExp(`^${regexStr}$`);
|
|
702
|
+
return (0, import_node_fs5.readdirSync)(dir).filter((f) => regex.test(f)).map((f) => (0, import_node_path5.join)(dir, f));
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
// src/render/interpolate.ts
|
|
707
|
+
function interpolate(template, row) {
|
|
708
|
+
return template.replace(/\{\{([^}]+)\}\}/g, (_, path) => {
|
|
709
|
+
const parts = path.trim().split(".");
|
|
710
|
+
let val = row;
|
|
711
|
+
for (const part of parts) {
|
|
712
|
+
if (val == null || typeof val !== "object") return "";
|
|
713
|
+
val = val[part];
|
|
714
|
+
}
|
|
715
|
+
return val == null ? "" : String(val);
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// src/render/templates.ts
|
|
720
|
+
function compileRender(def, tableName, schema, adapter) {
|
|
721
|
+
const { renderFn, templateName, hooks } = _normalizeSpec(def.render);
|
|
722
|
+
if (renderFn) {
|
|
723
|
+
if (hooks?.beforeRender) {
|
|
724
|
+
const bh = hooks.beforeRender;
|
|
725
|
+
return (rows) => renderFn(bh(rows));
|
|
726
|
+
}
|
|
727
|
+
return renderFn;
|
|
728
|
+
}
|
|
729
|
+
return (rows) => {
|
|
730
|
+
const processed = hooks?.beforeRender ? hooks.beforeRender(rows) : rows;
|
|
731
|
+
const enriched = processed.map((row) => _enrichRow(row, def, schema, adapter));
|
|
732
|
+
switch (templateName) {
|
|
733
|
+
case "default-list":
|
|
734
|
+
return _renderList(enriched, hooks?.formatRow);
|
|
735
|
+
case "default-table":
|
|
736
|
+
return _renderTable(enriched);
|
|
737
|
+
case "default-detail":
|
|
738
|
+
return _renderDetail(enriched, tableName, schema, hooks?.formatRow);
|
|
739
|
+
case "default-json":
|
|
740
|
+
return JSON.stringify(processed, null, 2);
|
|
741
|
+
default:
|
|
742
|
+
return "";
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
function _normalizeSpec(render) {
|
|
747
|
+
if (typeof render === "function") {
|
|
748
|
+
return { renderFn: render };
|
|
749
|
+
}
|
|
750
|
+
if (typeof render === "string") {
|
|
751
|
+
return { templateName: render };
|
|
752
|
+
}
|
|
753
|
+
const spec = render;
|
|
754
|
+
return { templateName: spec.template, hooks: spec.hooks };
|
|
755
|
+
}
|
|
756
|
+
function _enrichRow(row, def, schema, adapter) {
|
|
757
|
+
if (!def.relations) return row;
|
|
758
|
+
const enriched = { ...row };
|
|
759
|
+
for (const [relName, rel] of Object.entries(def.relations)) {
|
|
760
|
+
if (rel.type !== "belongsTo") continue;
|
|
761
|
+
const fkValue = row[rel.foreignKey];
|
|
762
|
+
if (fkValue == null) continue;
|
|
763
|
+
try {
|
|
764
|
+
const refCol = rel.references ?? schema.getPrimaryKey(rel.table)[0] ?? "id";
|
|
765
|
+
const relRow = adapter.get(`SELECT * FROM "${rel.table}" WHERE "${refCol}" = ?`, [fkValue]);
|
|
766
|
+
if (relRow) {
|
|
767
|
+
enriched[relName] = relRow;
|
|
768
|
+
}
|
|
769
|
+
} catch {
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return enriched;
|
|
773
|
+
}
|
|
774
|
+
function _applyFormatRow(row, formatRow) {
|
|
775
|
+
if (formatRow == null) {
|
|
776
|
+
return Object.entries(row).map(([k, v]) => `${k}: ${v == null ? "" : String(v)}`).join(", ");
|
|
777
|
+
}
|
|
778
|
+
if (typeof formatRow === "function") return formatRow(row);
|
|
779
|
+
return interpolate(formatRow, row);
|
|
780
|
+
}
|
|
781
|
+
function _renderList(rows, formatRow) {
|
|
782
|
+
if (rows.length === 0) return "";
|
|
783
|
+
return rows.map((row) => `- ${_applyFormatRow(row, formatRow)}`).join("\n");
|
|
784
|
+
}
|
|
785
|
+
function _renderTable(rows) {
|
|
786
|
+
if (rows.length === 0) return "";
|
|
787
|
+
const firstRow = rows[0];
|
|
788
|
+
if (!firstRow) return "";
|
|
789
|
+
const headers = Object.keys(firstRow);
|
|
790
|
+
const headerRow = `| ${headers.join(" | ")} |`;
|
|
791
|
+
const separatorRow = `| ${headers.map(() => "---").join(" | ")} |`;
|
|
792
|
+
const bodyRows = rows.map(
|
|
793
|
+
(row) => `| ${headers.map((h) => {
|
|
794
|
+
const v = row[h];
|
|
795
|
+
return v == null ? "" : String(v);
|
|
796
|
+
}).join(" | ")} |`
|
|
797
|
+
).join("\n");
|
|
798
|
+
return [headerRow, separatorRow, bodyRows].join("\n");
|
|
799
|
+
}
|
|
800
|
+
function _renderDetail(rows, tableName, schema, formatRow) {
|
|
801
|
+
if (rows.length === 0) return "";
|
|
802
|
+
const pkCols = schema.getPrimaryKey(tableName);
|
|
803
|
+
return rows.map((row) => {
|
|
804
|
+
const pkVal = pkCols.map((col) => {
|
|
805
|
+
const v = row[col];
|
|
806
|
+
return v == null ? "" : String(v);
|
|
807
|
+
}).join(":");
|
|
808
|
+
const heading = `## ${pkVal}`;
|
|
809
|
+
let body;
|
|
810
|
+
if (formatRow != null) {
|
|
811
|
+
body = typeof formatRow === "function" ? formatRow(row) : interpolate(formatRow, row);
|
|
812
|
+
} else {
|
|
813
|
+
body = Object.entries(row).map(([k, v]) => `${k}: ${v == null ? "" : String(v)}`).join("\n");
|
|
814
|
+
}
|
|
815
|
+
return `${heading}
|
|
816
|
+
|
|
817
|
+
${body}`;
|
|
818
|
+
}).join("\n\n---\n\n");
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// src/config/parser.ts
|
|
822
|
+
var import_node_fs6 = require("fs");
|
|
823
|
+
var import_node_path6 = require("path");
|
|
824
|
+
var import_yaml = require("yaml");
|
|
825
|
+
function parseConfigFile(configPath) {
|
|
826
|
+
const absPath = (0, import_node_path6.resolve)(configPath);
|
|
827
|
+
const configDir = (0, import_node_path6.dirname)(absPath);
|
|
828
|
+
let raw;
|
|
829
|
+
try {
|
|
830
|
+
raw = (0, import_node_fs6.readFileSync)(absPath, "utf-8");
|
|
831
|
+
} catch (e) {
|
|
832
|
+
throw new Error(`Lattice: cannot read config file at "${absPath}": ${e.message}`);
|
|
833
|
+
}
|
|
834
|
+
let parsed;
|
|
835
|
+
try {
|
|
836
|
+
parsed = (0, import_yaml.parse)(raw);
|
|
837
|
+
} catch (e) {
|
|
838
|
+
throw new Error(`Lattice: YAML parse error in "${absPath}": ${e.message}`);
|
|
839
|
+
}
|
|
840
|
+
return buildParsedConfig(parsed, absPath, configDir);
|
|
841
|
+
}
|
|
842
|
+
function parseConfigString(yamlContent, configDir) {
|
|
843
|
+
let parsed;
|
|
844
|
+
try {
|
|
845
|
+
parsed = (0, import_yaml.parse)(yamlContent);
|
|
846
|
+
} catch (e) {
|
|
847
|
+
throw new Error(`Lattice: YAML parse error: ${e.message}`);
|
|
848
|
+
}
|
|
849
|
+
return buildParsedConfig(parsed, "<string>", configDir);
|
|
850
|
+
}
|
|
851
|
+
function buildParsedConfig(raw, sourceName, configDir) {
|
|
852
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
853
|
+
throw new Error(
|
|
854
|
+
`Lattice: config "${sourceName}" must be a YAML object with "db" and "entities" keys`
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
const cfg = raw;
|
|
858
|
+
if (typeof cfg.db !== "string") {
|
|
859
|
+
throw new Error(`Lattice: config.db must be a string path (got ${typeof cfg.db})`);
|
|
860
|
+
}
|
|
861
|
+
if (!cfg.entities || typeof cfg.entities !== "object" || Array.isArray(cfg.entities)) {
|
|
862
|
+
throw new Error(`Lattice: config.entities must be an object`);
|
|
863
|
+
}
|
|
864
|
+
const config = raw;
|
|
865
|
+
const dbPath = (0, import_node_path6.resolve)(configDir, config.db);
|
|
866
|
+
const tables = [];
|
|
867
|
+
for (const [entityName, entityDef] of Object.entries(config.entities)) {
|
|
868
|
+
const definition = entityToTableDef(entityName, entityDef, configDir);
|
|
869
|
+
tables.push({ name: entityName, definition });
|
|
870
|
+
}
|
|
871
|
+
const entityContexts = parseEntityContexts(config.entityContexts);
|
|
872
|
+
return { dbPath, tables, entityContexts };
|
|
873
|
+
}
|
|
874
|
+
function entityToTableDef(entityName, entity, configDir) {
|
|
875
|
+
const rawFields = entity.fields;
|
|
876
|
+
if (!rawFields || typeof rawFields !== "object" || Array.isArray(rawFields)) {
|
|
877
|
+
throw new Error(`Lattice: entity "${entityName}" must have a "fields" object`);
|
|
878
|
+
}
|
|
879
|
+
const columns = {};
|
|
880
|
+
const relations = {};
|
|
881
|
+
let pkFromField;
|
|
882
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
883
|
+
columns[fieldName] = fieldToSqliteSpec(field);
|
|
884
|
+
if (field.primaryKey) {
|
|
885
|
+
pkFromField = fieldName;
|
|
886
|
+
}
|
|
887
|
+
if (field.ref) {
|
|
888
|
+
const relName = fieldName.endsWith("_id") ? fieldName.slice(0, -3) : fieldName;
|
|
889
|
+
relations[relName] = {
|
|
890
|
+
type: "belongsTo",
|
|
891
|
+
table: field.ref,
|
|
892
|
+
foreignKey: fieldName
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
const primaryKey = entity.primaryKey ?? pkFromField;
|
|
897
|
+
const render = parseEntityRender(entity.render);
|
|
898
|
+
const outputFile = (0, import_node_path6.resolve)(configDir, entity.outputFile);
|
|
899
|
+
return {
|
|
900
|
+
columns,
|
|
901
|
+
render,
|
|
902
|
+
outputFile,
|
|
903
|
+
...primaryKey !== void 0 ? { primaryKey } : {},
|
|
904
|
+
...Object.keys(relations).length > 0 ? { relations } : {}
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
function fieldToSqliteSpec(field) {
|
|
908
|
+
const parts = [fieldToSqliteBaseType(field.type)];
|
|
909
|
+
if (field.primaryKey) {
|
|
910
|
+
parts.push("PRIMARY KEY");
|
|
911
|
+
} else if (field.required) {
|
|
912
|
+
parts.push("NOT NULL");
|
|
913
|
+
}
|
|
914
|
+
if (field.default !== void 0) {
|
|
915
|
+
const dv = field.default;
|
|
916
|
+
if (typeof dv === "string") {
|
|
917
|
+
parts.push(`DEFAULT '${dv.replace(/'/g, "''")}'`);
|
|
918
|
+
} else {
|
|
919
|
+
parts.push(`DEFAULT ${String(dv)}`);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
return parts.join(" ");
|
|
923
|
+
}
|
|
924
|
+
function fieldToSqliteBaseType(type) {
|
|
925
|
+
switch (type) {
|
|
926
|
+
case "uuid":
|
|
927
|
+
case "text":
|
|
928
|
+
case "datetime":
|
|
929
|
+
case "date":
|
|
930
|
+
return "TEXT";
|
|
931
|
+
case "integer":
|
|
932
|
+
case "int":
|
|
933
|
+
case "boolean":
|
|
934
|
+
case "bool":
|
|
935
|
+
return "INTEGER";
|
|
936
|
+
case "real":
|
|
937
|
+
case "float":
|
|
938
|
+
return "REAL";
|
|
939
|
+
case "blob":
|
|
940
|
+
return "BLOB";
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
function parseEntityRender(render) {
|
|
944
|
+
if (!render) {
|
|
945
|
+
return "default-list";
|
|
946
|
+
}
|
|
947
|
+
if (typeof render === "string") {
|
|
948
|
+
return render;
|
|
949
|
+
}
|
|
950
|
+
const spec = render;
|
|
951
|
+
if (spec.formatRow) {
|
|
952
|
+
return {
|
|
953
|
+
template: spec.template,
|
|
954
|
+
hooks: { formatRow: spec.formatRow }
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
return spec.template;
|
|
958
|
+
}
|
|
959
|
+
function renderFnForTemplate(templateName) {
|
|
960
|
+
switch (templateName) {
|
|
961
|
+
case "default-list":
|
|
962
|
+
return (rows) => {
|
|
963
|
+
if (rows.length === 0) return "";
|
|
964
|
+
return rows.map(
|
|
965
|
+
(row) => `- ${Object.entries(row).map(([k, v]) => `${k}: ${v == null ? "" : String(v)}`).join(", ")}`
|
|
966
|
+
).join("\n");
|
|
967
|
+
};
|
|
968
|
+
case "default-table":
|
|
969
|
+
return (rows) => {
|
|
970
|
+
if (rows.length === 0) return "";
|
|
971
|
+
const firstRow = rows[0];
|
|
972
|
+
if (!firstRow) return "";
|
|
973
|
+
const headers = Object.keys(firstRow);
|
|
974
|
+
const headerRow = `| ${headers.join(" | ")} |`;
|
|
975
|
+
const separatorRow = `| ${headers.map(() => "---").join(" | ")} |`;
|
|
976
|
+
const bodyRows = rows.map(
|
|
977
|
+
(row) => `| ${headers.map((h) => {
|
|
978
|
+
const v = row[h];
|
|
979
|
+
return v == null ? "" : String(v);
|
|
980
|
+
}).join(" | ")} |`
|
|
981
|
+
).join("\n");
|
|
982
|
+
return [headerRow, separatorRow, bodyRows].join("\n");
|
|
983
|
+
};
|
|
984
|
+
case "default-detail":
|
|
985
|
+
return (rows) => {
|
|
986
|
+
if (rows.length === 0) return "";
|
|
987
|
+
return rows.map((row) => {
|
|
988
|
+
const body = Object.entries(row).map(([k, v]) => `${k}: ${v == null ? "" : String(v)}`).join("\n");
|
|
989
|
+
return `## ${String(Object.values(row)[0] ?? "")}
|
|
990
|
+
|
|
991
|
+
${body}`;
|
|
992
|
+
}).join("\n\n---\n\n");
|
|
993
|
+
};
|
|
994
|
+
case "default-json":
|
|
995
|
+
return (rows) => JSON.stringify(rows, null, 2);
|
|
996
|
+
default:
|
|
997
|
+
return (rows) => JSON.stringify(rows, null, 2);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
function parseEntitySource(sourceDef) {
|
|
1001
|
+
if (sourceDef === "self") {
|
|
1002
|
+
return { type: "self" };
|
|
1003
|
+
}
|
|
1004
|
+
return sourceDef;
|
|
1005
|
+
}
|
|
1006
|
+
function extractSlugField(slugTemplate) {
|
|
1007
|
+
const match = /^\{\{(\w+)\}\}$/.exec(slugTemplate);
|
|
1008
|
+
if (match?.[1]) {
|
|
1009
|
+
const field = match[1];
|
|
1010
|
+
return (row) => row[field];
|
|
1011
|
+
}
|
|
1012
|
+
return () => slugTemplate;
|
|
1013
|
+
}
|
|
1014
|
+
function parseEntityContexts(entityContexts) {
|
|
1015
|
+
if (!entityContexts) return [];
|
|
1016
|
+
const result = [];
|
|
1017
|
+
for (const [tableName, ctxDef] of Object.entries(entityContexts)) {
|
|
1018
|
+
const slugFn = extractSlugField(ctxDef.slug);
|
|
1019
|
+
const files = {};
|
|
1020
|
+
for (const [filename, fileDef] of Object.entries(ctxDef.files)) {
|
|
1021
|
+
files[filename] = {
|
|
1022
|
+
source: parseEntitySource(fileDef.source),
|
|
1023
|
+
render: renderFnForTemplate(fileDef.template),
|
|
1024
|
+
...fileDef.budget !== void 0 ? { budget: fileDef.budget } : {},
|
|
1025
|
+
...fileDef.omitIfEmpty !== void 0 ? { omitIfEmpty: fileDef.omitIfEmpty } : {}
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
const definition = {
|
|
1029
|
+
slug: slugFn,
|
|
1030
|
+
files,
|
|
1031
|
+
...ctxDef.directoryRoot !== void 0 ? { directoryRoot: ctxDef.directoryRoot } : {},
|
|
1032
|
+
...ctxDef.protectedFiles !== void 0 ? { protectedFiles: ctxDef.protectedFiles } : {},
|
|
1033
|
+
...ctxDef.index !== void 0 ? {
|
|
1034
|
+
index: {
|
|
1035
|
+
outputFile: ctxDef.index.outputFile,
|
|
1036
|
+
render: renderFnForTemplate(ctxDef.index.render)
|
|
1037
|
+
}
|
|
1038
|
+
} : {},
|
|
1039
|
+
...ctxDef.combined !== void 0 ? { combined: ctxDef.combined } : {}
|
|
1040
|
+
};
|
|
1041
|
+
result.push({ table: tableName, definition });
|
|
1042
|
+
}
|
|
1043
|
+
return result;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// src/lattice.ts
|
|
1047
|
+
var Lattice = class {
|
|
1048
|
+
_adapter;
|
|
1049
|
+
_schema;
|
|
1050
|
+
_sanitizer;
|
|
1051
|
+
_render;
|
|
1052
|
+
_loop;
|
|
1053
|
+
_writeback;
|
|
1054
|
+
_initialized = false;
|
|
1055
|
+
/** Cache of actual table columns (from PRAGMA), populated after init(). */
|
|
1056
|
+
_columnCache = /* @__PURE__ */ new Map();
|
|
1057
|
+
_auditHandlers = [];
|
|
1058
|
+
_renderHandlers = [];
|
|
1059
|
+
_writebackHandlers = [];
|
|
1060
|
+
_errorHandlers = [];
|
|
1061
|
+
constructor(pathOrConfig, options = {}) {
|
|
1062
|
+
let dbPath;
|
|
1063
|
+
let configTables;
|
|
1064
|
+
let configEntityContexts;
|
|
1065
|
+
if (typeof pathOrConfig === "string") {
|
|
1066
|
+
dbPath = pathOrConfig;
|
|
1067
|
+
} else {
|
|
1068
|
+
const parsed = parseConfigFile(pathOrConfig.config);
|
|
1069
|
+
dbPath = parsed.dbPath;
|
|
1070
|
+
configTables = [...parsed.tables];
|
|
1071
|
+
configEntityContexts = [...parsed.entityContexts];
|
|
1072
|
+
if (pathOrConfig.options) {
|
|
1073
|
+
options = { ...pathOrConfig.options, ...options };
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
const adapterOpts = {};
|
|
1077
|
+
if (options.wal !== void 0) adapterOpts.wal = options.wal;
|
|
1078
|
+
if (options.busyTimeout !== void 0) adapterOpts.busyTimeout = options.busyTimeout;
|
|
1079
|
+
this._adapter = new SQLiteAdapter(dbPath, adapterOpts);
|
|
1080
|
+
this._schema = new SchemaManager();
|
|
1081
|
+
this._sanitizer = new Sanitizer(options.security);
|
|
1082
|
+
this._render = new RenderEngine(this._schema, this._adapter);
|
|
1083
|
+
this._loop = new SyncLoop(this._render);
|
|
1084
|
+
this._writeback = new WritebackPipeline();
|
|
1085
|
+
this._sanitizer.onAudit((event) => {
|
|
1086
|
+
for (const h of this._auditHandlers) h(event);
|
|
1087
|
+
});
|
|
1088
|
+
if (configTables) {
|
|
1089
|
+
for (const { name, definition } of configTables) {
|
|
1090
|
+
this.define(name, definition);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
if (configEntityContexts) {
|
|
1094
|
+
for (const { table, definition } of configEntityContexts) {
|
|
1095
|
+
this.defineEntityContext(table, definition);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
// -------------------------------------------------------------------------
|
|
1100
|
+
// Setup
|
|
1101
|
+
// -------------------------------------------------------------------------
|
|
1102
|
+
define(table, def) {
|
|
1103
|
+
this._assertNotInit("define");
|
|
1104
|
+
const compiledDef = {
|
|
1105
|
+
...def,
|
|
1106
|
+
render: compileRender(def, table, this._schema, this._adapter)
|
|
1107
|
+
};
|
|
1108
|
+
this._schema.define(table, compiledDef);
|
|
1109
|
+
return this;
|
|
1110
|
+
}
|
|
1111
|
+
defineMulti(name, def) {
|
|
1112
|
+
this._assertNotInit("defineMulti");
|
|
1113
|
+
this._schema.defineMulti(name, def);
|
|
1114
|
+
return this;
|
|
1115
|
+
}
|
|
1116
|
+
defineEntityContext(table, def) {
|
|
1117
|
+
this._assertNotInit("defineEntityContext");
|
|
1118
|
+
this._schema.defineEntityContext(table, def);
|
|
1119
|
+
return this;
|
|
1120
|
+
}
|
|
1121
|
+
defineWriteback(def) {
|
|
1122
|
+
this._writeback.define(def);
|
|
1123
|
+
return this;
|
|
1124
|
+
}
|
|
1125
|
+
init(options = {}) {
|
|
1126
|
+
if (this._initialized) {
|
|
1127
|
+
return Promise.reject(new Error("Lattice: init() has already been called"));
|
|
1128
|
+
}
|
|
1129
|
+
this._adapter.open();
|
|
1130
|
+
this._schema.applySchema(this._adapter);
|
|
1131
|
+
if (options.migrations?.length) {
|
|
1132
|
+
this._schema.applyMigrations(this._adapter, options.migrations);
|
|
1133
|
+
}
|
|
1134
|
+
for (const tableName of this._schema.getTables().keys()) {
|
|
1135
|
+
const rows = this._adapter.all(`PRAGMA table_info("${tableName}")`);
|
|
1136
|
+
this._columnCache.set(tableName, new Set(rows.map((r) => r.name)));
|
|
1137
|
+
}
|
|
1138
|
+
this._initialized = true;
|
|
1139
|
+
return Promise.resolve();
|
|
1140
|
+
}
|
|
1141
|
+
close() {
|
|
1142
|
+
this._adapter.close();
|
|
1143
|
+
this._columnCache.clear();
|
|
1144
|
+
this._initialized = false;
|
|
1145
|
+
}
|
|
1146
|
+
// -------------------------------------------------------------------------
|
|
1147
|
+
// CRUD
|
|
1148
|
+
// -------------------------------------------------------------------------
|
|
1149
|
+
insert(table, row) {
|
|
1150
|
+
const notInit = this._notInitError();
|
|
1151
|
+
if (notInit) return notInit;
|
|
1152
|
+
const sanitized = this._filterToSchemaColumns(table, this._sanitizer.sanitizeRow(row));
|
|
1153
|
+
const pkCols = this._schema.getPrimaryKey(table);
|
|
1154
|
+
const isDefaultPk = pkCols.length === 1 && pkCols[0] === "id";
|
|
1155
|
+
let rowWithPk;
|
|
1156
|
+
if (isDefaultPk) {
|
|
1157
|
+
const id = sanitized.id ?? (0, import_uuid.v4)();
|
|
1158
|
+
rowWithPk = { ...sanitized, id };
|
|
1159
|
+
} else {
|
|
1160
|
+
rowWithPk = sanitized;
|
|
1161
|
+
}
|
|
1162
|
+
const cols = Object.keys(rowWithPk).map((c) => `"${c}"`).join(", ");
|
|
1163
|
+
const placeholders = Object.keys(rowWithPk).map(() => "?").join(", ");
|
|
1164
|
+
const values = Object.values(rowWithPk);
|
|
1165
|
+
this._adapter.run(`INSERT INTO "${table}" (${cols}) VALUES (${placeholders})`, values);
|
|
1166
|
+
const pkCol = pkCols[0] ?? "id";
|
|
1167
|
+
const rawPk = rowWithPk[pkCol];
|
|
1168
|
+
const pkValue = rawPk != null ? String(rawPk) : "";
|
|
1169
|
+
this._sanitizer.emitAudit(table, "insert", pkValue);
|
|
1170
|
+
return Promise.resolve(pkValue);
|
|
1171
|
+
}
|
|
1172
|
+
upsert(table, row) {
|
|
1173
|
+
const notInit = this._notInitError();
|
|
1174
|
+
if (notInit) return notInit;
|
|
1175
|
+
const sanitized = this._filterToSchemaColumns(table, this._sanitizer.sanitizeRow(row));
|
|
1176
|
+
const pkCols = this._schema.getPrimaryKey(table);
|
|
1177
|
+
const isDefaultPk = pkCols.length === 1 && pkCols[0] === "id";
|
|
1178
|
+
let rowWithPk;
|
|
1179
|
+
if (isDefaultPk) {
|
|
1180
|
+
const id = sanitized.id ?? (0, import_uuid.v4)();
|
|
1181
|
+
rowWithPk = { ...sanitized, id };
|
|
1182
|
+
} else {
|
|
1183
|
+
rowWithPk = sanitized;
|
|
1184
|
+
}
|
|
1185
|
+
const cols = Object.keys(rowWithPk).map((c) => `"${c}"`).join(", ");
|
|
1186
|
+
const placeholders = Object.keys(rowWithPk).map(() => "?").join(", ");
|
|
1187
|
+
const conflictCols = pkCols.map((c) => `"${c}"`).join(", ");
|
|
1188
|
+
const updateCols = Object.keys(rowWithPk).filter((c) => !pkCols.includes(c)).map((c) => `"${c}" = excluded."${c}"`).join(", ");
|
|
1189
|
+
const values = Object.values(rowWithPk);
|
|
1190
|
+
this._adapter.run(
|
|
1191
|
+
`INSERT INTO "${table}" (${cols}) VALUES (${placeholders}) ON CONFLICT(${conflictCols}) DO UPDATE SET ${updateCols}`,
|
|
1192
|
+
values
|
|
1193
|
+
);
|
|
1194
|
+
const pkCol = pkCols[0] ?? "id";
|
|
1195
|
+
const rawPk = rowWithPk[pkCol];
|
|
1196
|
+
const pkValue = rawPk != null ? String(rawPk) : "";
|
|
1197
|
+
this._sanitizer.emitAudit(table, "update", pkValue);
|
|
1198
|
+
return Promise.resolve(pkValue);
|
|
1199
|
+
}
|
|
1200
|
+
upsertBy(table, col, val, row) {
|
|
1201
|
+
const notInit = this._notInitError();
|
|
1202
|
+
if (notInit) return notInit;
|
|
1203
|
+
const existing = this._adapter.get(`SELECT * FROM "${table}" WHERE "${col}" = ?`, [val]);
|
|
1204
|
+
if (existing) {
|
|
1205
|
+
const pkCols = this._schema.getPrimaryKey(table);
|
|
1206
|
+
const pkLookup = pkCols.length === 1 ? String(existing[pkCols[0] ?? "id"]) : Object.fromEntries(pkCols.map((c) => [c, existing[c]]));
|
|
1207
|
+
return this.update(table, pkLookup, row).then(
|
|
1208
|
+
() => typeof pkLookup === "string" ? pkLookup : JSON.stringify(pkLookup)
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
return this.insert(table, { ...row, [col]: val });
|
|
1212
|
+
}
|
|
1213
|
+
update(table, id, row) {
|
|
1214
|
+
const notInit = this._notInitError();
|
|
1215
|
+
if (notInit) return notInit;
|
|
1216
|
+
const sanitized = this._filterToSchemaColumns(table, this._sanitizer.sanitizeRow(row));
|
|
1217
|
+
const setCols = Object.keys(sanitized).map((c) => `"${c}" = ?`).join(", ");
|
|
1218
|
+
const { clause, params: pkParams } = this._pkWhere(table, id);
|
|
1219
|
+
const values = [...Object.values(sanitized), ...pkParams];
|
|
1220
|
+
this._adapter.run(`UPDATE "${table}" SET ${setCols} WHERE ${clause}`, values);
|
|
1221
|
+
const auditId = typeof id === "string" ? id : JSON.stringify(id);
|
|
1222
|
+
this._sanitizer.emitAudit(table, "update", auditId);
|
|
1223
|
+
return Promise.resolve();
|
|
1224
|
+
}
|
|
1225
|
+
delete(table, id) {
|
|
1226
|
+
const notInit = this._notInitError();
|
|
1227
|
+
if (notInit) return notInit;
|
|
1228
|
+
const { clause, params } = this._pkWhere(table, id);
|
|
1229
|
+
this._adapter.run(`DELETE FROM "${table}" WHERE ${clause}`, params);
|
|
1230
|
+
const auditId = typeof id === "string" ? id : JSON.stringify(id);
|
|
1231
|
+
this._sanitizer.emitAudit(table, "delete", auditId);
|
|
1232
|
+
return Promise.resolve();
|
|
1233
|
+
}
|
|
1234
|
+
get(table, id) {
|
|
1235
|
+
const notInit = this._notInitError();
|
|
1236
|
+
if (notInit) return notInit;
|
|
1237
|
+
const { clause, params } = this._pkWhere(table, id);
|
|
1238
|
+
return Promise.resolve(
|
|
1239
|
+
this._adapter.get(`SELECT * FROM "${table}" WHERE ${clause}`, params) ?? null
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
query(table, opts = {}) {
|
|
1243
|
+
const notInit = this._notInitError();
|
|
1244
|
+
if (notInit) return notInit;
|
|
1245
|
+
const colErr = this._invalidColumnError(table, [
|
|
1246
|
+
...Object.keys(opts.where ?? {}),
|
|
1247
|
+
...(opts.filters ?? []).map((f) => f.col),
|
|
1248
|
+
...opts.orderBy ? [opts.orderBy] : []
|
|
1249
|
+
]);
|
|
1250
|
+
if (colErr) return colErr;
|
|
1251
|
+
let sql = `SELECT * FROM "${table}"`;
|
|
1252
|
+
const params = [];
|
|
1253
|
+
const whereClauses = [];
|
|
1254
|
+
if (opts.where && Object.keys(opts.where).length > 0) {
|
|
1255
|
+
for (const [col, val] of Object.entries(opts.where)) {
|
|
1256
|
+
whereClauses.push(`"${col}" = ?`);
|
|
1257
|
+
params.push(val);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
if (opts.filters && opts.filters.length > 0) {
|
|
1261
|
+
const { clauses, params: fp } = this._buildFilters(opts.filters);
|
|
1262
|
+
whereClauses.push(...clauses);
|
|
1263
|
+
params.push(...fp);
|
|
1264
|
+
}
|
|
1265
|
+
if (whereClauses.length > 0) {
|
|
1266
|
+
sql += ` WHERE ${whereClauses.join(" AND ")}`;
|
|
1267
|
+
}
|
|
1268
|
+
if (opts.orderBy) {
|
|
1269
|
+
const dir = opts.orderDir === "desc" ? "DESC" : "ASC";
|
|
1270
|
+
sql += ` ORDER BY "${opts.orderBy}" ${dir}`;
|
|
1271
|
+
}
|
|
1272
|
+
if (opts.limit !== void 0) {
|
|
1273
|
+
sql += ` LIMIT ${opts.limit.toString()}`;
|
|
1274
|
+
}
|
|
1275
|
+
if (opts.offset !== void 0) {
|
|
1276
|
+
if (opts.limit === void 0) sql += " LIMIT -1";
|
|
1277
|
+
sql += ` OFFSET ${opts.offset.toString()}`;
|
|
1278
|
+
}
|
|
1279
|
+
return Promise.resolve(this._adapter.all(sql, params));
|
|
1280
|
+
}
|
|
1281
|
+
count(table, opts = {}) {
|
|
1282
|
+
const notInit = this._notInitError();
|
|
1283
|
+
if (notInit) return notInit;
|
|
1284
|
+
const colErr = this._invalidColumnError(table, [
|
|
1285
|
+
...Object.keys(opts.where ?? {}),
|
|
1286
|
+
...(opts.filters ?? []).map((f) => f.col)
|
|
1287
|
+
]);
|
|
1288
|
+
if (colErr) return colErr;
|
|
1289
|
+
let sql = `SELECT COUNT(*) as n FROM "${table}"`;
|
|
1290
|
+
const params = [];
|
|
1291
|
+
const whereClauses = [];
|
|
1292
|
+
if (opts.where && Object.keys(opts.where).length > 0) {
|
|
1293
|
+
for (const [col, val] of Object.entries(opts.where)) {
|
|
1294
|
+
whereClauses.push(`"${col}" = ?`);
|
|
1295
|
+
params.push(val);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
if (opts.filters && opts.filters.length > 0) {
|
|
1299
|
+
const { clauses, params: fp } = this._buildFilters(opts.filters);
|
|
1300
|
+
whereClauses.push(...clauses);
|
|
1301
|
+
params.push(...fp);
|
|
1302
|
+
}
|
|
1303
|
+
if (whereClauses.length > 0) {
|
|
1304
|
+
sql += ` WHERE ${whereClauses.join(" AND ")}`;
|
|
1305
|
+
}
|
|
1306
|
+
const row = this._adapter.get(sql, params);
|
|
1307
|
+
return Promise.resolve(Number(row?.n ?? 0));
|
|
1308
|
+
}
|
|
1309
|
+
// -------------------------------------------------------------------------
|
|
1310
|
+
// Sync
|
|
1311
|
+
// -------------------------------------------------------------------------
|
|
1312
|
+
async render(outputDir) {
|
|
1313
|
+
const notInit = this._notInitError();
|
|
1314
|
+
if (notInit) return notInit;
|
|
1315
|
+
const result = await this._render.render(outputDir);
|
|
1316
|
+
for (const h of this._renderHandlers) h(result);
|
|
1317
|
+
return result;
|
|
1318
|
+
}
|
|
1319
|
+
async sync(outputDir) {
|
|
1320
|
+
const notInit = this._notInitError();
|
|
1321
|
+
if (notInit) return notInit;
|
|
1322
|
+
const renderResult = await this._render.render(outputDir);
|
|
1323
|
+
for (const h of this._renderHandlers) h(renderResult);
|
|
1324
|
+
const writebackProcessed = await this._writeback.process();
|
|
1325
|
+
return { ...renderResult, writebackProcessed };
|
|
1326
|
+
}
|
|
1327
|
+
async reconcile(outputDir, options = {}) {
|
|
1328
|
+
const notInit = this._notInitError();
|
|
1329
|
+
if (notInit) return notInit;
|
|
1330
|
+
const prevManifest = readManifest(outputDir);
|
|
1331
|
+
const renderResult = await this._render.render(outputDir);
|
|
1332
|
+
for (const h of this._renderHandlers) h(renderResult);
|
|
1333
|
+
const newManifest = readManifest(outputDir);
|
|
1334
|
+
const cleanup = this._render.cleanup(outputDir, prevManifest, options, newManifest);
|
|
1335
|
+
return { ...renderResult, cleanup };
|
|
1336
|
+
}
|
|
1337
|
+
watch(outputDir, opts = {}) {
|
|
1338
|
+
const notInit = this._notInitError();
|
|
1339
|
+
if (notInit) return notInit;
|
|
1340
|
+
const stop = this._loop.watch(outputDir, {
|
|
1341
|
+
...opts,
|
|
1342
|
+
onRender: (result) => {
|
|
1343
|
+
opts.onRender?.(result);
|
|
1344
|
+
for (const h of this._renderHandlers) h(result);
|
|
1345
|
+
},
|
|
1346
|
+
onError: (err) => {
|
|
1347
|
+
opts.onError?.(err);
|
|
1348
|
+
for (const h of this._errorHandlers) h(err);
|
|
1349
|
+
}
|
|
1350
|
+
});
|
|
1351
|
+
return Promise.resolve(stop);
|
|
1352
|
+
}
|
|
1353
|
+
on(event, handler) {
|
|
1354
|
+
switch (event) {
|
|
1355
|
+
case "audit":
|
|
1356
|
+
this._auditHandlers.push(handler);
|
|
1357
|
+
break;
|
|
1358
|
+
case "render":
|
|
1359
|
+
this._renderHandlers.push(handler);
|
|
1360
|
+
break;
|
|
1361
|
+
case "writeback":
|
|
1362
|
+
this._writebackHandlers.push(
|
|
1363
|
+
handler
|
|
1364
|
+
);
|
|
1365
|
+
break;
|
|
1366
|
+
case "error":
|
|
1367
|
+
this._errorHandlers.push(handler);
|
|
1368
|
+
break;
|
|
1369
|
+
}
|
|
1370
|
+
return this;
|
|
1371
|
+
}
|
|
1372
|
+
// -------------------------------------------------------------------------
|
|
1373
|
+
// Escape hatch
|
|
1374
|
+
// -------------------------------------------------------------------------
|
|
1375
|
+
get db() {
|
|
1376
|
+
return this._adapter.db;
|
|
1377
|
+
}
|
|
1378
|
+
// -------------------------------------------------------------------------
|
|
1379
|
+
// Private helpers
|
|
1380
|
+
// -------------------------------------------------------------------------
|
|
1381
|
+
/**
|
|
1382
|
+
* Filter a sanitized row to only include columns that actually exist in the
|
|
1383
|
+
* table (verified via PRAGMA after init). Unregistered tables (accessed
|
|
1384
|
+
* through the raw `.db` handle) are passed through unchanged.
|
|
1385
|
+
*
|
|
1386
|
+
* This is a defence-in-depth guard: column names from caller-supplied `row`
|
|
1387
|
+
* objects are interpolated into SQL, so stripping unknown keys eliminates
|
|
1388
|
+
* any theoretical injection vector from crafted object keys.
|
|
1389
|
+
*/
|
|
1390
|
+
_filterToSchemaColumns(table, row) {
|
|
1391
|
+
const cols = this._columnCache.get(table);
|
|
1392
|
+
if (!cols) return row;
|
|
1393
|
+
const keys = Object.keys(row);
|
|
1394
|
+
if (keys.every((k) => cols.has(k))) return row;
|
|
1395
|
+
return Object.fromEntries(keys.filter((k) => cols.has(k)).map((k) => [k, row[k]]));
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Build the WHERE clause and params for a PK lookup.
|
|
1399
|
+
* - `string` → matches against the table's first PK column.
|
|
1400
|
+
* - `Record` → matches every PK column; all must be present in the object.
|
|
1401
|
+
*/
|
|
1402
|
+
_pkWhere(table, id) {
|
|
1403
|
+
const pkCols = this._schema.getPrimaryKey(table);
|
|
1404
|
+
if (typeof id === "string") {
|
|
1405
|
+
const firstCol = pkCols[0] ?? "id";
|
|
1406
|
+
return { clause: `"${firstCol}" = ?`, params: [id] };
|
|
1407
|
+
}
|
|
1408
|
+
const clauses = pkCols.map((col) => `"${col}" = ?`);
|
|
1409
|
+
const params = pkCols.map((col) => id[col]);
|
|
1410
|
+
return { clause: clauses.join(" AND "), params };
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* Convert Filter objects into SQL clause strings and bound params.
|
|
1414
|
+
* An `in` filter with an empty array is silently ignored (produces no clause).
|
|
1415
|
+
*/
|
|
1416
|
+
_buildFilters(filters) {
|
|
1417
|
+
const clauses = [];
|
|
1418
|
+
const params = [];
|
|
1419
|
+
for (const f of filters) {
|
|
1420
|
+
const col = `"${f.col}"`;
|
|
1421
|
+
switch (f.op) {
|
|
1422
|
+
case "eq":
|
|
1423
|
+
clauses.push(`${col} = ?`);
|
|
1424
|
+
params.push(f.val);
|
|
1425
|
+
break;
|
|
1426
|
+
case "ne":
|
|
1427
|
+
clauses.push(`${col} != ?`);
|
|
1428
|
+
params.push(f.val);
|
|
1429
|
+
break;
|
|
1430
|
+
case "gt":
|
|
1431
|
+
clauses.push(`${col} > ?`);
|
|
1432
|
+
params.push(f.val);
|
|
1433
|
+
break;
|
|
1434
|
+
case "gte":
|
|
1435
|
+
clauses.push(`${col} >= ?`);
|
|
1436
|
+
params.push(f.val);
|
|
1437
|
+
break;
|
|
1438
|
+
case "lt":
|
|
1439
|
+
clauses.push(`${col} < ?`);
|
|
1440
|
+
params.push(f.val);
|
|
1441
|
+
break;
|
|
1442
|
+
case "lte":
|
|
1443
|
+
clauses.push(`${col} <= ?`);
|
|
1444
|
+
params.push(f.val);
|
|
1445
|
+
break;
|
|
1446
|
+
case "like":
|
|
1447
|
+
clauses.push(`${col} LIKE ?`);
|
|
1448
|
+
params.push(f.val);
|
|
1449
|
+
break;
|
|
1450
|
+
case "in": {
|
|
1451
|
+
const list = f.val;
|
|
1452
|
+
if (Array.isArray(list) && list.length > 0) {
|
|
1453
|
+
clauses.push(`${col} IN (${list.map(() => "?").join(", ")})`);
|
|
1454
|
+
params.push(...list);
|
|
1455
|
+
}
|
|
1456
|
+
break;
|
|
1457
|
+
}
|
|
1458
|
+
case "isNull":
|
|
1459
|
+
clauses.push(`${col} IS NULL`);
|
|
1460
|
+
break;
|
|
1461
|
+
case "isNotNull":
|
|
1462
|
+
clauses.push(`${col} IS NOT NULL`);
|
|
1463
|
+
break;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
return { clauses, params };
|
|
1467
|
+
}
|
|
1468
|
+
/** Returns a rejected Promise if not initialized; null if ready. */
|
|
1469
|
+
_notInitError() {
|
|
1470
|
+
if (!this._initialized) {
|
|
1471
|
+
return Promise.reject(
|
|
1472
|
+
new Error("Lattice: call await db.init() before using CRUD or sync methods")
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
return null;
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Returns a rejected Promise if any of the given column names are not present
|
|
1479
|
+
* in the table's schema; null if all columns are valid.
|
|
1480
|
+
*
|
|
1481
|
+
* Applied on the read path (query/count) to validate WHERE and filter column
|
|
1482
|
+
* names before they are interpolated into SQL. The write path strips unknown
|
|
1483
|
+
* columns via _filterToSchemaColumns; the read path rejects instead to avoid
|
|
1484
|
+
* silently discarding intended filter conditions.
|
|
1485
|
+
*
|
|
1486
|
+
* Unregistered tables (accessed via the raw `.db` handle) are passed through.
|
|
1487
|
+
*/
|
|
1488
|
+
_invalidColumnError(table, cols) {
|
|
1489
|
+
const known = this._columnCache.get(table);
|
|
1490
|
+
if (!known) return null;
|
|
1491
|
+
for (const col of cols) {
|
|
1492
|
+
if (!known.has(col)) {
|
|
1493
|
+
return Promise.reject(
|
|
1494
|
+
new Error(`Lattice: unknown column "${col}" in table "${table}"`)
|
|
1495
|
+
);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
return null;
|
|
1499
|
+
}
|
|
1500
|
+
_assertNotInit(method) {
|
|
1501
|
+
if (this._initialized) {
|
|
1502
|
+
throw new Error(`Lattice: ${method}() must be called before init()`);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
};
|
|
1506
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1507
|
+
0 && (module.exports = {
|
|
1508
|
+
Lattice,
|
|
1509
|
+
manifestPath,
|
|
1510
|
+
parseConfigFile,
|
|
1511
|
+
parseConfigString,
|
|
1512
|
+
readManifest,
|
|
1513
|
+
writeManifest
|
|
1514
|
+
});
|