latticesql 0.8.0 → 0.10.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
@@ -757,6 +757,165 @@ function truncateContent(content, budget) {
757
757
  return content.slice(0, budget) + "\n\n*[truncated \u2014 context budget exceeded]*";
758
758
  }
759
759
 
760
+ // src/render/markdown.ts
761
+ function frontmatter(fields) {
762
+ const lines = [`generated_at: "${(/* @__PURE__ */ new Date()).toISOString()}"`];
763
+ for (const [key, val] of Object.entries(fields)) {
764
+ lines.push(typeof val === "string" ? `${key}: "${val}"` : `${key}: ${String(val)}`);
765
+ }
766
+ return `---
767
+ ${lines.join("\n")}
768
+ ---
769
+
770
+ `;
771
+ }
772
+ function markdownTable(rows, columns) {
773
+ if (rows.length === 0 || columns.length === 0) return "";
774
+ const header = "| " + columns.map((c) => c.header).join(" | ") + " |";
775
+ const separator = "| " + columns.map(() => "---").join(" | ") + " |";
776
+ const body = rows.map((row) => {
777
+ const cells = columns.map((col) => {
778
+ const raw = row[col.key];
779
+ return col.format ? col.format(raw, row) : String(raw ?? "");
780
+ });
781
+ return "| " + cells.join(" | ") + " |";
782
+ });
783
+ return [header, separator, ...body].join("\n") + "\n";
784
+ }
785
+
786
+ // src/session/constants.ts
787
+ function createReadOnlyHeader(options) {
788
+ const generator = options?.generator ?? "Lattice";
789
+ const docsRef = options?.docsRef ?? "the Lattice documentation";
790
+ return `<!-- READ ONLY \u2014 generated by ${generator}. Do not edit directly.
791
+ To update data in Lattice: write entries to SESSION.md in this directory.
792
+ Format: type: write | op: create/update/delete | table: <name> | target: <id>
793
+ See ${docsRef} for the SESSION.md format spec. -->
794
+
795
+ `;
796
+ }
797
+ var READ_ONLY_HEADER = createReadOnlyHeader();
798
+
799
+ // src/render/entity-templates.ts
800
+ var DEFAULT_HEADER = createReadOnlyHeader();
801
+ function compileEntityRender(spec) {
802
+ if (typeof spec === "function") return spec;
803
+ return compileTemplate(spec);
804
+ }
805
+ function compileTemplate(tmpl) {
806
+ switch (tmpl.template) {
807
+ case "entity-table":
808
+ return compileEntityTable(tmpl);
809
+ case "entity-profile":
810
+ return compileEntityProfile(tmpl);
811
+ case "entity-sections":
812
+ return compileEntitySections(tmpl);
813
+ }
814
+ }
815
+ function compileEntityTable(tmpl) {
816
+ return (rows) => {
817
+ const data = tmpl.beforeRender ? tmpl.beforeRender(rows) : rows;
818
+ let md = DEFAULT_HEADER;
819
+ md += frontmatter(tmpl.frontmatter ?? {});
820
+ md += `# ${tmpl.heading}
821
+
822
+ `;
823
+ if (data.length === 0) {
824
+ md += tmpl.emptyMessage ?? "*No data.*\n";
825
+ } else {
826
+ md += markdownTable(data, tmpl.columns);
827
+ }
828
+ return md;
829
+ };
830
+ }
831
+ function compileEntityProfile(tmpl) {
832
+ return (rows) => {
833
+ const data = tmpl.beforeRender ? tmpl.beforeRender(rows) : rows;
834
+ const r = data[0];
835
+ if (!r) return "";
836
+ let md = DEFAULT_HEADER;
837
+ if (tmpl.frontmatter) {
838
+ const fm = typeof tmpl.frontmatter === "function" ? tmpl.frontmatter(r) : tmpl.frontmatter;
839
+ md += frontmatter(fm);
840
+ } else {
841
+ md += frontmatter({});
842
+ }
843
+ const heading = typeof tmpl.heading === "function" ? tmpl.heading(r) : tmpl.heading;
844
+ md += `# ${heading}
845
+
846
+ `;
847
+ for (const field of tmpl.fields) {
848
+ const val = r[field.key];
849
+ if (val === null || val === void 0) continue;
850
+ const formatted = field.format ? field.format(val, r) : String(val);
851
+ if (formatted) {
852
+ md += `**${field.label}:** ${formatted}
853
+ `;
854
+ }
855
+ }
856
+ if (tmpl.sections) {
857
+ for (const section of tmpl.sections) {
858
+ const rawJson = r[`_${section.key}`];
859
+ if (!rawJson) continue;
860
+ if (section.condition && !section.condition(r)) continue;
861
+ const items = JSON.parse(rawJson);
862
+ if (items.length === 0) continue;
863
+ const sectionHeading = typeof section.heading === "function" ? section.heading(r) : section.heading;
864
+ md += `
865
+ ## ${sectionHeading}
866
+
867
+ `;
868
+ if (section.render === "table" && section.columns) {
869
+ md += markdownTable(items, section.columns);
870
+ } else if (section.render === "list" && section.formatItem) {
871
+ for (const item of items) {
872
+ md += `- ${section.formatItem(item)}
873
+ `;
874
+ }
875
+ } else if (typeof section.render === "function") {
876
+ md += section.render(items);
877
+ }
878
+ }
879
+ }
880
+ return md;
881
+ };
882
+ }
883
+ function compileEntitySections(tmpl) {
884
+ return (rows) => {
885
+ const data = tmpl.beforeRender ? tmpl.beforeRender(rows) : rows;
886
+ let md = DEFAULT_HEADER;
887
+ md += frontmatter(tmpl.frontmatter ?? {});
888
+ md += `# ${tmpl.heading}
889
+
890
+ `;
891
+ if (data.length === 0) {
892
+ md += tmpl.emptyMessage ?? "*No data.*\n";
893
+ return md;
894
+ }
895
+ for (const row of data) {
896
+ md += `## ${tmpl.perRow.heading(row)}
897
+ `;
898
+ if (tmpl.perRow.metadata?.length) {
899
+ const parts = tmpl.perRow.metadata.map((m) => {
900
+ const val = row[m.key];
901
+ const formatted = m.format ? m.format(val) : String(val ?? "");
902
+ return `**${m.label}:** ${formatted}`;
903
+ }).filter(Boolean);
904
+ if (parts.length > 0) {
905
+ md += parts.join(" | ") + "\n";
906
+ }
907
+ }
908
+ if (tmpl.perRow.body) {
909
+ md += `
910
+ ${tmpl.perRow.body(row)}
911
+ `;
912
+ }
913
+ md += "\n";
914
+ }
915
+ return md;
916
+ };
917
+ }
918
+
760
919
  // src/lifecycle/cleanup.ts
761
920
  import { join as join4 } from "path";
762
921
  import { existsSync as existsSync4, readdirSync, unlinkSync, rmdirSync, statSync } from "fs";
