latticesql 1.3.1 → 1.4.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 CHANGED
@@ -321,7 +321,15 @@ import { join as join3 } from "path";
321
321
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
322
322
 
323
323
  // src/render/writer.ts
324
- import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, renameSync, copyFileSync, unlinkSync, existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
324
+ import {
325
+ writeFileSync as writeFileSync2,
326
+ mkdirSync as mkdirSync2,
327
+ renameSync,
328
+ copyFileSync,
329
+ unlinkSync,
330
+ existsSync as existsSync2,
331
+ readFileSync as readFileSync2
332
+ } from "fs";
325
333
  import { createHash } from "crypto";
326
334
  import { dirname as dirname3, join as join2 } from "path";
327
335
  import { tmpdir } from "os";
@@ -389,15 +397,21 @@ function writeManifest(outputDir, manifest) {
389
397
 
390
398
  // src/db/sqlite.ts
391
399
  import Database from "better-sqlite3";
400
+ var DDL_RE = /^\s*(CREATE|ALTER|DROP|PRAGMA)\b/i;
401
+ var DEFAULT_CACHE_MAX = 500;
392
402
  var SQLiteAdapter = class {
393
403
  _db = null;
394
404
  _path;
395
405
  _wal;
396
406
  _busyTimeout;
407
+ /** Cached prepared statements keyed by SQL string. */
408
+ _stmtCache = /* @__PURE__ */ new Map();
409
+ _cacheMax;
397
410
  constructor(path, options) {
398
411
  this._path = path;
399
412
  this._wal = options?.wal ?? true;
400
413
  this._busyTimeout = options?.busyTimeout ?? 5e3;
414
+ this._cacheMax = options?.cacheMax ?? DEFAULT_CACHE_MAX;
401
415
  }
402
416
  get db() {
403
417
  if (!this._db) throw new Error("SQLiteAdapter: not open \u2014 call open() first");
@@ -411,20 +425,46 @@ var SQLiteAdapter = class {
411
425
  }
412
426
  }
413
427
  close() {
428
+ this._stmtCache.clear();
414
429
  this._db?.close();
415
430
  this._db = null;
416
431
  }
432
+ /** Clear the prepared-statement cache. Call after DDL changes (schema/migrations). */
433
+ clearStatementCache() {
434
+ this._stmtCache.clear();
435
+ }
436
+ /**
437
+ * Return a cached prepared statement for the given SQL, or compile and cache a new one.
438
+ * DDL statements bypass the cache entirely.
439
+ */
440
+ _cachedPrepare(sql) {
441
+ if (DDL_RE.test(sql)) {
442
+ return this.db.prepare(sql);
443
+ }
444
+ let stmt = this._stmtCache.get(sql);
445
+ if (stmt) return stmt;
446
+ stmt = this.db.prepare(sql);
447
+ if (this._stmtCache.size >= this._cacheMax) {
448
+ this._stmtCache.clear();
449
+ }
450
+ this._stmtCache.set(sql, stmt);
451
+ return stmt;
452
+ }
417
453
  run(sql, params = []) {
418
- this.db.prepare(sql).run(...params);
454
+ this._cachedPrepare(sql).run(...params);
419
455
  }
420
456
  get(sql, params = []) {
421
- return this.db.prepare(sql).get(...params);
457
+ return this._cachedPrepare(sql).get(...params);
422
458
  }
423
459
  all(sql, params = []) {
424
- return this.db.prepare(sql).all(...params);
460
+ return this._cachedPrepare(sql).all(...params);
461
+ }
462
+ /** Execute raw SQL that may contain multiple statements (e.g. migrations). */
463
+ exec(sql) {
464
+ this.db.exec(sql);
425
465
  }
426
466
  prepare(sql) {
427
- const stmt = this.db.prepare(sql);
467
+ const stmt = this._cachedPrepare(sql);
428
468
  return {
429
469
  run: (...params) => stmt.run(...params),
430
470
  get: (...params) => stmt.get(...params),
@@ -518,19 +558,40 @@ var SchemaManager = class {
518
558
  });
519
559
  }
520
560
  /** Run explicit versioned migrations in order, idempotently */
521
- applyMigrations(adapter, migrations) {
561
+ applyMigrations(adapter, migrations, validator) {
522
562
  const sorted = [...migrations].sort((a, b) => {
523
563
  const va = String(a.version);
524
564
  const vb = String(b.version);
525
565
  return va.localeCompare(vb, void 0, { numeric: true });
526
566
  });
567
+ if (validator) {
568
+ const errors = [];
569
+ for (const m of sorted) {
570
+ const versionStr = String(m.version);
571
+ const exists = adapter.get("SELECT 1 FROM __lattice_migrations WHERE version = ?", [
572
+ versionStr
573
+ ]);
574
+ if (!exists) {
575
+ const result = validator(m.sql);
576
+ if (!result.valid) {
577
+ errors.push(
578
+ `Migration ${versionStr}: ${(result.errors ?? ["invalid SQL"]).join("; ")}`
579
+ );
580
+ }
581
+ }
582
+ }
583
+ if (errors.length > 0) {
584
+ throw new Error(`Migration validation failed:
585
+ ${errors.join("\n")}`);
586
+ }
587
+ }
527
588
  for (const m of sorted) {
528
589
  const versionStr = String(m.version);
529
590
  const exists = adapter.get("SELECT 1 FROM __lattice_migrations WHERE version = ?", [
530
591
  versionStr
531
592
  ]);
532
593
  if (!exists) {
533
- adapter.run(m.sql);
594
+ adapter.exec(m.sql);
534
595
  adapter.run("INSERT INTO __lattice_migrations (version, applied_at) VALUES (?, ?)", [
535
596
  versionStr,
536
597
  (/* @__PURE__ */ new Date()).toISOString()
@@ -868,6 +929,223 @@ function truncateContent(content, budget) {
868
929
  if (budget === void 0 || content.length <= budget) return content;
869
930
  return content.slice(0, budget) + "\n\n*[truncated \u2014 context budget exceeded]*";
870
931
  }
932
+ var BATCH_CHUNK_SIZE = 500;
933
+ function groupBy(rows, keyCol) {
934
+ const map = /* @__PURE__ */ new Map();
935
+ for (const row of rows) {
936
+ const key = String(row[keyCol] ?? "");
937
+ let arr = map.get(key);
938
+ if (!arr) {
939
+ arr = [];
940
+ map.set(key, arr);
941
+ }
942
+ arr.push(row);
943
+ }
944
+ return map;
945
+ }
946
+ function buildBatchClauses(params, opts, tableAlias) {
947
+ let sql = "";
948
+ const prefix = tableAlias ? `${tableAlias}.` : "";
949
+ for (const f of effectiveFilters(opts)) {
950
+ if (!SAFE_COL_RE.test(f.col)) continue;
951
+ switch (f.op) {
952
+ case "eq":
953
+ sql += ` AND ${prefix}"${f.col}" = ?`;
954
+ params.push(f.val);
955
+ break;
956
+ case "ne":
957
+ sql += ` AND ${prefix}"${f.col}" != ?`;
958
+ params.push(f.val);
959
+ break;
960
+ case "gt":
961
+ sql += ` AND ${prefix}"${f.col}" > ?`;
962
+ params.push(f.val);
963
+ break;
964
+ case "gte":
965
+ sql += ` AND ${prefix}"${f.col}" >= ?`;
966
+ params.push(f.val);
967
+ break;
968
+ case "lt":
969
+ sql += ` AND ${prefix}"${f.col}" < ?`;
970
+ params.push(f.val);
971
+ break;
972
+ case "lte":
973
+ sql += ` AND ${prefix}"${f.col}" <= ?`;
974
+ params.push(f.val);
975
+ break;
976
+ case "like":
977
+ sql += ` AND ${prefix}"${f.col}" LIKE ?`;
978
+ params.push(f.val);
979
+ break;
980
+ case "in": {
981
+ const arr = f.val;
982
+ if (arr.length === 0) {
983
+ sql += " AND 0";
984
+ } else {
985
+ sql += ` AND ${prefix}"${f.col}" IN (${arr.map(() => "?").join(", ")})`;
986
+ params.push(...arr);
987
+ }
988
+ break;
989
+ }
990
+ case "isNull":
991
+ sql += ` AND ${prefix}"${f.col}" IS NULL`;
992
+ break;
993
+ case "isNotNull":
994
+ sql += ` AND ${prefix}"${f.col}" IS NOT NULL`;
995
+ break;
996
+ }
997
+ }
998
+ if (opts.orderBy) {
999
+ if (typeof opts.orderBy === "string") {
1000
+ if (SAFE_COL_RE.test(opts.orderBy)) {
1001
+ const dir = opts.orderDir === "desc" ? "DESC" : "ASC";
1002
+ sql += ` ORDER BY ${prefix}"${opts.orderBy}" ${dir}`;
1003
+ }
1004
+ } else {
1005
+ const clauses = opts.orderBy.filter((spec) => SAFE_COL_RE.test(spec.col)).map((spec) => `${prefix}"${spec.col}" ${spec.dir === "desc" ? "DESC" : "ASC"}`);
1006
+ if (clauses.length > 0) {
1007
+ sql += ` ORDER BY ${clauses.join(", ")}`;
1008
+ }
1009
+ }
1010
+ }
1011
+ return sql;
1012
+ }
1013
+ function batchQuery(adapter, buildSql, inValues, extraParams) {
1014
+ if (inValues.length === 0) return [];
1015
+ const allRows = [];
1016
+ for (let i = 0; i < inValues.length; i += BATCH_CHUNK_SIZE) {
1017
+ const chunk = inValues.slice(i, i + BATCH_CHUNK_SIZE);
1018
+ const placeholders = chunk.map(() => "?").join(", ");
1019
+ const sql = buildSql(placeholders);
1020
+ allRows.push(...adapter.all(sql, [...chunk, ...extraParams]));
1021
+ }
1022
+ return allRows;
1023
+ }
1024
+ function batchPrefetchEntitySources(files, allEntityRows, entityPk, adapter, protection) {
1025
+ const results = /* @__PURE__ */ new Map();
1026
+ const unbatched = /* @__PURE__ */ new Set();
1027
+ const allPkValues = allEntityRows.map((r) => r[entityPk]);
1028
+ for (const [filename, spec] of Object.entries(files)) {
1029
+ const source = spec.source;
1030
+ if (source.type === "self") {
1031
+ continue;
1032
+ }
1033
+ if (source.type === "custom" || source.type === "enriched") {
1034
+ unbatched.add(filename);
1035
+ continue;
1036
+ }
1037
+ if (source.type === "hasMany") {
1038
+ if (protection?.protectedTables.has(source.table)) {
1039
+ unbatched.add(filename);
1040
+ continue;
1041
+ }
1042
+ const ref = source.references ?? entityPk;
1043
+ const pkValues = allEntityRows.map((r) => r[ref]);
1044
+ const extraParams = [];
1045
+ const clauses = buildBatchClauses(extraParams, source);
1046
+ const rows = batchQuery(
1047
+ adapter,
1048
+ (ph) => `SELECT * FROM "${source.table}" WHERE "${source.foreignKey}" IN (${ph})${clauses}`,
1049
+ pkValues,
1050
+ extraParams
1051
+ );
1052
+ const grouped = groupBy(rows, source.foreignKey);
1053
+ if (source.limit !== void 0 && source.limit > 0) {
1054
+ for (const [key, arr] of grouped) {
1055
+ if (arr.length > source.limit) grouped.set(key, arr.slice(0, source.limit));
1056
+ }
1057
+ }
1058
+ results.set(filename, grouped);
1059
+ continue;
1060
+ }
1061
+ if (source.type === "manyToMany") {
1062
+ if (protection?.protectedTables.has(source.remoteTable)) {
1063
+ unbatched.add(filename);
1064
+ continue;
1065
+ }
1066
+ const remotePk = source.references ?? "id";
1067
+ const extraParams = [];
1068
+ const clauses = buildBatchClauses(extraParams, source, "r");
1069
+ let selectCols = "r.*";
1070
+ if (source.junctionColumns?.length) {
1071
+ const jCols = source.junctionColumns.map((jc) => {
1072
+ if (typeof jc === "string") {
1073
+ if (!SAFE_COL_RE.test(jc)) return null;
1074
+ return `j."${jc}"`;
1075
+ }
1076
+ if (!SAFE_COL_RE.test(jc.col) || !SAFE_COL_RE.test(jc.as)) return null;
1077
+ return `j."${jc.col}" AS "${jc.as}"`;
1078
+ }).filter(Boolean);
1079
+ if (jCols.length > 0) selectCols += ", " + jCols.join(", ");
1080
+ }
1081
+ const batchKeyCol = "__lattice_batch_key";
1082
+ const rows = batchQuery(
1083
+ adapter,
1084
+ (ph) => `SELECT ${selectCols}, j."${source.localKey}" AS "${batchKeyCol}" FROM "${source.remoteTable}" r JOIN "${source.junctionTable}" j ON j."${source.remoteKey}" = r."${remotePk}" WHERE j."${source.localKey}" IN (${ph})${clauses}`,
1085
+ allPkValues,
1086
+ extraParams
1087
+ );
1088
+ const grouped = /* @__PURE__ */ new Map();
1089
+ for (const row of rows) {
1090
+ const key = String(row[batchKeyCol] ?? "");
1091
+ delete row[batchKeyCol];
1092
+ let arr = grouped.get(key);
1093
+ if (!arr) {
1094
+ arr = [];
1095
+ grouped.set(key, arr);
1096
+ }
1097
+ arr.push(row);
1098
+ }
1099
+ if (source.limit !== void 0 && source.limit > 0) {
1100
+ for (const [key, arr] of grouped) {
1101
+ if (arr.length > source.limit) grouped.set(key, arr.slice(0, source.limit));
1102
+ }
1103
+ }
1104
+ results.set(filename, grouped);
1105
+ continue;
1106
+ }
1107
+ if (source.type === "belongsTo") {
1108
+ if (protection?.protectedTables.has(source.table)) {
1109
+ unbatched.add(filename);
1110
+ continue;
1111
+ }
1112
+ const fkValues = [
1113
+ ...new Set(allEntityRows.map((r) => r[source.foreignKey]).filter((v) => v != null))
1114
+ ];
1115
+ if (fkValues.length === 0) {
1116
+ results.set(filename, /* @__PURE__ */ new Map());
1117
+ continue;
1118
+ }
1119
+ const refCol = source.references ?? "id";
1120
+ const extraParams = [];
1121
+ const clauses = buildBatchClauses(extraParams, source);
1122
+ const rows = batchQuery(
1123
+ adapter,
1124
+ (ph) => `SELECT * FROM "${source.table}" WHERE "${refCol}" IN (${ph})${clauses}`,
1125
+ fkValues,
1126
+ extraParams
1127
+ );
1128
+ const lookup = /* @__PURE__ */ new Map();
1129
+ for (const row of rows) {
1130
+ lookup.set(String(row[refCol] ?? ""), row);
1131
+ }
1132
+ const grouped = /* @__PURE__ */ new Map();
1133
+ for (const entityRow of allEntityRows) {
1134
+ const fkVal = entityRow[source.foreignKey];
1135
+ const pkVal = String(entityRow[entityPk] ?? "");
1136
+ if (fkVal == null) {
1137
+ grouped.set(pkVal, []);
1138
+ } else {
1139
+ const related = lookup.get(String(fkVal));
1140
+ grouped.set(pkVal, related ? [related] : []);
1141
+ }
1142
+ }
1143
+ results.set(filename, grouped);
1144
+ continue;
1145
+ }
1146
+ }
1147
+ return { results, unbatched };
1148
+ }
871
1149
 
872
1150
  // src/render/markdown.ts
873
1151
  function frontmatter(fields) {
@@ -1168,7 +1446,9 @@ var RenderEngine = class {
1168
1446
  }
1169
1447
  }
1170
1448
  if (!def.prioritizeBy) {
1171
- rows.sort((a, b) => (b._reward_total ?? 0) - (a._reward_total ?? 0));
1449
+ rows.sort(
1450
+ (a, b) => (b._reward_total ?? 0) - (a._reward_total ?? 0)
1451
+ );
1172
1452
  }
1173
1453
  }
1174
1454
  if (def.enrich) {
@@ -1270,13 +1550,25 @@ var RenderEngine = class {
1270
1550
  counters.skipped++;
1271
1551
  }
1272
1552
  }
1553
+ const protection = protectedTables.size > 0 ? { protectedTables, currentTable: table } : void 0;
1554
+ const mergedFiles = {};
1555
+ for (const [filename, spec] of Object.entries(def.files)) {
1556
+ const mergeDefaults = def.sourceDefaults && spec.source.type !== "self" && spec.source.type !== "custom" && spec.source.type !== "enriched";
1557
+ const source = mergeDefaults ? { ...def.sourceDefaults, ...spec.source } : spec.source;
1558
+ mergedFiles[filename] = { source, limit: spec.budget };
1559
+ }
1560
+ const batch = batchPrefetchEntitySources(
1561
+ mergedFiles,
1562
+ allRows,
1563
+ entityPk,
1564
+ this._adapter,
1565
+ protection
1566
+ );
1273
1567
  for (const entityRow of allRows) {
1274
1568
  const rawSlug = def.slug(entityRow);
1275
1569
  const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\x00-\x1F\x7F]/g, "");
1276
1570
  if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~\[\]]/.test(slug)) {
1277
- throw new Error(
1278
- `Invalid slug "${slug}": contains characters outside the allowed set`
1279
- );
1571
+ throw new Error(`Invalid slug "${slug}": contains characters outside the allowed set`);
1280
1572
  }
1281
1573
  const entityDir = def.directory ? join5(outputDir, def.directory(entityRow)) : join5(outputDir, directoryRoot, slug);
1282
1574
  const resolvedDir = resolve3(entityDir);
@@ -1303,11 +1595,19 @@ var RenderEngine = class {
1303
1595
  }
1304
1596
  const renderedFiles = /* @__PURE__ */ new Map();
1305
1597
  const entityFileHashes = {};
1306
- const protection = protectedTables.size > 0 ? { protectedTables, currentTable: table } : void 0;
1598
+ const entityPkVal = String(entityRow[entityPk] ?? "");
1307
1599
  for (const [filename, spec] of Object.entries(def.files)) {
1308
- const mergeDefaults = def.sourceDefaults && spec.source.type !== "self" && spec.source.type !== "custom" && spec.source.type !== "enriched";
1309
- const source = mergeDefaults ? { ...def.sourceDefaults, ...spec.source } : spec.source;
1310
- const rows = resolveEntitySource(source, entityRow, entityPk, this._adapter, protection);
1600
+ let rows;
1601
+ if (spec.source.type === "self") {
1602
+ rows = [entityRow];
1603
+ } else if (batch.unbatched.has(filename)) {
1604
+ const source = mergedFiles[filename].source;
1605
+ rows = resolveEntitySource(source, entityRow, entityPk, this._adapter, protection);
1606
+ } else if (batch.results.has(filename)) {
1607
+ rows = batch.results.get(filename).get(entityPkVal) ?? [];
1608
+ } else {
1609
+ rows = [];
1610
+ }
1311
1611
  if (spec.omitIfEmpty && rows.length === 0) continue;
1312
1612
  const renderFn = compileEntityRender(spec.render);
1313
1613
  const content = truncateContent(renderFn(rows), spec.budget);
@@ -1787,10 +2087,10 @@ async function storeEmbedding(adapter, table, pk, row, config) {
1787
2087
  );
1788
2088
  }
1789
2089
  function removeEmbedding(adapter, table, pk) {
1790
- adapter.run(
1791
- `DELETE FROM "${EMBEDDINGS_TABLE}" WHERE "table_name" = ? AND "row_pk" = ?`,
1792
- [table, pk]
1793
- );
2090
+ adapter.run(`DELETE FROM "${EMBEDDINGS_TABLE}" WHERE "table_name" = ? AND "row_pk" = ?`, [
2091
+ table,
2092
+ pk
2093
+ ]);
1794
2094
  }
1795
2095
  function cosineSimilarity(a, b) {
1796
2096
  const len = Math.min(a.length, b.length);
@@ -1851,6 +2151,10 @@ var Lattice = class {
1851
2151
  _encryptionKeyRaw;
1852
2152
  /** Current task context string for relevance filtering. */
1853
2153
  _taskContext = "";
2154
+ /** Per-table write version counter, incremented on every mutation. */
2155
+ _tableWriteVersion = /* @__PURE__ */ new Map();
2156
+ /** Snapshot of write versions at the last successful render. */
2157
+ _lastRenderedVersions = /* @__PURE__ */ new Map();
1854
2158
  _auditHandlers = [];
1855
2159
  _renderHandlers = [];
1856
2160
  _writebackHandlers = [];
@@ -1944,8 +2248,9 @@ var Lattice = class {
1944
2248
  this._adapter.open();
1945
2249
  this._schema.applySchema(this._adapter);
1946
2250
  if (options.migrations?.length) {
1947
- this._schema.applyMigrations(this._adapter, options.migrations);
2251
+ this._schema.applyMigrations(this._adapter, options.migrations, options.validateMigrationSQL);
1948
2252
  }
2253
+ this._adapter.clearStatementCache();
1949
2254
  for (const tableName of this._schema.getTables().keys()) {
1950
2255
  const rows = this._adapter.all(`PRAGMA table_info("${tableName}")`);
1951
2256
  this._columnCache.set(tableName, new Set(rows.map((r) => r.name)));
@@ -1969,6 +2274,7 @@ var Lattice = class {
1969
2274
  return Promise.reject(new Error("Lattice: not initialized \u2014 call init() first"));
1970
2275
  }
1971
2276
  this._schema.applyMigrations(this._adapter, migrations);
2277
+ this._adapter.clearStatementCache();
1972
2278
  for (const tableName of this._schema.getTables().keys()) {
1973
2279
  const rows = this._adapter.all(`PRAGMA table_info("${tableName}")`);
1974
2280
  this._columnCache.set(tableName, new Set(rows.map((r) => r.name)));
@@ -1993,6 +2299,20 @@ var Lattice = class {
1993
2299
  this._taskContext = context;
1994
2300
  return this;
1995
2301
  }
2302
+ /**
2303
+ * Mark one or all tables as dirty so the next render() will run a full cycle.
2304
+ * Use this after writing directly via the `db` escape hatch.
2305
+ */
2306
+ markDirty(table) {
2307
+ if (table) {
2308
+ this._bumpWriteVersion(table);
2309
+ } else {
2310
+ for (const t of this._schema.getTables().keys()) {
2311
+ this._bumpWriteVersion(t);
2312
+ }
2313
+ }
2314
+ return this;
2315
+ }
1996
2316
  /** Return the current task context string. */
1997
2317
  getTaskContext() {
1998
2318
  return this._taskContext;
@@ -2113,6 +2433,7 @@ var Lattice = class {
2113
2433
  const rawPk = rowWithPk[pkCol];
2114
2434
  const pkValue = rawPk != null ? String(rawPk) : "";
2115
2435
  this._sanitizer.emitAudit(table, "update", pkValue);
2436
+ this._bumpWriteVersion(table);
2116
2437
  return Promise.resolve(pkValue);
2117
2438
  }
2118
2439
  upsertBy(table, col, val, row) {
@@ -2289,6 +2610,7 @@ var Lattice = class {
2289
2610
  AND (deleted_at IS NULL OR deleted_at = '')`,
2290
2611
  [sourceFile, ...currentKeys]
2291
2612
  );
2613
+ this._bumpWriteVersion(table);
2292
2614
  }
2293
2615
  return Promise.resolve(count);
2294
2616
  }
@@ -2345,6 +2667,7 @@ var Lattice = class {
2345
2667
  `${verb} INTO "${junctionTable}" (${colNames}) VALUES (${placeholders})`,
2346
2668
  Object.values(filtered)
2347
2669
  );
2670
+ this._bumpWriteVersion(junctionTable);
2348
2671
  return Promise.resolve();
2349
2672
  }
2350
2673
  /**
@@ -2360,6 +2683,7 @@ var Lattice = class {
2360
2683
  `DELETE FROM "${junctionTable}" WHERE ${where}`,
2361
2684
  entries.map(([, v]) => v)
2362
2685
  );
2686
+ this._bumpWriteVersion(junctionTable);
2363
2687
  return Promise.resolve();
2364
2688
  }
2365
2689
  // -------------------------------------------------------------------------
@@ -2563,9 +2887,7 @@ var Lattice = class {
2563
2887
  if (notInit) return notInit;
2564
2888
  const def = this._schema.getTables().get(table);
2565
2889
  if (!def?.rewardTracking) {
2566
- return Promise.reject(
2567
- new Error(`Table "${table}" does not have rewardTracking enabled`)
2568
- );
2890
+ return Promise.reject(new Error(`Table "${table}" does not have rewardTracking enabled`));
2569
2891
  }
2570
2892
  const vals = Object.values(scores);
2571
2893
  if (vals.length === 0) return Promise.resolve();
@@ -2575,6 +2897,7 @@ var Lattice = class {
2575
2897
  `UPDATE "${table}" SET "_reward_total" = ("_reward_total" * "_reward_count" + ?) / ("_reward_count" + 1), "_reward_count" = "_reward_count" + 1 WHERE ${clause}`,
2576
2898
  [avg, ...pkParams]
2577
2899
  );
2900
+ this._bumpWriteVersion(table);
2578
2901
  return Promise.resolve();
2579
2902
  }
2580
2903
  // -------------------------------------------------------------------------
@@ -2594,9 +2917,7 @@ var Lattice = class {
2594
2917
  if (notInit) return notInit;
2595
2918
  const def = this._schema.getTables().get(table);
2596
2919
  if (!def?.embeddings) {
2597
- return Promise.reject(
2598
- new Error(`Table "${table}" does not have embeddings configured`)
2599
- );
2920
+ return Promise.reject(new Error(`Table "${table}" does not have embeddings configured`));
2600
2921
  }
2601
2922
  const pkCol = this._schema.getPrimaryKey(table)[0] ?? "id";
2602
2923
  return searchByEmbedding(
@@ -2683,13 +3004,27 @@ var Lattice = class {
2683
3004
  const notInit = this._notInitError();
2684
3005
  if (notInit) return notInit;
2685
3006
  const result = await this._render.render(outputDir);
3007
+ for (const [t, v] of this._tableWriteVersion) {
3008
+ this._lastRenderedVersions.set(t, v);
3009
+ }
2686
3010
  for (const h of this._renderHandlers) h(result);
2687
3011
  return result;
2688
3012
  }
3013
+ /**
3014
+ * Returns `true` when no table has been written to since the last render.
3015
+ * Useful for consumers implementing their own polling/watch loops to skip
3016
+ * redundant render cycles.
3017
+ */
3018
+ isDirty() {
3019
+ return !this._isClean();
3020
+ }
2689
3021
  async sync(outputDir) {
2690
3022
  const notInit = this._notInitError();
2691
3023
  if (notInit) return notInit;
2692
3024
  const renderResult = await this._render.render(outputDir);
3025
+ for (const [t, v] of this._tableWriteVersion) {
3026
+ this._lastRenderedVersions.set(t, v);
3027
+ }
2693
3028
  for (const h of this._renderHandlers) h(renderResult);
2694
3029
  const writebackProcessed = await this._writeback.process();
2695
3030
  return { ...renderResult, writebackProcessed };
@@ -2850,8 +3185,21 @@ var Lattice = class {
2850
3185
  }
2851
3186
  return { clauses, params };
2852
3187
  }
3188
+ /** Increment the write version for a table, marking it dirty for render. */
3189
+ _bumpWriteVersion(table) {
3190
+ this._tableWriteVersion.set(table, (this._tableWriteVersion.get(table) ?? 0) + 1);
3191
+ }
3192
+ /** True when no table has been written to since the last render. */
3193
+ _isClean() {
3194
+ if (this._lastRenderedVersions.size === 0) return false;
3195
+ for (const [table, version] of this._tableWriteVersion) {
3196
+ if (version !== (this._lastRenderedVersions.get(table) ?? -1)) return false;
3197
+ }
3198
+ return true;
3199
+ }
2853
3200
  /** Returns a rejected Promise if not initialized; null if ready. */
2854
3201
  _fireWriteHooks(table, op, row, pk, changedColumns) {
3202
+ this._bumpWriteVersion(table);
2855
3203
  for (const hook of this._writeHooks) {
2856
3204
  if (hook.table !== table) continue;
2857
3205
  if (!hook.on.includes(op)) continue;