@@ -966,7 +1125,8 @@ var RenderEngine = class {
966
1125
  const source = mergeDefaults ? { ...def.sourceDefaults, ...spec.source } : spec.source;
967
1126
  const rows = resolveEntitySource(source, entityRow, entityPk, this._adapter);
968
1127
  if (spec.omitIfEmpty && rows.length === 0) continue;
969
- const content = truncateContent(spec.render(rows), spec.budget);
1128
+ const renderFn = compileEntityRender(spec.render);
1129
+ const content = truncateContent(renderFn(rows), spec.budget);
970
1130
  renderedFiles.set(filename, content);
971
1131
  const filePath = join5(entityDir, filename);
972
1132
  if (atomicWrite(filePath, content)) {
@@ -1229,6 +1389,7 @@ var Lattice = class {
1229
1389
  _renderHandlers = [];
1230
1390
  _writebackHandlers = [];
1231
1391
  _errorHandlers = [];
1392
+ _writeHooks = [];
1232
1393
  constructor(pathOrConfig, options = {}) {
1233
1394
  let dbPath;
1234
1395
  let configTables;
@@ -1288,6 +1449,14 @@ var Lattice = class {
1288
1449
  this._schema.defineEntityContext(table, def);
1289
1450
  return this;
1290
1451
  }
1452
+ /**
1453
+ * Register a write hook that fires after insert/update/delete operations.
1454
+ * Hooks run synchronously after the DB write and audit emit.
1455
+ */
1456
+ defineWriteHook(hook) {
1457
+ this._writeHooks.push(hook);
1458
+ return this;
1459
+ }
1291
1460
  defineWriteback(def) {
1292
1461
  this._writeback.define(def);
1293
1462
  return this;
@@ -1337,6 +1506,7 @@ var Lattice = class {
1337
1506
  const rawPk = rowWithPk[pkCol];
1338
1507
  const pkValue = rawPk != null ? String(rawPk) : "";
1339
1508
  this._sanitizer.emitAudit(table, "insert", pkValue);
1509
+ this._fireWriteHooks(table, "insert", rowWithPk, pkValue);
1340
1510
  return Promise.resolve(pkValue);
1341
1511
  }
1342
1512
  upsert(table, row) {
@@ -1390,6 +1560,7 @@ var Lattice = class {
1390
1560
  this._adapter.run(`UPDATE "${table}" SET ${setCols} WHERE ${clause}`, values);
1391
1561
  const auditId = typeof id === "string" ? id : JSON.stringify(id);
1392
1562
  this._sanitizer.emitAudit(table, "update", auditId);
1563
+ this._fireWriteHooks(table, "update", sanitized, auditId, Object.keys(sanitized));
1393
1564
  return Promise.resolve();
1394
1565
  }
1395
1566
  delete(table, id) {
@@ -1399,6 +1570,7 @@ var Lattice = class {
1399
1570
  this._adapter.run(`DELETE FROM "${table}" WHERE ${clause}`, params);
1400
1571
  const auditId = typeof id === "string" ? id : JSON.stringify(id);
1401
1572
  this._sanitizer.emitAudit(table, "delete", auditId);
1573
+ this._fireWriteHooks(table, "delete", { id: auditId }, auditId);
1402
1574
  return Promise.resolve();
1403
1575
  }
1404
1576
  get(table, id) {
@@ -1636,6 +1808,22 @@ var Lattice = class {
1636
1808
  return { clauses, params };
1637
1809
  }
1638
1810
  /** Returns a rejected Promise if not initialized; null if ready. */
1811
+ _fireWriteHooks(table, op, row, pk, changedColumns) {
1812
+ for (const hook of this._writeHooks) {
1813
+ if (hook.table !== table) continue;
1814
+ if (!hook.on.includes(op)) continue;
1815
+ if (op === "update" && hook.watchColumns && changedColumns) {
1816
+ if (!hook.watchColumns.some((c) => changedColumns.includes(c))) continue;
1817
+ }
1818
+ try {
1819
+ const ctx = { table, op, row, pk };
1820
+ if (changedColumns) ctx.changedColumns = changedColumns;
1821
+ hook.handler(ctx);
1822
+ } catch (err) {
1823
+ for (const h of this._errorHandlers) h(err instanceof Error ? err : new Error(String(err)));
1824
+ }
1825
+ }
1826
+ }
1639
1827
  _notInitError() {
1640
1828
  if (!this._initialized) {
1641
1829
  return Promise.reject(
package/dist/index.cjs CHANGED
@@ -499,6 +499,172 @@ function truncateContent(content, budget) {
499
499
  return content.slice(0, budget) + "\n\n*[truncated \u2014 context budget exceeded]*";
500
500
  }
501
501
 
502
+ // src/render/markdown.ts
503
+ function frontmatter(fields) {
504
+ const lines = [`generated_at: "${(/* @__PURE__ */ new Date()).toISOString()}"`];
505
+ for (const [key, val] of Object.entries(fields)) {
506
+ lines.push(typeof val === "string" ? `${key}: "${val}"` : `${key}: ${String(val)}`);
507
+ }
508
+ return `---
509
+ ${lines.join("\n")}
510
+ ---
511
+
512
+ `;
513
+ }
514
+ function markdownTable(rows, columns) {
515
+ if (rows.length === 0 || columns.length === 0) return "";
516
+ const header = "| " + columns.map((c) => c.header).join(" | ") + " |";
517
+ const separator = "| " + columns.map(() => "---").join(" | ") + " |";
518
+ const body = rows.map((row) => {
519
+ const cells = columns.map((col) => {
520
+ const raw = row[col.key];
521
+ return col.format ? col.format(raw, row) : String(raw ?? "");
522
+ });
523
+ return "| " + cells.join(" | ") + " |";
524
+ });
525
+ return [header, separator, ...body].join("\n") + "\n";
526
+ }
527
+ function slugify(name) {
528
+ return name.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/\u0131/g, "i").replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
529
+ }
530
+ function truncate(content, maxChars, notice = "\n\n*[truncated \u2014 context budget exceeded]*") {
531
+ if (content.length <= maxChars) return content;
532
+ return content.slice(0, maxChars) + notice;
533
+ }
534
+
535
+ // src/session/constants.ts
536
+ function createReadOnlyHeader(options) {
537
+ const generator = options?.generator ?? "Lattice";
538
+ const docsRef = options?.docsRef ?? "the Lattice documentation";
539
+ return `<!-- READ ONLY \u2014 generated by ${generator}. Do not edit directly.
540
+ To update data in Lattice: write entries to SESSION.md in this directory.
541
+ Format: type: write | op: create/update/delete | table: <name> | target: <id>
542
+ See ${docsRef} for the SESSION.md format spec. -->
543
+
544
+ `;
545
+ }
546
+ var READ_ONLY_HEADER = createReadOnlyHeader();
547
+
548
+ // src/render/entity-templates.ts
549
+ var DEFAULT_HEADER = createReadOnlyHeader();
550
+ function compileEntityRender(spec) {
551
+ if (typeof spec === "function") return spec;
552
+ return compileTemplate(spec);
553
+ }
554
+ function compileTemplate(tmpl) {
555
+ switch (tmpl.template) {
556
+ case "entity-table":
557
+ return compileEntityTable(tmpl);
558
+ case "entity-profile":
559
+ return compileEntityProfile(tmpl);
560
+ case "entity-sections":
561
+ return compileEntitySections(tmpl);
562
+ }
563
+ }
564
+ function compileEntityTable(tmpl) {
565
+ return (rows) => {
566
+ const data = tmpl.beforeRender ? tmpl.beforeRender(rows) : rows;
567
+ let md = DEFAULT_HEADER;
568
+ md += frontmatter(tmpl.frontmatter ?? {});
569
+ md += `# ${tmpl.heading}
570
+
571
+ `;
572
+ if (data.length === 0) {
573
+ md += tmpl.emptyMessage ?? "*No data.*\n";
574
+ } else {
575
+ md += markdownTable(data, tmpl.columns);
576
+ }
577
+ return md;
578
+ };
579
+ }
580
+ function compileEntityProfile(tmpl) {
581
+ return (rows) => {
582
+ const data = tmpl.beforeRender ? tmpl.beforeRender(rows) : rows;
583
+ const r = data[0];
584
+ if (!r) return "";
585
+ let md = DEFAULT_HEADER;
586
+ if (tmpl.frontmatter) {
587
+ const fm = typeof tmpl.frontmatter === "function" ? tmpl.frontmatter(r) : tmpl.frontmatter;
588
+ md += frontmatter(fm);
589
+ } else {
590
+ md += frontmatter({});
591
+ }
592
+ const heading = typeof tmpl.heading === "function" ? tmpl.heading(r) : tmpl.heading;
593
+ md += `# ${heading}
594
+
595
+ `;
596
+ for (const field of tmpl.fields) {
597
+ const val = r[field.key];
598
+ if (val === null || val === void 0) continue;
599
+ const formatted = field.format ? field.format(val, r) : String(val);
600
+ if (formatted) {
601
+ md += `**${field.label}:** ${formatted}
602
+ `;
603
+ }
604
+ }
605
+ if (tmpl.sections) {
606
+ for (const section of tmpl.sections) {
607
+ const rawJson = r[`_${section.key}`];
608
+ if (!rawJson) continue;
609
+ if (section.condition && !section.condition(r)) continue;
610
+ const items = JSON.parse(rawJson);
611
+ if (items.length === 0) continue;
612
+ const sectionHeading = typeof section.heading === "function" ? section.heading(r) : section.heading;
613
+ md += `
614
+ ## ${sectionHeading}
615
+
616
+ `;
617
+ if (section.render === "table" && section.columns) {
618
+ md += markdownTable(items, section.columns);
619
+ } else if (section.render === "list" && section.formatItem) {
620
+ for (const item of items) {
621
+ md += `- ${section.formatItem(item)}
622
+ `;
623
+ }
624
+ } else if (typeof section.render === "function") {
625
+ md += section.render(items);
626
+ }
627
+ }
628
+ }
629
+ return md;
630
+ };
631
+ }
632
+ function compileEntitySections(tmpl) {
633
+ return (rows) => {
634
+ const data = tmpl.beforeRender ? tmpl.beforeRender(rows) : rows;
635
+ let md = DEFAULT_HEADER;
636
+ md += frontmatter(tmpl.frontmatter ?? {});
637
+ md += `# ${tmpl.heading}
638
+
639
+ `;
640
+ if (data.length === 0) {
641
+ md += tmpl.emptyMessage ?? "*No data.*\n";
642
+ return md;
643
+ }
644
+ for (const row of data) {
645
+ md += `## ${tmpl.perRow.heading(row)}
646
+ `;
647
+ if (tmpl.perRow.metadata?.length) {
648
+ const parts = tmpl.perRow.metadata.map((m) => {
649
+ const val = row[m.key];
650
+ const formatted = m.format ? m.format(val) : String(val ?? "");
651
+ return `**${m.label}:** ${formatted}`;
652
+ }).filter(Boolean);
653
+ if (parts.length > 0) {
654
+ md += parts.join(" | ") + "\n";
655
+ }
656
+ }
657
+ if (tmpl.perRow.body) {
658
+ md += `
659
+ ${tmpl.perRow.body(row)}
660
+ `;
661
+ }
662
+ md += "\n";
663
+ }
664
+ return md;
665
+ };
666
+ }
667
+
502
668
  // src/lifecycle/cleanup.ts
503
669
  var import_node_path3 = require("path");
504
670
  var import_node_fs3 = require("fs");
@@ -708,7 +874,8 @@ var RenderEngine = class {
708
874
  const source = mergeDefaults ? { ...def.sourceDefaults, ...spec.source } : spec.source;
709
875
  const rows = resolveEntitySource(source, entityRow, entityPk, this._adapter);
710
876
  if (spec.omitIfEmpty && rows.length === 0) continue;
711
- const content = truncateContent(spec.render(rows), spec.budget);
877
+ const renderFn = compileEntityRender(spec.render);
878
+ const content = truncateContent(renderFn(rows), spec.budget);
712
879
  renderedFiles.set(filename, content);
713
880
  const filePath = (0, import_node_path4.join)(entityDir, filename);
714
881
  if (atomicWrite(filePath, content)) {
@@ -1196,6 +1363,7 @@ var Lattice = class {
1196
1363
  _renderHandlers = [];
1197
1364
  _writebackHandlers = [];
1198
1365
  _errorHandlers = [];
1366
+ _writeHooks = [];
1199
1367
  constructor(pathOrConfig, options = {}) {
1200
1368
  let dbPath;
1201
1369
  let configTables;
@@ -1255,6 +1423,14 @@ var Lattice = class {
1255
1423
  this._schema.defineEntityContext(table, def);
1256
1424
  return this;
1257
1425
  }
1426
+ /**
1427
+ * Register a write hook that fires after insert/update/delete operations.
1428
+ * Hooks run synchronously after the DB write and audit emit.
1429
+ */
1430
+ defineWriteHook(hook) {
1431
+ this._writeHooks.push(hook);
1432
+ return this;
1433
+ }
1258
1434
  defineWriteback(def) {
1259
1435
  this._writeback.define(def);
1260
1436
  return this;
@@ -1304,6 +1480,7 @@ var Lattice = class {
1304
1480
  const rawPk = rowWithPk[pkCol];
1305
1481
  const pkValue = rawPk != null ? String(rawPk) : "";
1306
1482
  this._sanitizer.emitAudit(table, "insert", pkValue);
1483
+ this._fireWriteHooks(table, "insert", rowWithPk, pkValue);
1307
1484
  return Promise.resolve(pkValue);
1308
1485
  }
1309
1486
  upsert(table, row) {
@@ -1357,6 +1534,7 @@ var Lattice = class {
1357
1534
  this._adapter.run(`UPDATE "${table}" SET ${setCols} WHERE ${clause}`, values);
1358
1535
  const auditId = typeof id === "string" ? id : JSON.stringify(id);
1359
1536
  this._sanitizer.emitAudit(table, "update", auditId);
1537
+ this._fireWriteHooks(table, "update", sanitized, auditId, Object.keys(sanitized));
1360
1538
  return Promise.resolve();
1361
1539
  }
1362
1540
  delete(table, id) {
@@ -1366,6 +1544,7 @@ var Lattice = class {
1366
1544
  this._adapter.run(`DELETE FROM "${table}" WHERE ${clause}`, params);
1367
1545
  const auditId = typeof id === "string" ? id : JSON.stringify(id);
1368
1546
  this._sanitizer.emitAudit(table, "delete", auditId);
1547
+ this._fireWriteHooks(table, "delete", { id: auditId }, auditId);
1369
1548
  return Promise.resolve();
1370
1549
  }
1371
1550
  get(table, id) {
@@ -1603,6 +1782,22 @@ var Lattice = class {
1603
1782
  return { clauses, params };
1604
1783
  }
1605
1784
  /** Returns a rejected Promise if not initialized; null if ready. */
1785
+ _fireWriteHooks(table, op, row, pk, changedColumns) {
1786
+ for (const hook of this._writeHooks) {
1787
+ if (hook.table !== table) continue;
1788
+ if (!hook.on.includes(op)) continue;
1789
+ if (op === "update" && hook.watchColumns && changedColumns) {
1790
+ if (!hook.watchColumns.some((c) => changedColumns.includes(c))) continue;
1791
+ }
1792
+ try {
1793
+ const ctx = { table, op, row, pk };
1794
+ if (changedColumns) ctx.changedColumns = changedColumns;
1795
+ hook.handler(ctx);
1796
+ } catch (err) {
1797
+ for (const h of this._errorHandlers) h(err instanceof Error ? err : new Error(String(err)));
1798
+ }
1799
+ }
1800
+ }
1606
1801
  _notInitError() {
1607
1802
  if (!this._initialized) {
1608
1803
  return Promise.reject(
@@ -1641,39 +1836,6 @@ var Lattice = class {
1641
1836
  }
1642
1837
  };
1643
1838
 
1644
- // src/render/markdown.ts
1645
- function frontmatter(fields) {
1646
- const lines = [`generated_at: "${(/* @__PURE__ */ new Date()).toISOString()}"`];
1647
- for (const [key, val] of Object.entries(fields)) {
1648
- lines.push(typeof val === "string" ? `${key}: "${val}"` : `${key}: ${String(val)}`);
1649
- }
1650
- return `---
1651
- ${lines.join("\n")}
1652
- ---
1653
-
1654
- `;
1655
- }
1656
- function markdownTable(rows, columns) {
1657
- if (rows.length === 0 || columns.length === 0) return "";
1658
- const header = "| " + columns.map((c) => c.header).join(" | ") + " |";
1659
- const separator = "| " + columns.map(() => "---").join(" | ") + " |";
1660
- const body = rows.map((row) => {
1661
- const cells = columns.map((col) => {
1662
- const raw = row[col.key];
1663
- return col.format ? col.format(raw, row) : String(raw ?? "");
1664
- });
1665
- return "| " + cells.join(" | ") + " |";
1666
- });
1667
- return [header, separator, ...body].join("\n") + "\n";
1668
- }
1669
- function slugify(name) {
1670
- return name.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/\u0131/g, "i").replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
1671
- }
1672
- function truncate(content, maxChars, notice = "\n\n*[truncated \u2014 context budget exceeded]*") {
1673
- if (content.length <= maxChars) return content;
1674
- return content.slice(0, maxChars) + notice;
1675
- }
1676
-
1677
1839
  // src/session/parser.ts
1678
1840
  var import_node_crypto3 = require("crypto");
1679
1841
  function generateWriteEntryId(timestamp, agentName, op, table, target) {
@@ -2058,19 +2220,6 @@ function applyWriteEntry(db, entry) {
2058
2220
  return { ok: false, reason: `DB error: ${message}` };
2059
2221
  }
2060
2222
  }
2061
-
2062
- // src/session/constants.ts
2063
- function createReadOnlyHeader(options) {
2064
- const generator = options?.generator ?? "Lattice";
2065
- const docsRef = options?.docsRef ?? "the Lattice documentation";
2066
- return `<!-- READ ONLY \u2014 generated by ${generator}. Do not edit directly.
2067
- To update data in Lattice: write entries to SESSION.md in this directory.
2068
- Format: type: write | op: create/update/delete | table: <name> | target: <id>
2069
- See ${docsRef} for the SESSION.md format spec. -->
2070
-
2071
- `;
2072
- }
2073
- var READ_ONLY_HEADER = createReadOnlyHeader();
2074
2223
  // Annotate the CommonJS export names for ESM import in node:
2075
2224
  0 && (module.exports = {
2076
2225
  DEFAULT_ENTRY_TYPES,
package/dist/index.d.cts CHANGED
@@ -266,14 +266,89 @@ type EntityFileSource = SelfSource | HasManySource | ManyToManySource | BelongsT
266
266
  * }
267
267
  * ```
268
268
  */
269
+ /** Column spec for entity-table template (reuses MarkdownTableColumn). */
270
+ interface EntityTableColumn {
271
+ key: string;
272
+ header: string;
273
+ format?: (val: unknown, row: Row) => string;
274
+ }
275
+ /**
276
+ * Render a heading + GFM table. Auto-prepends read-only header + frontmatter.
277
+ */
278
+ interface EntityTableTemplate {
279
+ template: 'entity-table';
280
+ heading: string;
281
+ columns: EntityTableColumn[];
282
+ emptyMessage?: string;
283
+ frontmatter?: Record<string, string | number | boolean>;
284
+ beforeRender?: (rows: Row[]) => Row[];
285
+ }
286
+ /**
287
+ * Field spec for entity-profile template.
288
+ */
289
+ interface EntityProfileField {
290
+ key: string;
291
+ label: string;
292
+ format?: (val: unknown, row: Row) => string;
293
+ }
294
+ /**
295
+ * Section spec for entity-profile template (renders enriched JSON arrays).
296
+ */
297
+ interface EntityProfileSection {
298
+ /** Key of the enriched field (e.g. 'agents' → reads row._agents). */
299
+ key: string;
300
+ heading: string | ((row: Row) => string);
301
+ condition?: (row: Row) => boolean;
302
+ render: 'table' | 'list' | ((items: Row[]) => string);
303
+ columns?: EntityTableColumn[];
304
+ formatItem?: (item: Row) => string;
305
+ }
306
+ /**
307
+ * Render entity profile: heading + field-value pairs + optional enriched sections.
308
+ */
309
+ interface EntityProfileTemplate {
310
+ template: 'entity-profile';
311
+ heading: string | ((row: Row) => string);
312
+ fields: EntityProfileField[];
313
+ sections?: EntityProfileSection[];
314
+ frontmatter?: Record<string, string | number | boolean> | ((row: Row) => Record<string, string | number | boolean>);
315
+ beforeRender?: (rows: Row[]) => Row[];
316
+ }
317
+ /**
318
+ * Per-row section spec for entity-sections template.
319
+ */
320
+ interface EntitySectionPerRow {
321
+ heading: (row: Row) => string;
322
+ metadata?: Array<{
323
+ key: string;
324
+ label: string;
325
+ format?: (val: unknown) => string;
326
+ }>;
327
+ body?: (row: Row) => string;
328
+ }
329
+ /**
330
+ * Render per-row sections: heading + metadata key-value + body text per row.
331
+ */
332
+ interface EntitySectionsTemplate {
333
+ template: 'entity-sections';
334
+ heading: string;
335
+ perRow: EntitySectionPerRow;
336
+ emptyMessage?: string;
337
+ frontmatter?: Record<string, string | number | boolean>;
338
+ beforeRender?: (rows: Row[]) => Row[];
339
+ }
340
+ /** Union of all entity render template types. */
341
+ type EntityRenderTemplate = EntityTableTemplate | EntityProfileTemplate | EntitySectionsTemplate;
342
+ /** Accepted values for EntityFileSpec.render — function or template object. */
343
+ type EntityRenderSpec = ((rows: Row[]) => string) | EntityRenderTemplate;
269
344
  interface EntityFileSpec {
270
345
  /** Determines what rows are passed to {@link render}. */
271
346
  source: EntityFileSource;
272
347
  /**
273
348
  * Converts the resolved rows into the file's markdown content.
274
- * For `self` sources, `rows` is always a single-element array.
349
+ * Accepts a function `(rows) => string` or a declarative template object.
275
350
  */
276
- render: (rows: Row[]) => string;
351
+ render: EntityRenderSpec;
277
352
  /**
278
353
  * Maximum number of characters allowed in the rendered output.
279
354
  * Content exceeding this limit is truncated with a notice appended.
@@ -674,6 +749,45 @@ interface AuditEvent {
674
749
  id: string;
675
750
  timestamp: string;
676
751
  }
752
+ /**
753
+ * Context passed to write hook handlers.
754
+ */
755
+ interface WriteHookContext {
756
+ /** Table that was modified. */
757
+ table: string;
758
+ /** The operation that triggered the hook. */
759
+ op: 'insert' | 'update' | 'delete';
760
+ /** The row data (for insert: full row; for update: changed fields; for delete: { id }). */
761
+ row: Row;
762
+ /** Primary key value(s) of the affected row. */
763
+ pk: string;
764
+ /** For updates: the column names that were changed. */
765
+ changedColumns?: string[];
766
+ }
767
+ /**
768
+ * A write hook fires after insert/update/delete operations.
769
+ *
770
+ * @example
771
+ * ```ts
772
+ * db.defineWriteHook({
773
+ * table: 'agents',
774
+ * on: ['insert', 'update'],
775
+ * watchColumns: ['team_id'],
776
+ * handler: (ctx) => { denormalizeTeamFields(ctx.pk); },
777
+ * });
778
+ * ```
779
+ */
780
+ interface WriteHook {
781
+ /** Table the hook fires on. */
782
+ table: string;
783
+ /** Operations that trigger the hook. */
784
+ on: Array<'insert' | 'update' | 'delete'>;
785
+ /** Only fire on update when these columns changed. Omit = fire on any change. */
786
+ watchColumns?: string[];
787
+ /** Handler function. Runs synchronously after the DB write. */
788
+ handler: (ctx: WriteHookContext) => void;
789
+ }
790
+
677
791
  interface ReconcileOptions {
678
792
  /** Remove entity directories whose slug is no longer in the DB. Default: true. */
679
793
  removeOrphanedDirectories?: boolean;
@@ -726,10 +840,16 @@ declare class Lattice {
726
840
  private readonly _renderHandlers;
727
841
  private readonly _writebackHandlers;
728
842
  private readonly _errorHandlers;
843
+ private readonly _writeHooks;
729
844
  constructor(pathOrConfig: string | LatticeConfigInput, options?: LatticeOptions);
730
845
  define(table: string, def: TableDefinition): this;
731
846
  defineMulti(name: string, def: MultiTableDefinition): this;
732
847
  defineEntityContext(table: string, def: EntityContextDefinition): this;
848
+ /**
849
+ * Register a write hook that fires after insert/update/delete operations.
850
+ * Hooks run synchronously after the DB write and audit emit.
851
+ */
852
+ defineWriteHook(hook: WriteHook): this;
733
853
  defineWriteback(def: WritebackDefinition): this;
734
854
  init(options?: InitOptions): Promise<void>;
735
855
  close(): void;
@@ -775,6 +895,7 @@ declare class Lattice {
775
895
  */
776
896
  private _buildFilters;
777
897
  /** Returns a rejected Promise if not initialized; null if ready. */
898
+ private _fireWriteHooks;
778
899
  private _notInitError;
779
900
  /**
780
901
  * Returns a rejected Promise if any of the given column names are not present
@@ -1230,4 +1351,4 @@ declare function createReadOnlyHeader(options?: ReadOnlyHeaderOptions): string;
1230
1351
  */
1231
1352
  declare const READ_ONLY_HEADER: string;
1232
1353
 
1233
- export { type ApplyWriteResult, type AuditEvent, type BelongsToRelation, type BelongsToSource, type BuiltinTemplateName, type CleanupOptions, type CleanupResult, type CountOptions, type CustomSource, DEFAULT_ENTRY_TYPES, DEFAULT_TYPE_ALIASES, type EnrichedSource, type EnrichmentLookup, type EntityContextDefinition, type EntityContextManifestEntry, type EntityFileSource, type EntityFileSpec, type Filter, type FilterOp, type HasManyRelation, type HasManySource, type InitOptions, Lattice, type LatticeConfig, type LatticeConfigInput, type LatticeEntityDef, type LatticeEntityRenderSpec, type LatticeFieldDef, type LatticeFieldType, type LatticeManifest, type LatticeOptions, type ManyToManySource, type MarkdownTableColumn, type Migration, type MultiTableDefinition, type OrderBySpec, type ParseError, type ParseResult, type ParsedConfig, type PkLookup, type PrimaryKey, type QueryOptions, READ_ONLY_HEADER, type ReadOnlyHeaderOptions, type ReconcileOptions, type ReconcileResult, type Relation, type RenderHooks, type RenderResult, type RenderSpec, type Row, type SecurityOptions, type SelfSource, type SessionEntry, type SessionParseOptions, type SessionWriteEntry, type SessionWriteOp, type SessionWriteParseResult, type SourceQueryOptions, type StopFn, type SyncResult, type TableDefinition, type TemplateRenderSpec, type WatchOptions, type WritebackDefinition, applyWriteEntry, createReadOnlyHeader, frontmatter, generateEntryId, generateWriteEntryId, manifestPath, markdownTable, parseConfigFile, parseConfigString, parseMarkdownEntries, parseSessionMD, parseSessionWrites, readManifest, slugify, truncate, validateEntryId, writeManifest };
1354
+ export { type ApplyWriteResult, type AuditEvent, type BelongsToRelation, type BelongsToSource, type BuiltinTemplateName, type CleanupOptions, type CleanupResult, type CountOptions, type CustomSource, DEFAULT_ENTRY_TYPES, DEFAULT_TYPE_ALIASES, type EnrichedSource, type EnrichmentLookup, type EntityContextDefinition, type EntityContextManifestEntry, type EntityFileSource, type EntityFileSpec, type EntityProfileField, type EntityProfileSection, type EntityProfileTemplate, type EntityRenderSpec, type EntityRenderTemplate, type EntitySectionPerRow, type EntitySectionsTemplate, type EntityTableColumn, type EntityTableTemplate, type Filter, type FilterOp, type HasManyRelation, type HasManySource, type InitOptions, Lattice, type LatticeConfig, type LatticeConfigInput, type LatticeEntityDef, type LatticeEntityRenderSpec, type LatticeFieldDef, type LatticeFieldType, type LatticeManifest, type LatticeOptions, type ManyToManySource, type MarkdownTableColumn, type Migration, type MultiTableDefinition, type OrderBySpec, type ParseError, type ParseResult, type ParsedConfig, type PkLookup, type PrimaryKey, type QueryOptions, READ_ONLY_HEADER, type ReadOnlyHeaderOptions, type ReconcileOptions, type ReconcileResult, type Relation, type RenderHooks, type RenderResult, type RenderSpec, type Row, type SecurityOptions, type SelfSource, type SessionEntry, type SessionParseOptions, type SessionWriteEntry, type SessionWriteOp, type SessionWriteParseResult, type SourceQueryOptions, type StopFn, type SyncResult, type TableDefinition, type TemplateRenderSpec, type WatchOptions, type WriteHook, type WriteHookContext, type WritebackDefinition, applyWriteEntry, createReadOnlyHeader, frontmatter, generateEntryId, generateWriteEntryId, manifestPath, markdownTable, parseConfigFile, parseConfigString, parseMarkdownEntries, parseSessionMD, parseSessionWrites, readManifest, slugify, truncate, validateEntryId, writeManifest };
package/dist/index.d.ts CHANGED
@@ -266,14 +266,89 @@ type EntityFileSource = SelfSource | HasManySource | ManyToManySource | BelongsT
266
266
  * }
267
267
  * ```
268
268
  */
269
+ /** Column spec for entity-table template (reuses MarkdownTableColumn). */
270
+ interface EntityTableColumn {
271
+ key: string;
272
+ header: string;
273
+ format?: (val: unknown, row: Row) => string;
274
+ }
275
+ /**
276
+ * Render a heading + GFM table. Auto-prepends read-only header + frontmatter.
277
+ */
278
+ interface EntityTableTemplate {
279
+ template: 'entity-table';
280
+ heading: string;
281
+ columns: EntityTableColumn[];
282
+ emptyMessage?: string;
283
+ frontmatter?: Record<string, string | number | boolean>;
284
+ beforeRender?: (rows: Row[]) => Row[];
285
+ }
286
+ /**
287
+ * Field spec for entity-profile template.
288
+ */
289
+ interface EntityProfileField {
290
+ key: string;
291
+ label: string;
292
+ format?: (val: unknown, row: Row) => string;
293
+ }
294
+ /**
295
+ * Section spec for entity-profile template (renders enriched JSON arrays).
296
+ */
297
+ interface EntityProfileSection {
298
+ /** Key of the enriched field (e.g. 'agents' → reads row._agents). */
299
+ key: string;
300
+ heading: string | ((row: Row) => string);
301
+ condition?: (row: Row) => boolean;
302
+ render: 'table' | 'list' | ((items: Row[]) => string);
303
+ columns?: EntityTableColumn[];
304
+ formatItem?: (item: Row) => string;
305
+ }
306
+ /**
307
+ * Render entity profile: heading + field-value pairs + optional enriched sections.
308
+ */
309
+ interface EntityProfileTemplate {
310
+ template: 'entity-profile';
311
+ heading: string | ((row: Row) => string);
312
+ fields: EntityProfileField[];
313
+ sections?: EntityProfileSection[];
314
+ frontmatter?: Record<string, string | number | boolean> | ((row: Row) => Record<string, string | number | boolean>);
315
+ beforeRender?: (rows: Row[]) => Row[];
316
+ }
317
+ /**
318
+ * Per-row section spec for entity-sections template.
319
+ */
320
+ interface EntitySectionPerRow {
321
+ heading: (row: Row) => string;
322
+ metadata?: Array<{
323
+ key: string;
324
+ label: string;
325
+ format?: (val: unknown) => string;
326
+ }>;
327
+ body?: (row: Row) => string;
328
+ }
329
+ /**
330
+ * Render per-row sections: heading + metadata key-value + body text per row.
331
+ */
332
+ interface EntitySectionsTemplate {
333
+ template: 'entity-sections';
334
+ heading: string;
335
+ perRow: EntitySectionPerRow;
336
+ emptyMessage?: string;
337
+ frontmatter?: Record<string, string | number | boolean>;
338
+ beforeRender?: (rows: Row[]) => Row[];
339
+ }
340
+ /** Union of all entity render template types. */
341
+ type EntityRenderTemplate = EntityTableTemplate | EntityProfileTemplate | EntitySectionsTemplate;
342
+ /** Accepted values for EntityFileSpec.render — function or template object. */
343
+ type EntityRenderSpec = ((rows: Row[]) => string) | EntityRenderTemplate;
269
344
  interface EntityFileSpec {
270
345
  /** Determines what rows are passed to {@link render}. */
271
346
  source: EntityFileSource;
272
347
  /**
273
348
  * Converts the resolved rows into the file's markdown content.
274
- * For `self` sources, `rows` is always a single-element array.
349
+ * Accepts a function `(rows) => string` or a declarative template object.
275
350
  */
276
- render: (rows: Row[]) => string;
351
+ render: EntityRenderSpec;
277
352
  /**
278
353
  * Maximum number of characters allowed in the rendered output.
279
354
  * Content exceeding this limit is truncated with a notice appended.
@@ -674,6 +749,45 @@ interface AuditEvent {
674
749
  id: string;
675
750
  timestamp: string;
676
751
  }
752
+ /**
753
+ * Context passed to write hook handlers.
754
+ */
755
+ interface WriteHookContext {
756
+ /** Table that was modified. */
757
+ table: string;
758
+ /** The operation that triggered the hook. */
759
+ op: 'insert' | 'update' | 'delete';
760
+ /** The row data (for insert: full row; for update: changed fields; for delete: { id }). */
761
+ row: Row;
762
+ /** Primary key value(s) of the affected row. */
763
+ pk: string;
764
+ /** For updates: the column names that were changed. */
765
+ changedColumns?: string[];
766
+ }
767
+ /**
768
+ * A write hook fires after insert/update/delete operations.
769
+ *
770
+ * @example
771
+ * ```ts
772
+ * db.defineWriteHook({
773
+ * table: 'agents',
774
+ * on: ['insert', 'update'],
775
+ * watchColumns: ['team_id'],
776
+ * handler: (ctx) => { denormalizeTeamFields(ctx.pk); },
777
+ * });
778
+ * ```
779
+ */
780
+ interface WriteHook {
781
+ /** Table the hook fires on. */
782
+ table: string;
783
+ /** Operations that trigger the hook. */
784
+ on: Array<'insert' | 'update' | 'delete'>;
785
+ /** Only fire on update when these columns changed. Omit = fire on any change. */
786
+ watchColumns?: string[];
787
+ /** Handler function. Runs synchronously after the DB write. */
788
+ handler: (ctx: WriteHookContext) => void;
789
+ }
790
+
677
791
  interface ReconcileOptions {
678
792
  /** Remove entity directories whose slug is no longer in the DB. Default: true. */
679
793
  removeOrphanedDirectories?: boolean;
@@ -726,10 +840,16 @@ declare class Lattice {
726
840
  private readonly _renderHandlers;
727
841
  private readonly _writebackHandlers;
728
842
  private readonly _errorHandlers;
843
+ private readonly _writeHooks;
729
844
  constructor(pathOrConfig: string | LatticeConfigInput, options?: LatticeOptions);
730
845
  define(table: string, def: TableDefinition): this;
731
846
  defineMulti(name: string, def: MultiTableDefinition): this;
732
847
  defineEntityContext(table: string, def: EntityContextDefinition): this;
848
+ /**
849
+ * Register a write hook that fires after insert/update/delete operations.
850
+ * Hooks run synchronously after the DB write and audit emit.
851
+ */
852
+ defineWriteHook(hook: WriteHook): this;
733
853
  defineWriteback(def: WritebackDefinition): this;
734
854
  init(options?: InitOptions): Promise<void>;
735
855
  close(): void;
@@ -775,6 +895,7 @@ declare class Lattice {
775
895
  */
776
896
  private _buildFilters;
777
897
  /** Returns a rejected Promise if not initialized; null if ready. */
898
+ private _fireWriteHooks;
778
899
  private _notInitError;
779
900
  /**
780
901
  * Returns a rejected Promise if any of the given column names are not present
@@ -1230,4 +1351,4 @@ declare function createReadOnlyHeader(options?: ReadOnlyHeaderOptions): string;
1230
1351
  */
1231
1352
  declare const READ_ONLY_HEADER: string;
1232
1353
 
1233
- export { type ApplyWriteResult, type AuditEvent, type BelongsToRelation, type BelongsToSource, type BuiltinTemplateName, type CleanupOptions, type CleanupResult, type CountOptions, type CustomSource, DEFAULT_ENTRY_TYPES, DEFAULT_TYPE_ALIASES, type EnrichedSource, type EnrichmentLookup, type EntityContextDefinition, type EntityContextManifestEntry, type EntityFileSource, type EntityFileSpec, type Filter, type FilterOp, type HasManyRelation, type HasManySource, type InitOptions, Lattice, type LatticeConfig, type LatticeConfigInput, type LatticeEntityDef, type LatticeEntityRenderSpec, type LatticeFieldDef, type LatticeFieldType, type LatticeManifest, type LatticeOptions, type ManyToManySource, type MarkdownTableColumn, type Migration, type MultiTableDefinition, type OrderBySpec, type ParseError, type ParseResult, type ParsedConfig, type PkLookup, type PrimaryKey, type QueryOptions, READ_ONLY_HEADER, type ReadOnlyHeaderOptions, type ReconcileOptions, type ReconcileResult, type Relation, type RenderHooks, type RenderResult, type RenderSpec, type Row, type SecurityOptions, type SelfSource, type SessionEntry, type SessionParseOptions, type SessionWriteEntry, type SessionWriteOp, type SessionWriteParseResult, type SourceQueryOptions, type StopFn, type SyncResult, type TableDefinition, type TemplateRenderSpec, type WatchOptions, type WritebackDefinition, applyWriteEntry, createReadOnlyHeader, frontmatter, generateEntryId, generateWriteEntryId, manifestPath, markdownTable, parseConfigFile, parseConfigString, parseMarkdownEntries, parseSessionMD, parseSessionWrites, readManifest, slugify, truncate, validateEntryId, writeManifest };
1354
+ export { type ApplyWriteResult, type AuditEvent, type BelongsToRelation, type BelongsToSource, type BuiltinTemplateName, type CleanupOptions, type CleanupResult, type CountOptions, type CustomSource, DEFAULT_ENTRY_TYPES, DEFAULT_TYPE_ALIASES, type EnrichedSource, type EnrichmentLookup, type EntityContextDefinition, type EntityContextManifestEntry, type EntityFileSource, type EntityFileSpec, type EntityProfileField, type EntityProfileSection, type EntityProfileTemplate, type EntityRenderSpec, type EntityRenderTemplate, type EntitySectionPerRow, type EntitySectionsTemplate, type EntityTableColumn, type EntityTableTemplate, type Filter, type FilterOp, type HasManyRelation, type HasManySource, type InitOptions, Lattice, type LatticeConfig, type LatticeConfigInput, type LatticeEntityDef, type LatticeEntityRenderSpec, type LatticeFieldDef, type LatticeFieldType, type LatticeManifest, type LatticeOptions, type ManyToManySource, type MarkdownTableColumn, type Migration, type MultiTableDefinition, type OrderBySpec, type ParseError, type ParseResult, type ParsedConfig, type PkLookup, type PrimaryKey, type QueryOptions, READ_ONLY_HEADER, type ReadOnlyHeaderOptions, type ReconcileOptions, type ReconcileResult, type Relation, type RenderHooks, type RenderResult, type RenderSpec, type Row, type SecurityOptions, type SelfSource, type SessionEntry, type SessionParseOptions, type SessionWriteEntry, type SessionWriteOp, type SessionWriteParseResult, type SourceQueryOptions, type StopFn, type SyncResult, type TableDefinition, type TemplateRenderSpec, type WatchOptions, type WriteHook, type WriteHookContext, type WritebackDefinition, applyWriteEntry, createReadOnlyHeader, frontmatter, generateEntryId, generateWriteEntryId, manifestPath, markdownTable, parseConfigFile, parseConfigString, parseMarkdownEntries, parseSessionMD, parseSessionWrites, readManifest, slugify, truncate, validateEntryId, writeManifest };
package/dist/index.js CHANGED
@@ -443,6 +443,172 @@ function truncateContent(content, budget) {
443
443
  return content.slice(0, budget) + "\n\n*[truncated \u2014 context budget exceeded]*";
444
444
  }
445
445
 
446
+ // src/render/markdown.ts
447
+ function frontmatter(fields) {
448
+ const lines = [`generated_at: "${(/* @__PURE__ */ new Date()).toISOString()}"`];
449
+ for (const [key, val] of Object.entries(fields)) {
450
+ lines.push(typeof val === "string" ? `${key}: "${val}"` : `${key}: ${String(val)}`);
451
+ }
452
+ return `---
453
+ ${lines.join("\n")}
454
+ ---
455
+
456
+ `;
457
+ }
458
+ function markdownTable(rows, columns) {
459
+ if (rows.length === 0 || columns.length === 0) return "";
460
+ const header = "| " + columns.map((c) => c.header).join(" | ") + " |";
461
+ const separator = "| " + columns.map(() => "---").join(" | ") + " |";
462
+ const body = rows.map((row) => {
463
+ const cells = columns.map((col) => {
464
+ const raw = row[col.key];
465
+ return col.format ? col.format(raw, row) : String(raw ?? "");
466
+ });
467
+ return "| " + cells.join(" | ") + " |";
468
+ });
469
+ return [header, separator, ...body].join("\n") + "\n";
470
+ }
471
+ function slugify(name) {
472
+ return name.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/\u0131/g, "i").replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
473
+ }
474
+ function truncate(content, maxChars, notice = "\n\n*[truncated \u2014 context budget exceeded]*") {
475
+ if (content.length <= maxChars) return content;
476
+ return content.slice(0, maxChars) + notice;
477
+ }
478
+
479
+ // src/session/constants.ts
480
+ function createReadOnlyHeader(options) {
481
+ const generator = options?.generator ?? "Lattice";
482
+ const docsRef = options?.docsRef ?? "the Lattice documentation";
483
+ return `<!-- READ ONLY \u2014 generated by ${generator}. Do not edit directly.
484
+ To update data in Lattice: write entries to SESSION.md in this directory.
485
+ Format: type: write | op: create/update/delete | table: <name> | target: <id>
486
+ See ${docsRef} for the SESSION.md format spec. -->
487
+
488
+ `;
489
+ }
490
+ var READ_ONLY_HEADER = createReadOnlyHeader();
491
+
492
+ // src/render/entity-templates.ts
493
+ var DEFAULT_HEADER = createReadOnlyHeader();
494
+ function compileEntityRender(spec) {
495
+ if (typeof spec === "function") return spec;
496
+ return compileTemplate(spec);
497
+ }
498
+ function compileTemplate(tmpl) {
499
+ switch (tmpl.template) {
500
+ case "entity-table":
501
+ return compileEntityTable(tmpl);
502
+ case "entity-profile":
503
+ return compileEntityProfile(tmpl);
504
+ case "entity-sections":
505
+ return compileEntitySections(tmpl);
506
+ }
507
+ }
508
+ function compileEntityTable(tmpl) {
509
+ return (rows) => {
510
+ const data = tmpl.beforeRender ? tmpl.beforeRender(rows) : rows;
511
+ let md = DEFAULT_HEADER;
512
+ md += frontmatter(tmpl.frontmatter ?? {});
513
+ md += `# ${tmpl.heading}
514
+
515
+ `;
516
+ if (data.length === 0) {
517
+ md += tmpl.emptyMessage ?? "*No data.*\n";
518
+ } else {
519
+ md += markdownTable(data, tmpl.columns);
520
+ }
521
+ return md;
522
+ };
523
+ }
524
+ function compileEntityProfile(tmpl) {
525
+ return (rows) => {
526
+ const data = tmpl.beforeRender ? tmpl.beforeRender(rows) : rows;
527
+ const r = data[0];
528
+ if (!r) return "";
529
+ let md = DEFAULT_HEADER;
530
+ if (tmpl.frontmatter) {
531
+ const fm = typeof tmpl.frontmatter === "function" ? tmpl.frontmatter(r) : tmpl.frontmatter;
532
+ md += frontmatter(fm);
533
+ } else {
534
+ md += frontmatter({});
535
+ }
536
+ const heading = typeof tmpl.heading === "function" ? tmpl.heading(r) : tmpl.heading;
537
+ md += `# ${heading}
538
+
539
+ `;
540
+ for (const field of tmpl.fields) {
541
+ const val = r[field.key];
542
+ if (val === null || val === void 0) continue;
543
+ const formatted = field.format ? field.format(val, r) : String(val);
544
+ if (formatted) {
545
+ md += `**${field.label}:** ${formatted}
546
+ `;
547
+ }
548
+ }
549
+ if (tmpl.sections) {
550
+ for (const section of tmpl.sections) {
551
+ const rawJson = r[`_${section.key}`];
552
+ if (!rawJson) continue;
553
+ if (section.condition && !section.condition(r)) continue;
554
+ const items = JSON.parse(rawJson);
555
+ if (items.length === 0) continue;
556
+ const sectionHeading = typeof section.heading === "function" ? section.heading(r) : section.heading;
557
+ md += `
558
+ ## ${sectionHeading}
559
+
560
+ `;
561
+ if (section.render === "table" && section.columns) {
562
+ md += markdownTable(items, section.columns);
563
+ } else if (section.render === "list" && section.formatItem) {
564
+ for (const item of items) {
565
+ md += `- ${section.formatItem(item)}
566
+ `;
567
+ }
568
+ } else if (typeof section.render === "function") {
569
+ md += section.render(items);
570
+ }
571
+ }
572
+ }
573
+ return md;
574
+ };
575
+ }
576
+ function compileEntitySections(tmpl) {
577
+ return (rows) => {
578
+ const data = tmpl.beforeRender ? tmpl.beforeRender(rows) : rows;
579
+ let md = DEFAULT_HEADER;
580
+ md += frontmatter(tmpl.frontmatter ?? {});
581
+ md += `# ${tmpl.heading}
582
+
583
+ `;
584
+ if (data.length === 0) {
585
+ md += tmpl.emptyMessage ?? "*No data.*\n";
586
+ return md;
587
+ }
588
+ for (const row of data) {
589
+ md += `## ${tmpl.perRow.heading(row)}
590
+ `;
591
+ if (tmpl.perRow.metadata?.length) {
592
+ const parts = tmpl.perRow.metadata.map((m) => {
593
+ const val = row[m.key];
594
+ const formatted = m.format ? m.format(val) : String(val ?? "");
595
+ return `**${m.label}:** ${formatted}`;
596
+ }).filter(Boolean);
597
+ if (parts.length > 0) {
598
+ md += parts.join(" | ") + "\n";
599
+ }
600
+ }
601
+ if (tmpl.perRow.body) {
602
+ md += `
603
+ ${tmpl.perRow.body(row)}
604
+ `;
605
+ }
606
+ md += "\n";
607
+ }
608
+ return md;
609
+ };
610
+ }
611
+
446
612
  // src/lifecycle/cleanup.ts
447
613
  import { join as join3 } from "path";
448
614
  import { existsSync as existsSync3, readdirSync, unlinkSync, rmdirSync, statSync } from "fs";
@@ -652,7 +818,8 @@ var RenderEngine = class {
652
818
  const source = mergeDefaults ? { ...def.sourceDefaults, ...spec.source } : spec.source;
653
819
  const rows = resolveEntitySource(source, entityRow, entityPk, this._adapter);
654
820
  if (spec.omitIfEmpty && rows.length === 0) continue;
655
- const content = truncateContent(spec.render(rows), spec.budget);
821
+ const renderFn = compileEntityRender(spec.render);
822
+ const content = truncateContent(renderFn(rows), spec.budget);
656
823
  renderedFiles.set(filename, content);
657
824
  const filePath = join4(entityDir, filename);
658
825
  if (atomicWrite(filePath, content)) {
@@ -1140,6 +1307,7 @@ var Lattice = class {
1140
1307
  _renderHandlers = [];
1141
1308
  _writebackHandlers = [];
1142
1309
  _errorHandlers = [];
1310
+ _writeHooks = [];
1143
1311
  constructor(pathOrConfig, options = {}) {
1144
1312
  let dbPath;
1145
1313
  let configTables;
@@ -1199,6 +1367,14 @@ var Lattice = class {
1199
1367
  this._schema.defineEntityContext(table, def);
1200
1368
  return this;
1201
1369
  }
1370
+ /**
1371
+ * Register a write hook that fires after insert/update/delete operations.
1372
+ * Hooks run synchronously after the DB write and audit emit.
1373
+ */
1374
+ defineWriteHook(hook) {
1375
+ this._writeHooks.push(hook);
1376
+ return this;
1377
+ }
1202
1378
  defineWriteback(def) {
1203
1379
  this._writeback.define(def);
1204
1380
  return this;
@@ -1248,6 +1424,7 @@ var Lattice = class {
1248
1424
  const rawPk = rowWithPk[pkCol];
1249
1425
  const pkValue = rawPk != null ? String(rawPk) : "";
1250
1426
  this._sanitizer.emitAudit(table, "insert", pkValue);
1427
+ this._fireWriteHooks(table, "insert", rowWithPk, pkValue);
1251
1428
  return Promise.resolve(pkValue);
1252
1429
  }
1253
1430
  upsert(table, row) {
@@ -1301,6 +1478,7 @@ var Lattice = class {
1301
1478
  this._adapter.run(`UPDATE "${table}" SET ${setCols} WHERE ${clause}`, values);
1302
1479
  const auditId = typeof id === "string" ? id : JSON.stringify(id);
1303
1480
  this._sanitizer.emitAudit(table, "update", auditId);
1481
+ this._fireWriteHooks(table, "update", sanitized, auditId, Object.keys(sanitized));
1304
1482
  return Promise.resolve();
1305
1483
  }
1306
1484
  delete(table, id) {
@@ -1310,6 +1488,7 @@ var Lattice = class {
1310
1488
  this._adapter.run(`DELETE FROM "${table}" WHERE ${clause}`, params);
1311
1489
  const auditId = typeof id === "string" ? id : JSON.stringify(id);
1312
1490
  this._sanitizer.emitAudit(table, "delete", auditId);
1491
+ this._fireWriteHooks(table, "delete", { id: auditId }, auditId);
1313
1492
  return Promise.resolve();
1314
1493
  }
1315
1494
  get(table, id) {
@@ -1547,6 +1726,22 @@ var Lattice = class {
1547
1726
  return { clauses, params };
1548
1727
  }
1549
1728
  /** Returns a rejected Promise if not initialized; null if ready. */
1729
+ _fireWriteHooks(table, op, row, pk, changedColumns) {
1730
+ for (const hook of this._writeHooks) {
1731
+ if (hook.table !== table) continue;
1732
+ if (!hook.on.includes(op)) continue;
1733
+ if (op === "update" && hook.watchColumns && changedColumns) {
1734
+ if (!hook.watchColumns.some((c) => changedColumns.includes(c))) continue;
1735
+ }
1736
+ try {
1737
+ const ctx = { table, op, row, pk };
1738
+ if (changedColumns) ctx.changedColumns = changedColumns;
1739
+ hook.handler(ctx);
1740
+ } catch (err) {
1741
+ for (const h of this._errorHandlers) h(err instanceof Error ? err : new Error(String(err)));
1742
+ }
1743
+ }
1744
+ }
1550
1745
  _notInitError() {
1551
1746
  if (!this._initialized) {
1552
1747
  return Promise.reject(
@@ -1585,39 +1780,6 @@ var Lattice = class {
1585
1780
  }
1586
1781
  };
1587
1782
 
1588
- // src/render/markdown.ts
1589
- function frontmatter(fields) {
1590
- const lines = [`generated_at: "${(/* @__PURE__ */ new Date()).toISOString()}"`];
1591
- for (const [key, val] of Object.entries(fields)) {
1592
- lines.push(typeof val === "string" ? `${key}: "${val}"` : `${key}: ${String(val)}`);
1593
- }
1594
- return `---
1595
- ${lines.join("\n")}
1596
- ---
1597
-
1598
- `;
1599
- }
1600
- function markdownTable(rows, columns) {
1601
- if (rows.length === 0 || columns.length === 0) return "";
1602
- const header = "| " + columns.map((c) => c.header).join(" | ") + " |";
1603
- const separator = "| " + columns.map(() => "---").join(" | ") + " |";
1604
- const body = rows.map((row) => {
1605
- const cells = columns.map((col) => {
1606
- const raw = row[col.key];
1607
- return col.format ? col.format(raw, row) : String(raw ?? "");
1608
- });
1609
- return "| " + cells.join(" | ") + " |";
1610
- });
1611
- return [header, separator, ...body].join("\n") + "\n";
1612
- }
1613
- function slugify(name) {
1614
- return name.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/\u0131/g, "i").replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
1615
- }
1616
- function truncate(content, maxChars, notice = "\n\n*[truncated \u2014 context budget exceeded]*") {
1617
- if (content.length <= maxChars) return content;
1618
- return content.slice(0, maxChars) + notice;
1619
- }
1620
-
1621
1783
  // src/session/parser.ts
1622
1784
  import { createHash as createHash2 } from "crypto";
1623
1785
  function generateWriteEntryId(timestamp, agentName, op, table, target) {
@@ -2002,19 +2164,6 @@ function applyWriteEntry(db, entry) {
2002
2164
  return { ok: false, reason: `DB error: ${message}` };
2003
2165
  }
2004
2166
  }
2005
-
2006
- // src/session/constants.ts
2007
- function createReadOnlyHeader(options) {
2008
- const generator = options?.generator ?? "Lattice";
2009
- const docsRef = options?.docsRef ?? "the Lattice documentation";
2010
- return `<!-- READ ONLY \u2014 generated by ${generator}. Do not edit directly.
2011
- To update data in Lattice: write entries to SESSION.md in this directory.
2012
- Format: type: write | op: create/update/delete | table: <name> | target: <id>
2013
- See ${docsRef} for the SESSION.md format spec. -->
2014
-
2015
- `;
2016
- }
2017
- var READ_ONLY_HEADER = createReadOnlyHeader();
2018
2167
  export {
2019
2168
  DEFAULT_ENTRY_TYPES,
2020
2169
  DEFAULT_TYPE_ALIASES,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latticesql",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Persistent structured memory for AI agent systems — SQLite ↔ LLM context bridge",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",