latticesql 0.9.0 → 0.11.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/README.md CHANGED
@@ -456,6 +456,44 @@ context/
456
456
 
457
457
  The `softDelete: true` shorthand is equivalent to `filters: [{ col: 'deleted_at', op: 'isNull' }]`.
458
458
 
459
+ #### Junction column projection (v0.8+)
460
+
461
+ `manyToMany` sources can include columns from the junction table in results:
462
+
463
+ ```typescript
464
+ {
465
+ type: 'manyToMany',
466
+ junctionTable: 'agent_projects',
467
+ localKey: 'agent_id',
468
+ remoteKey: 'project_id',
469
+ remoteTable: 'projects',
470
+ junctionColumns: [
471
+ 'source', // included as-is
472
+ { col: 'role', as: 'agent_role' }, // aliased
473
+ ],
474
+ }
475
+ // Each result row includes both remote table columns AND junction columns
476
+ ```
477
+
478
+ #### Multi-column ORDER BY (v0.8+)
479
+
480
+ `orderBy` accepts an array for multi-column sorting:
481
+
482
+ ```typescript
483
+ {
484
+ type: 'hasMany',
485
+ table: 'events',
486
+ foreignKey: 'project_id',
487
+ orderBy: [
488
+ { col: 'severity' }, // ASC by default
489
+ { col: 'timestamp', dir: 'desc' }, // DESC
490
+ ],
491
+ limit: 20,
492
+ }
493
+ ```
494
+
495
+ The string form (`orderBy: 'name'`) still works for single-column sorting.
496
+
459
497
  #### sourceDefaults (v0.6+)
460
498
 
461
499
  Set default query options for all relationship sources in an entity context:
@@ -504,10 +542,100 @@ Starts with the entity's own row and attaches related data as JSON string fields
504
542
  }
505
543
  ```
506
544
 
545
+ #### Entity render templates (v0.9+)
546
+
547
+ `EntityFileSpec.render` accepts declarative template objects in addition to functions. Three built-in templates:
548
+
549
+ **entity-table** — heading + GFM table:
550
+ ```typescript
551
+ render: {
552
+ template: 'entity-table',
553
+ heading: 'Skills',
554
+ columns: [
555
+ { key: 'name', header: 'Name' },
556
+ { key: 'level', header: 'Level', format: (v) => String(v || '—') },
557
+ ],
558
+ emptyMessage: '*No skills assigned.*',
559
+ beforeRender: (rows) => rows.filter(r => r.active), // optional
560
+ }
561
+ ```
562
+
563
+ **entity-profile** — heading + field-value pairs + enriched JSON sections:
564
+ ```typescript
565
+ render: {
566
+ template: 'entity-profile',
567
+ heading: (r) => r.name as string,
568
+ fields: [
569
+ { key: 'status', label: 'Status' },
570
+ { key: 'role', label: 'Role' },
571
+ ],
572
+ sections: [
573
+ { key: 'skills', heading: 'Skills', render: 'table',
574
+ columns: [{ key: 'name', header: 'Name' }] },
575
+ { key: 'projects', heading: 'Projects', render: 'list',
576
+ formatItem: (p) => `${p.name} (${p.status})` },
577
+ ],
578
+ frontmatter: (r) => ({ agent: r.name as string }),
579
+ }
580
+ ```
581
+
582
+ **entity-sections** — per-row sections with metadata + body:
583
+ ```typescript
584
+ render: {
585
+ template: 'entity-sections',
586
+ heading: 'Rules',
587
+ perRow: {
588
+ heading: (r) => r.title as string,
589
+ metadata: [
590
+ { key: 'scope', label: 'Scope' },
591
+ { key: 'category', label: 'Category' },
592
+ ],
593
+ body: (r) => r.rule_text as string,
594
+ },
595
+ emptyMessage: '*No rules defined.*',
596
+ }
597
+ ```
598
+
599
+ All templates auto-prepend a read-only header and YAML frontmatter. Functions still work — the union type is backward compatible.
600
+
507
601
  See [docs/entity-context.md](./docs/entity-context.md) for the complete guide.
508
602
 
509
603
  ---
510
604
 
605
+ ### `defineWriteHook()` (v0.10+)
606
+
607
+ ```typescript
608
+ db.defineWriteHook(hook: WriteHook): this
609
+ ```
610
+
611
+ Register a post-write lifecycle hook that fires after `insert()`, `update()`, or `delete()` operations. Useful for denormalization, fan-out, computed fields, and audit logging.
612
+
613
+ ```typescript
614
+ db.defineWriteHook({
615
+ table: 'agents',
616
+ on: ['insert', 'update'],
617
+ watchColumns: ['team_id', 'division'], // only fire when these change
618
+ handler: (ctx) => {
619
+ // ctx.table, ctx.op, ctx.row, ctx.pk, ctx.changedColumns
620
+ console.log(`${ctx.op} on ${ctx.table}: ${ctx.pk}`);
621
+ denormalizeRelatedData(ctx.pk, ctx.row);
622
+ },
623
+ });
624
+ ```
625
+
626
+ **Options:**
627
+
628
+ | Field | Type | Description |
629
+ |---|---|---|
630
+ | `table` | `string` | Table to watch |
631
+ | `on` | `Array<'insert' \| 'update' \| 'delete'>` | Operations that trigger the hook |
632
+ | `watchColumns` | `string[]` (optional) | Only fire on update when these columns changed |
633
+ | `handler` | `(ctx: WriteHookContext) => void` | Synchronous handler |
634
+
635
+ Hook errors are caught and routed to error handlers — they never crash the caller. Multiple hooks per table are supported.
636
+
637
+ ---
638
+
511
639
  ### `defineWriteback()`
512
640
 
513
641
  ```typescript
package/dist/cli.js CHANGED
@@ -1389,6 +1389,7 @@ var Lattice = class {
1389
1389
  _renderHandlers = [];
1390
1390
  _writebackHandlers = [];
1391
1391
  _errorHandlers = [];
1392
+ _writeHooks = [];
1392
1393
  constructor(pathOrConfig, options = {}) {
1393
1394
  let dbPath;
1394
1395
  let configTables;
@@ -1448,6 +1449,14 @@ var Lattice = class {
1448
1449
  this._schema.defineEntityContext(table, def);
1449
1450
  return this;
1450
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
+ }
1451
1460
  defineWriteback(def) {
1452
1461
  this._writeback.define(def);
1453
1462
  return this;
@@ -1497,6 +1506,7 @@ var Lattice = class {
1497
1506
  const rawPk = rowWithPk[pkCol];
1498
1507
  const pkValue = rawPk != null ? String(rawPk) : "";
1499
1508
  this._sanitizer.emitAudit(table, "insert", pkValue);
1509
+ this._fireWriteHooks(table, "insert", rowWithPk, pkValue);
1500
1510
  return Promise.resolve(pkValue);
1501
1511
  }
1502
1512
  upsert(table, row) {
@@ -1550,6 +1560,7 @@ var Lattice = class {
1550
1560
  this._adapter.run(`UPDATE "${table}" SET ${setCols} WHERE ${clause}`, values);
1551
1561
  const auditId = typeof id === "string" ? id : JSON.stringify(id);
1552
1562
  this._sanitizer.emitAudit(table, "update", auditId);
1563
+ this._fireWriteHooks(table, "update", sanitized, auditId, Object.keys(sanitized));
1553
1564
  return Promise.resolve();
1554
1565
  }
1555
1566
  delete(table, id) {
@@ -1559,6 +1570,7 @@ var Lattice = class {
1559
1570
  this._adapter.run(`DELETE FROM "${table}" WHERE ${clause}`, params);
1560
1571
  const auditId = typeof id === "string" ? id : JSON.stringify(id);
1561
1572
  this._sanitizer.emitAudit(table, "delete", auditId);
1573
+ this._fireWriteHooks(table, "delete", { id: auditId }, auditId);
1562
1574
  return Promise.resolve();
1563
1575
  }
1564
1576
  get(table, id) {
@@ -1569,6 +1581,160 @@ var Lattice = class {
1569
1581
  this._adapter.get(`SELECT * FROM "${table}" WHERE ${clause}`, params) ?? null
1570
1582
  );
1571
1583
  }
1584
+ // -------------------------------------------------------------------------
1585
+ // Generic CRUD — works on ANY table (v0.11+)
1586
+ // -------------------------------------------------------------------------
1587
+ /**
1588
+ * Upsert a record by natural key. If a non-deleted record with the given
1589
+ * natural key exists, update it. Otherwise insert with a new UUID.
1590
+ * Auto-handles `org_id`, `updated_at`, `deleted_at`, `source_file`, `source_hash`.
1591
+ */
1592
+ upsertByNaturalKey(table, naturalKeyCol, naturalKeyVal, data, opts) {
1593
+ const notInit = this._notInitError();
1594
+ if (notInit) return notInit;
1595
+ const cols = this._ensureColumnCache(table);
1596
+ const sanitized = this._filterToSchemaColumns(table, this._sanitizer.sanitizeRow(data));
1597
+ const withConventions = { ...sanitized };
1598
+ if (cols.has("updated_at")) withConventions.updated_at = (/* @__PURE__ */ new Date()).toISOString();
1599
+ if (opts?.sourceFile && cols.has("source_file")) withConventions.source_file = opts.sourceFile;
1600
+ if (opts?.sourceHash && cols.has("source_hash")) withConventions.source_hash = opts.sourceHash;
1601
+ const existing = this._adapter.get(
1602
+ `SELECT id FROM "${table}" WHERE "${naturalKeyCol}" = ? AND (deleted_at IS NULL OR deleted_at = '')`,
1603
+ [naturalKeyVal]
1604
+ );
1605
+ if (existing) {
1606
+ const entries = Object.entries(withConventions).filter(([k]) => k !== "id");
1607
+ if (entries.length === 0) return Promise.resolve(existing.id);
1608
+ const setCols = entries.map(([k]) => `"${k}" = ?`).join(", ");
1609
+ this._adapter.run(`UPDATE "${table}" SET ${setCols} WHERE id = ?`, [...entries.map(([, v]) => v), existing.id]);
1610
+ this._fireWriteHooks(table, "update", withConventions, existing.id, Object.keys(sanitized));
1611
+ return Promise.resolve(existing.id);
1612
+ }
1613
+ const id = sanitized.id ?? uuidv4();
1614
+ const insertData = { ...withConventions, id, [naturalKeyCol]: naturalKeyVal };
1615
+ if (opts?.orgId && cols.has("org_id") && !insertData.org_id) insertData.org_id = opts.orgId;
1616
+ if (cols.has("deleted_at")) insertData.deleted_at = null;
1617
+ if (cols.has("created_at") && !insertData.created_at) insertData.created_at = (/* @__PURE__ */ new Date()).toISOString();
1618
+ const filtered = this._filterToSchemaColumns(table, insertData);
1619
+ const colNames = Object.keys(filtered).map((c) => `"${c}"`).join(", ");
1620
+ const placeholders = Object.keys(filtered).map(() => "?").join(", ");
1621
+ this._adapter.run(`INSERT INTO "${table}" (${colNames}) VALUES (${placeholders})`, Object.values(filtered));
1622
+ this._fireWriteHooks(table, "insert", filtered, id);
1623
+ return Promise.resolve(id);
1624
+ }
1625
+ /**
1626
+ * Sparse update by natural key — only writes non-null fields on an existing record.
1627
+ * Returns true if a row was found and updated.
1628
+ */
1629
+ enrichByNaturalKey(table, naturalKeyCol, naturalKeyVal, data) {
1630
+ const notInit = this._notInitError();
1631
+ if (notInit) return notInit;
1632
+ const existing = this._adapter.get(
1633
+ `SELECT id FROM "${table}" WHERE "${naturalKeyCol}" = ? AND (deleted_at IS NULL OR deleted_at = '')`,
1634
+ [naturalKeyVal]
1635
+ );
1636
+ if (!existing) return Promise.resolve(false);
1637
+ const sanitized = this._filterToSchemaColumns(table, this._sanitizer.sanitizeRow(data));
1638
+ const entries = Object.entries(sanitized).filter(([k, v]) => v !== null && v !== void 0 && k !== "id");
1639
+ if (entries.length === 0) return Promise.resolve(true);
1640
+ const cols = this._ensureColumnCache(table);
1641
+ const withTs = [...entries];
1642
+ if (cols.has("updated_at")) withTs.push(["updated_at", (/* @__PURE__ */ new Date()).toISOString()]);
1643
+ const setCols = withTs.map(([k]) => `"${k}" = ?`).join(", ");
1644
+ this._adapter.run(`UPDATE "${table}" SET ${setCols} WHERE id = ?`, [...withTs.map(([, v]) => v), existing.id]);
1645
+ this._fireWriteHooks(table, "update", Object.fromEntries(entries), existing.id, entries.map(([k]) => k));
1646
+ return Promise.resolve(true);
1647
+ }
1648
+ /**
1649
+ * Soft-delete records from a source file whose natural key is NOT in the given set.
1650
+ * Returns count of rows soft-deleted.
1651
+ */
1652
+ softDeleteMissing(table, naturalKeyCol, sourceFile, currentKeys) {
1653
+ const notInit = this._notInitError();
1654
+ if (notInit) return notInit;
1655
+ if (currentKeys.length === 0) return Promise.resolve(0);
1656
+ const placeholders = currentKeys.map(() => "?").join(", ");
1657
+ const countRow = this._adapter.get(
1658
+ `SELECT COUNT(*) as cnt FROM "${table}"
1659
+ WHERE source_file = ? AND "${naturalKeyCol}" NOT IN (${placeholders})
1660
+ AND (deleted_at IS NULL OR deleted_at = '')`,
1661
+ [sourceFile, ...currentKeys]
1662
+ );
1663
+ const count = countRow?.cnt ?? 0;
1664
+ if (count > 0) {
1665
+ this._adapter.run(
1666
+ `UPDATE "${table}" SET deleted_at = datetime('now'), updated_at = datetime('now')
1667
+ WHERE source_file = ? AND "${naturalKeyCol}" NOT IN (${placeholders})
1668
+ AND (deleted_at IS NULL OR deleted_at = '')`,
1669
+ [sourceFile, ...currentKeys]
1670
+ );
1671
+ }
1672
+ return Promise.resolve(count);
1673
+ }
1674
+ /**
1675
+ * Get all non-deleted rows from a table, ordered by the given column.
1676
+ * Works on any table, not just defined ones.
1677
+ */
1678
+ getActive(table, orderBy = "name") {
1679
+ const notInit = this._notInitError();
1680
+ if (notInit) return notInit;
1681
+ const cols = this._ensureColumnCache(table);
1682
+ const hasDeletedAt = cols.has("deleted_at");
1683
+ const where = hasDeletedAt ? ` WHERE deleted_at IS NULL` : "";
1684
+ const order = cols.has(orderBy) ? ` ORDER BY "${orderBy}"` : "";
1685
+ return Promise.resolve(this._adapter.all(`SELECT * FROM "${table}"${where}${order}`));
1686
+ }
1687
+ /**
1688
+ * Count non-deleted rows in a table.
1689
+ */
1690
+ countActive(table) {
1691
+ const notInit = this._notInitError();
1692
+ if (notInit) return notInit;
1693
+ const cols = this._ensureColumnCache(table);
1694
+ const hasDeletedAt = cols.has("deleted_at");
1695
+ const where = hasDeletedAt ? ` WHERE deleted_at IS NULL` : "";
1696
+ const row = this._adapter.get(`SELECT COUNT(*) as cnt FROM "${table}"${where}`);
1697
+ return Promise.resolve(row.cnt);
1698
+ }
1699
+ /**
1700
+ * Lookup a single row by natural key (non-deleted).
1701
+ */
1702
+ getByNaturalKey(table, naturalKeyCol, naturalKeyVal) {
1703
+ const notInit = this._notInitError();
1704
+ if (notInit) return notInit;
1705
+ return Promise.resolve(
1706
+ this._adapter.get(
1707
+ `SELECT * FROM "${table}" WHERE "${naturalKeyCol}" = ? AND (deleted_at IS NULL OR deleted_at = '')`,
1708
+ [naturalKeyVal]
1709
+ ) ?? null
1710
+ );
1711
+ }
1712
+ /**
1713
+ * Insert a row into a junction table. Uses INSERT OR IGNORE by default
1714
+ * (idempotent). Pass `{ upsert: true }` for INSERT OR REPLACE.
1715
+ */
1716
+ link(junctionTable, data, opts) {
1717
+ const notInit = this._notInitError();
1718
+ if (notInit) return notInit;
1719
+ const filtered = this._filterToSchemaColumns(junctionTable, data);
1720
+ const colNames = Object.keys(filtered).map((c) => `"${c}"`).join(", ");
1721
+ const placeholders = Object.keys(filtered).map(() => "?").join(", ");
1722
+ const verb = opts?.upsert ? "INSERT OR REPLACE" : "INSERT OR IGNORE";
1723
+ this._adapter.run(`${verb} INTO "${junctionTable}" (${colNames}) VALUES (${placeholders})`, Object.values(filtered));
1724
+ return Promise.resolve();
1725
+ }
1726
+ /**
1727
+ * Delete rows from a junction table matching all given conditions.
1728
+ */
1729
+ unlink(junctionTable, conditions) {
1730
+ const notInit = this._notInitError();
1731
+ if (notInit) return notInit;
1732
+ const entries = Object.entries(conditions);
1733
+ if (entries.length === 0) return Promise.resolve();
1734
+ const where = entries.map(([k]) => `"${k}" = ?`).join(" AND ");
1735
+ this._adapter.run(`DELETE FROM "${junctionTable}" WHERE ${where}`, entries.map(([, v]) => v));
1736
+ return Promise.resolve();
1737
+ }
1572
1738
  query(table, opts = {}) {
1573
1739
  const notInit = this._notInitError();
1574
1740
  if (notInit) return notInit;
@@ -1717,9 +1883,19 @@ var Lattice = class {
1717
1883
  * objects are interpolated into SQL, so stripping unknown keys eliminates
1718
1884
  * any theoretical injection vector from crafted object keys.
1719
1885
  */
1886
+ /** Lazily populate column cache for tables not registered via define(). */
1887
+ _ensureColumnCache(table) {
1888
+ let cols = this._columnCache.get(table);
1889
+ if (!cols) {
1890
+ const rows = this._adapter.all(`PRAGMA table_info("${table}")`);
1891
+ cols = new Set(rows.map((r) => r.name));
1892
+ if (cols.size > 0) this._columnCache.set(table, cols);
1893
+ }
1894
+ return cols;
1895
+ }
1720
1896
  _filterToSchemaColumns(table, row) {
1721
- const cols = this._columnCache.get(table);
1722
- if (!cols) return row;
1897
+ const cols = this._ensureColumnCache(table);
1898
+ if (!cols || cols.size === 0) return row;
1723
1899
  const keys = Object.keys(row);
1724
1900
  if (keys.every((k) => cols.has(k))) return row;
1725
1901
  return Object.fromEntries(keys.filter((k) => cols.has(k)).map((k) => [k, row[k]]));
@@ -1796,6 +1972,22 @@ var Lattice = class {
1796
1972
  return { clauses, params };
1797
1973
  }
1798
1974
  /** Returns a rejected Promise if not initialized; null if ready. */
1975
+ _fireWriteHooks(table, op, row, pk, changedColumns) {
1976
+ for (const hook of this._writeHooks) {
1977
+ if (hook.table !== table) continue;
1978
+ if (!hook.on.includes(op)) continue;
1979
+ if (op === "update" && hook.watchColumns && changedColumns) {
1980
+ if (!hook.watchColumns.some((c) => changedColumns.includes(c))) continue;
1981
+ }
1982
+ try {
1983
+ const ctx = { table, op, row, pk };
1984
+ if (changedColumns) ctx.changedColumns = changedColumns;
1985
+ hook.handler(ctx);
1986
+ } catch (err) {
1987
+ for (const h of this._errorHandlers) h(err instanceof Error ? err : new Error(String(err)));
1988
+ }
1989
+ }
1990
+ }
1799
1991
  _notInitError() {
1800
1992
  if (!this._initialized) {
1801
1993
  return Promise.reject(
package/dist/index.cjs CHANGED
@@ -1363,6 +1363,7 @@ var Lattice = class {
1363
1363
  _renderHandlers = [];
1364
1364
  _writebackHandlers = [];
1365
1365
  _errorHandlers = [];
1366
+ _writeHooks = [];
1366
1367
  constructor(pathOrConfig, options = {}) {
1367
1368
  let dbPath;
1368
1369
  let configTables;
@@ -1422,6 +1423,14 @@ var Lattice = class {
1422
1423
  this._schema.defineEntityContext(table, def);
1423
1424
  return this;
1424
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
+ }
1425
1434
  defineWriteback(def) {
1426
1435
  this._writeback.define(def);
1427
1436
  return this;
@@ -1471,6 +1480,7 @@ var Lattice = class {
1471
1480
  const rawPk = rowWithPk[pkCol];
1472
1481
  const pkValue = rawPk != null ? String(rawPk) : "";
1473
1482
  this._sanitizer.emitAudit(table, "insert", pkValue);
1483
+ this._fireWriteHooks(table, "insert", rowWithPk, pkValue);
1474
1484
  return Promise.resolve(pkValue);
1475
1485
  }
1476
1486
  upsert(table, row) {
@@ -1524,6 +1534,7 @@ var Lattice = class {
1524
1534
  this._adapter.run(`UPDATE "${table}" SET ${setCols} WHERE ${clause}`, values);
1525
1535
  const auditId = typeof id === "string" ? id : JSON.stringify(id);
1526
1536
  this._sanitizer.emitAudit(table, "update", auditId);
1537
+ this._fireWriteHooks(table, "update", sanitized, auditId, Object.keys(sanitized));
1527
1538
  return Promise.resolve();
1528
1539
  }
1529
1540
  delete(table, id) {
@@ -1533,6 +1544,7 @@ var Lattice = class {
1533
1544
  this._adapter.run(`DELETE FROM "${table}" WHERE ${clause}`, params);
1534
1545
  const auditId = typeof id === "string" ? id : JSON.stringify(id);
1535
1546
  this._sanitizer.emitAudit(table, "delete", auditId);
1547
+ this._fireWriteHooks(table, "delete", { id: auditId }, auditId);
1536
1548
  return Promise.resolve();
1537
1549
  }
1538
1550
  get(table, id) {
@@ -1543,6 +1555,160 @@ var Lattice = class {
1543
1555
  this._adapter.get(`SELECT * FROM "${table}" WHERE ${clause}`, params) ?? null
1544
1556
  );
1545
1557
  }
1558
+ // -------------------------------------------------------------------------
1559
+ // Generic CRUD — works on ANY table (v0.11+)
1560
+ // -------------------------------------------------------------------------
1561
+ /**
1562
+ * Upsert a record by natural key. If a non-deleted record with the given
1563
+ * natural key exists, update it. Otherwise insert with a new UUID.
1564
+ * Auto-handles `org_id`, `updated_at`, `deleted_at`, `source_file`, `source_hash`.
1565
+ */
1566
+ upsertByNaturalKey(table, naturalKeyCol, naturalKeyVal, data, opts) {
1567
+ const notInit = this._notInitError();
1568
+ if (notInit) return notInit;
1569
+ const cols = this._ensureColumnCache(table);
1570
+ const sanitized = this._filterToSchemaColumns(table, this._sanitizer.sanitizeRow(data));
1571
+ const withConventions = { ...sanitized };
1572
+ if (cols.has("updated_at")) withConventions.updated_at = (/* @__PURE__ */ new Date()).toISOString();
1573
+ if (opts?.sourceFile && cols.has("source_file")) withConventions.source_file = opts.sourceFile;
1574
+ if (opts?.sourceHash && cols.has("source_hash")) withConventions.source_hash = opts.sourceHash;
1575
+ const existing = this._adapter.get(
1576
+ `SELECT id FROM "${table}" WHERE "${naturalKeyCol}" = ? AND (deleted_at IS NULL OR deleted_at = '')`,
1577
+ [naturalKeyVal]
1578
+ );
1579
+ if (existing) {
1580
+ const entries = Object.entries(withConventions).filter(([k]) => k !== "id");
1581
+ if (entries.length === 0) return Promise.resolve(existing.id);
1582
+ const setCols = entries.map(([k]) => `"${k}" = ?`).join(", ");
1583
+ this._adapter.run(`UPDATE "${table}" SET ${setCols} WHERE id = ?`, [...entries.map(([, v]) => v), existing.id]);
1584
+ this._fireWriteHooks(table, "update", withConventions, existing.id, Object.keys(sanitized));
1585
+ return Promise.resolve(existing.id);
1586
+ }
1587
+ const id = sanitized.id ?? (0, import_uuid.v4)();
1588
+ const insertData = { ...withConventions, id, [naturalKeyCol]: naturalKeyVal };
1589
+ if (opts?.orgId && cols.has("org_id") && !insertData.org_id) insertData.org_id = opts.orgId;
1590
+ if (cols.has("deleted_at")) insertData.deleted_at = null;
1591
+ if (cols.has("created_at") && !insertData.created_at) insertData.created_at = (/* @__PURE__ */ new Date()).toISOString();
1592
+ const filtered = this._filterToSchemaColumns(table, insertData);
1593
+ const colNames = Object.keys(filtered).map((c) => `"${c}"`).join(", ");
1594
+ const placeholders = Object.keys(filtered).map(() => "?").join(", ");
1595
+ this._adapter.run(`INSERT INTO "${table}" (${colNames}) VALUES (${placeholders})`, Object.values(filtered));
1596
+ this._fireWriteHooks(table, "insert", filtered, id);
1597
+ return Promise.resolve(id);
1598
+ }
1599
+ /**
1600
+ * Sparse update by natural key — only writes non-null fields on an existing record.
1601
+ * Returns true if a row was found and updated.
1602
+ */
1603
+ enrichByNaturalKey(table, naturalKeyCol, naturalKeyVal, data) {
1604
+ const notInit = this._notInitError();
1605
+ if (notInit) return notInit;
1606
+ const existing = this._adapter.get(
1607
+ `SELECT id FROM "${table}" WHERE "${naturalKeyCol}" = ? AND (deleted_at IS NULL OR deleted_at = '')`,
1608
+ [naturalKeyVal]
1609
+ );
1610
+ if (!existing) return Promise.resolve(false);
1611
+ const sanitized = this._filterToSchemaColumns(table, this._sanitizer.sanitizeRow(data));
1612
+ const entries = Object.entries(sanitized).filter(([k, v]) => v !== null && v !== void 0 && k !== "id");
1613
+ if (entries.length === 0) return Promise.resolve(true);
1614
+ const cols = this._ensureColumnCache(table);
1615
+ const withTs = [...entries];
1616
+ if (cols.has("updated_at")) withTs.push(["updated_at", (/* @__PURE__ */ new Date()).toISOString()]);
1617
+ const setCols = withTs.map(([k]) => `"${k}" = ?`).join(", ");
1618
+ this._adapter.run(`UPDATE "${table}" SET ${setCols} WHERE id = ?`, [...withTs.map(([, v]) => v), existing.id]);
1619
+ this._fireWriteHooks(table, "update", Object.fromEntries(entries), existing.id, entries.map(([k]) => k));
1620
+ return Promise.resolve(true);
1621
+ }
1622
+ /**
1623
+ * Soft-delete records from a source file whose natural key is NOT in the given set.
1624
+ * Returns count of rows soft-deleted.
1625
+ */
1626
+ softDeleteMissing(table, naturalKeyCol, sourceFile, currentKeys) {
1627
+ const notInit = this._notInitError();
1628
+ if (notInit) return notInit;
1629
+ if (currentKeys.length === 0) return Promise.resolve(0);
1630
+ const placeholders = currentKeys.map(() => "?").join(", ");
1631
+ const countRow = this._adapter.get(
1632
+ `SELECT COUNT(*) as cnt FROM "${table}"
1633
+ WHERE source_file = ? AND "${naturalKeyCol}" NOT IN (${placeholders})
1634
+ AND (deleted_at IS NULL OR deleted_at = '')`,
1635
+ [sourceFile, ...currentKeys]
1636
+ );
1637
+ const count = countRow?.cnt ?? 0;
1638
+ if (count > 0) {
1639
+ this._adapter.run(
1640
+ `UPDATE "${table}" SET deleted_at = datetime('now'), updated_at = datetime('now')
1641
+ WHERE source_file = ? AND "${naturalKeyCol}" NOT IN (${placeholders})
1642
+ AND (deleted_at IS NULL OR deleted_at = '')`,
1643
+ [sourceFile, ...currentKeys]
1644
+ );
1645
+ }
1646
+ return Promise.resolve(count);
1647
+ }
1648
+ /**
1649
+ * Get all non-deleted rows from a table, ordered by the given column.
1650
+ * Works on any table, not just defined ones.
1651
+ */
1652
+ getActive(table, orderBy = "name") {
1653
+ const notInit = this._notInitError();
1654
+ if (notInit) return notInit;
1655
+ const cols = this._ensureColumnCache(table);
1656
+ const hasDeletedAt = cols.has("deleted_at");
1657
+ const where = hasDeletedAt ? ` WHERE deleted_at IS NULL` : "";
1658
+ const order = cols.has(orderBy) ? ` ORDER BY "${orderBy}"` : "";
1659
+ return Promise.resolve(this._adapter.all(`SELECT * FROM "${table}"${where}${order}`));
1660
+ }
1661
+ /**
1662
+ * Count non-deleted rows in a table.
1663
+ */
1664
+ countActive(table) {
1665
+ const notInit = this._notInitError();
1666
+ if (notInit) return notInit;
1667
+ const cols = this._ensureColumnCache(table);
1668
+ const hasDeletedAt = cols.has("deleted_at");
1669
+ const where = hasDeletedAt ? ` WHERE deleted_at IS NULL` : "";
1670
+ const row = this._adapter.get(`SELECT COUNT(*) as cnt FROM "${table}"${where}`);
1671
+ return Promise.resolve(row.cnt);
1672
+ }
1673
+ /**
1674
+ * Lookup a single row by natural key (non-deleted).
1675
+ */
1676
+ getByNaturalKey(table, naturalKeyCol, naturalKeyVal) {
1677
+ const notInit = this._notInitError();
1678
+ if (notInit) return notInit;
1679
+ return Promise.resolve(
1680
+ this._adapter.get(
1681
+ `SELECT * FROM "${table}" WHERE "${naturalKeyCol}" = ? AND (deleted_at IS NULL OR deleted_at = '')`,
1682
+ [naturalKeyVal]
1683
+ ) ?? null
1684
+ );
1685
+ }
1686
+ /**
1687
+ * Insert a row into a junction table. Uses INSERT OR IGNORE by default
1688
+ * (idempotent). Pass `{ upsert: true }` for INSERT OR REPLACE.
1689
+ */
1690
+ link(junctionTable, data, opts) {
1691
+ const notInit = this._notInitError();
1692
+ if (notInit) return notInit;
1693
+ const filtered = this._filterToSchemaColumns(junctionTable, data);
1694
+ const colNames = Object.keys(filtered).map((c) => `"${c}"`).join(", ");
1695
+ const placeholders = Object.keys(filtered).map(() => "?").join(", ");
1696
+ const verb = opts?.upsert ? "INSERT OR REPLACE" : "INSERT OR IGNORE";
1697
+ this._adapter.run(`${verb} INTO "${junctionTable}" (${colNames}) VALUES (${placeholders})`, Object.values(filtered));
1698
+ return Promise.resolve();
1699
+ }
1700
+ /**
1701
+ * Delete rows from a junction table matching all given conditions.
1702
+ */
1703
+ unlink(junctionTable, conditions) {
1704
+ const notInit = this._notInitError();
1705
+ if (notInit) return notInit;
1706
+ const entries = Object.entries(conditions);
1707
+ if (entries.length === 0) return Promise.resolve();
1708
+ const where = entries.map(([k]) => `"${k}" = ?`).join(" AND ");
1709
+ this._adapter.run(`DELETE FROM "${junctionTable}" WHERE ${where}`, entries.map(([, v]) => v));
1710
+ return Promise.resolve();
1711
+ }
1546
1712
  query(table, opts = {}) {
1547
1713
  const notInit = this._notInitError();
1548
1714
  if (notInit) return notInit;
@@ -1691,9 +1857,19 @@ var Lattice = class {
1691
1857
  * objects are interpolated into SQL, so stripping unknown keys eliminates
1692
1858
  * any theoretical injection vector from crafted object keys.
1693
1859
  */
1860
+ /** Lazily populate column cache for tables not registered via define(). */
1861
+ _ensureColumnCache(table) {
1862
+ let cols = this._columnCache.get(table);
1863
+ if (!cols) {
1864
+ const rows = this._adapter.all(`PRAGMA table_info("${table}")`);
1865
+ cols = new Set(rows.map((r) => r.name));
1866
+ if (cols.size > 0) this._columnCache.set(table, cols);
1867
+ }
1868
+ return cols;
1869
+ }
1694
1870
  _filterToSchemaColumns(table, row) {
1695
- const cols = this._columnCache.get(table);
1696
- if (!cols) return row;
1871
+ const cols = this._ensureColumnCache(table);
1872
+ if (!cols || cols.size === 0) return row;
1697
1873
  const keys = Object.keys(row);
1698
1874
  if (keys.every((k) => cols.has(k))) return row;
1699
1875
  return Object.fromEntries(keys.filter((k) => cols.has(k)).map((k) => [k, row[k]]));
@@ -1770,6 +1946,22 @@ var Lattice = class {
1770
1946
  return { clauses, params };
1771
1947
  }
1772
1948
  /** Returns a rejected Promise if not initialized; null if ready. */
1949
+ _fireWriteHooks(table, op, row, pk, changedColumns) {
1950
+ for (const hook of this._writeHooks) {
1951
+ if (hook.table !== table) continue;
1952
+ if (!hook.on.includes(op)) continue;
1953
+ if (op === "update" && hook.watchColumns && changedColumns) {
1954
+ if (!hook.watchColumns.some((c) => changedColumns.includes(c))) continue;
1955
+ }
1956
+ try {
1957
+ const ctx = { table, op, row, pk };
1958
+ if (changedColumns) ctx.changedColumns = changedColumns;
1959
+ hook.handler(ctx);
1960
+ } catch (err) {
1961
+ for (const h of this._errorHandlers) h(err instanceof Error ? err : new Error(String(err)));
1962
+ }
1963
+ }
1964
+ }
1773
1965
  _notInitError() {
1774
1966
  if (!this._initialized) {
1775
1967
  return Promise.reject(
package/dist/index.d.cts CHANGED
@@ -749,6 +749,107 @@ interface AuditEvent {
749
749
  id: string;
750
750
  timestamp: string;
751
751
  }
752
+ /**
753
+ * Options for {@link Lattice.upsertByNaturalKey}.
754
+ */
755
+ interface UpsertByNaturalKeyOptions {
756
+ /** Source file path for change tracking (stored in `source_file` column if present). */
757
+ sourceFile?: string;
758
+ /** Content hash of the source (stored in `source_hash` column if present). */
759
+ sourceHash?: string;
760
+ /** Organization ID — auto-set on insert when the table has an `org_id` column and data lacks it. */
761
+ orgId?: string;
762
+ }
763
+ /**
764
+ * Options for {@link Lattice.link}.
765
+ */
766
+ interface LinkOptions {
767
+ /** Use INSERT OR REPLACE instead of INSERT OR IGNORE. Set true when junction has updateable columns. */
768
+ upsert?: boolean;
769
+ }
770
+ /**
771
+ * Result from {@link Lattice.seed}.
772
+ */
773
+ interface SeedResult {
774
+ upserted: number;
775
+ linked: number;
776
+ softDeleted: number;
777
+ }
778
+ /**
779
+ * Link specification for {@link SeedConfig}.
780
+ */
781
+ interface SeedLinkSpec {
782
+ /** Junction table name. */
783
+ junction: string;
784
+ /** FK column in the junction that points to the linked entity. */
785
+ foreignKey: string;
786
+ /** Column on the target table used to resolve names to IDs. */
787
+ resolveBy: string;
788
+ /** Target table name (defaults to the link key). */
789
+ resolveTable?: string;
790
+ /** Additional static columns to set on each junction row. */
791
+ extras?: Record<string, unknown>;
792
+ }
793
+ /**
794
+ * Configuration for {@link Lattice.seed}.
795
+ */
796
+ interface SeedConfig {
797
+ /** Array of records to seed (caller loads from YAML/JSON). */
798
+ data: Record<string, unknown>[];
799
+ /** Target table. */
800
+ table: string;
801
+ /** Column used as the natural key for upserting. */
802
+ naturalKey: string;
803
+ /** Source file for soft-delete tracking. */
804
+ sourceFile?: string;
805
+ /** Content hash. */
806
+ sourceHash?: string;
807
+ /** Junction table links — key is the field name on each data record containing an array of names. */
808
+ linkTo?: Record<string, SeedLinkSpec>;
809
+ /** Soft-delete records not in data. */
810
+ softDeleteMissing?: boolean;
811
+ /** Organization ID for org-scoped tables. */
812
+ orgId?: string;
813
+ }
814
+ /**
815
+ * Context passed to write hook handlers.
816
+ */
817
+ interface WriteHookContext {
818
+ /** Table that was modified. */
819
+ table: string;
820
+ /** The operation that triggered the hook. */
821
+ op: 'insert' | 'update' | 'delete';
822
+ /** The row data (for insert: full row; for update: changed fields; for delete: { id }). */
823
+ row: Row;
824
+ /** Primary key value(s) of the affected row. */
825
+ pk: string;
826
+ /** For updates: the column names that were changed. */
827
+ changedColumns?: string[];
828
+ }
829
+ /**
830
+ * A write hook fires after insert/update/delete operations.
831
+ *
832
+ * @example
833
+ * ```ts
834
+ * db.defineWriteHook({
835
+ * table: 'agents',
836
+ * on: ['insert', 'update'],
837
+ * watchColumns: ['team_id'],
838
+ * handler: (ctx) => { denormalizeTeamFields(ctx.pk); },
839
+ * });
840
+ * ```
841
+ */
842
+ interface WriteHook {
843
+ /** Table the hook fires on. */
844
+ table: string;
845
+ /** Operations that trigger the hook. */
846
+ on: Array<'insert' | 'update' | 'delete'>;
847
+ /** Only fire on update when these columns changed. Omit = fire on any change. */
848
+ watchColumns?: string[];
849
+ /** Handler function. Runs synchronously after the DB write. */
850
+ handler: (ctx: WriteHookContext) => void;
851
+ }
852
+
752
853
  interface ReconcileOptions {
753
854
  /** Remove entity directories whose slug is no longer in the DB. Default: true. */
754
855
  removeOrphanedDirectories?: boolean;
@@ -801,10 +902,16 @@ declare class Lattice {
801
902
  private readonly _renderHandlers;
802
903
  private readonly _writebackHandlers;
803
904
  private readonly _errorHandlers;
905
+ private readonly _writeHooks;
804
906
  constructor(pathOrConfig: string | LatticeConfigInput, options?: LatticeOptions);
805
907
  define(table: string, def: TableDefinition): this;
806
908
  defineMulti(name: string, def: MultiTableDefinition): this;
807
909
  defineEntityContext(table: string, def: EntityContextDefinition): this;
910
+ /**
911
+ * Register a write hook that fires after insert/update/delete operations.
912
+ * Hooks run synchronously after the DB write and audit emit.
913
+ */
914
+ defineWriteHook(hook: WriteHook): this;
808
915
  defineWriteback(def: WritebackDefinition): this;
809
916
  init(options?: InitOptions): Promise<void>;
810
917
  close(): void;
@@ -814,6 +921,44 @@ declare class Lattice {
814
921
  update(table: string, id: PkLookup, row: Partial<Row>): Promise<void>;
815
922
  delete(table: string, id: PkLookup): Promise<void>;
816
923
  get(table: string, id: PkLookup): Promise<Row | null>;
924
+ /**
925
+ * Upsert a record by natural key. If a non-deleted record with the given
926
+ * natural key exists, update it. Otherwise insert with a new UUID.
927
+ * Auto-handles `org_id`, `updated_at`, `deleted_at`, `source_file`, `source_hash`.
928
+ */
929
+ upsertByNaturalKey(table: string, naturalKeyCol: string, naturalKeyVal: string, data: Row, opts?: UpsertByNaturalKeyOptions): Promise<string>;
930
+ /**
931
+ * Sparse update by natural key — only writes non-null fields on an existing record.
932
+ * Returns true if a row was found and updated.
933
+ */
934
+ enrichByNaturalKey(table: string, naturalKeyCol: string, naturalKeyVal: string, data: Row): Promise<boolean>;
935
+ /**
936
+ * Soft-delete records from a source file whose natural key is NOT in the given set.
937
+ * Returns count of rows soft-deleted.
938
+ */
939
+ softDeleteMissing(table: string, naturalKeyCol: string, sourceFile: string, currentKeys: string[]): Promise<number>;
940
+ /**
941
+ * Get all non-deleted rows from a table, ordered by the given column.
942
+ * Works on any table, not just defined ones.
943
+ */
944
+ getActive(table: string, orderBy?: string): Promise<Row[]>;
945
+ /**
946
+ * Count non-deleted rows in a table.
947
+ */
948
+ countActive(table: string): Promise<number>;
949
+ /**
950
+ * Lookup a single row by natural key (non-deleted).
951
+ */
952
+ getByNaturalKey(table: string, naturalKeyCol: string, naturalKeyVal: string): Promise<Row | null>;
953
+ /**
954
+ * Insert a row into a junction table. Uses INSERT OR IGNORE by default
955
+ * (idempotent). Pass `{ upsert: true }` for INSERT OR REPLACE.
956
+ */
957
+ link(junctionTable: string, data: Row, opts?: LinkOptions): Promise<void>;
958
+ /**
959
+ * Delete rows from a junction table matching all given conditions.
960
+ */
961
+ unlink(junctionTable: string, conditions: Row): Promise<void>;
817
962
  query(table: string, opts?: QueryOptions): Promise<Row[]>;
818
963
  count(table: string, opts?: CountOptions): Promise<number>;
819
964
  render(outputDir: string): Promise<RenderResult>;
@@ -837,6 +982,8 @@ declare class Lattice {
837
982
  * objects are interpolated into SQL, so stripping unknown keys eliminates
838
983
  * any theoretical injection vector from crafted object keys.
839
984
  */
985
+ /** Lazily populate column cache for tables not registered via define(). */
986
+ private _ensureColumnCache;
840
987
  private _filterToSchemaColumns;
841
988
  /**
842
989
  * Build the WHERE clause and params for a PK lookup.
@@ -850,6 +997,7 @@ declare class Lattice {
850
997
  */
851
998
  private _buildFilters;
852
999
  /** Returns a rejected Promise if not initialized; null if ready. */
1000
+ private _fireWriteHooks;
853
1001
  private _notInitError;
854
1002
  /**
855
1003
  * Returns a rejected Promise if any of the given column names are not present
@@ -1305,4 +1453,4 @@ declare function createReadOnlyHeader(options?: ReadOnlyHeaderOptions): string;
1305
1453
  */
1306
1454
  declare const READ_ONLY_HEADER: string;
1307
1455
 
1308
- 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 WritebackDefinition, applyWriteEntry, createReadOnlyHeader, frontmatter, generateEntryId, generateWriteEntryId, manifestPath, markdownTable, parseConfigFile, parseConfigString, parseMarkdownEntries, parseSessionMD, parseSessionWrites, readManifest, slugify, truncate, validateEntryId, writeManifest };
1456
+ 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 LinkOptions, 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 SeedConfig, type SeedLinkSpec, type SeedResult, type SelfSource, type SessionEntry, type SessionParseOptions, type SessionWriteEntry, type SessionWriteOp, type SessionWriteParseResult, type SourceQueryOptions, type StopFn, type SyncResult, type TableDefinition, type TemplateRenderSpec, type UpsertByNaturalKeyOptions, 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
@@ -749,6 +749,107 @@ interface AuditEvent {
749
749
  id: string;
750
750
  timestamp: string;
751
751
  }
752
+ /**
753
+ * Options for {@link Lattice.upsertByNaturalKey}.
754
+ */
755
+ interface UpsertByNaturalKeyOptions {
756
+ /** Source file path for change tracking (stored in `source_file` column if present). */
757
+ sourceFile?: string;
758
+ /** Content hash of the source (stored in `source_hash` column if present). */
759
+ sourceHash?: string;
760
+ /** Organization ID — auto-set on insert when the table has an `org_id` column and data lacks it. */
761
+ orgId?: string;
762
+ }
763
+ /**
764
+ * Options for {@link Lattice.link}.
765
+ */
766
+ interface LinkOptions {
767
+ /** Use INSERT OR REPLACE instead of INSERT OR IGNORE. Set true when junction has updateable columns. */
768
+ upsert?: boolean;
769
+ }
770
+ /**
771
+ * Result from {@link Lattice.seed}.
772
+ */
773
+ interface SeedResult {
774
+ upserted: number;
775
+ linked: number;
776
+ softDeleted: number;
777
+ }
778
+ /**
779
+ * Link specification for {@link SeedConfig}.
780
+ */
781
+ interface SeedLinkSpec {
782
+ /** Junction table name. */
783
+ junction: string;
784
+ /** FK column in the junction that points to the linked entity. */
785
+ foreignKey: string;
786
+ /** Column on the target table used to resolve names to IDs. */
787
+ resolveBy: string;
788
+ /** Target table name (defaults to the link key). */
789
+ resolveTable?: string;
790
+ /** Additional static columns to set on each junction row. */
791
+ extras?: Record<string, unknown>;
792
+ }
793
+ /**
794
+ * Configuration for {@link Lattice.seed}.
795
+ */
796
+ interface SeedConfig {
797
+ /** Array of records to seed (caller loads from YAML/JSON). */
798
+ data: Record<string, unknown>[];
799
+ /** Target table. */
800
+ table: string;
801
+ /** Column used as the natural key for upserting. */
802
+ naturalKey: string;
803
+ /** Source file for soft-delete tracking. */
804
+ sourceFile?: string;
805
+ /** Content hash. */
806
+ sourceHash?: string;
807
+ /** Junction table links — key is the field name on each data record containing an array of names. */
808
+ linkTo?: Record<string, SeedLinkSpec>;
809
+ /** Soft-delete records not in data. */
810
+ softDeleteMissing?: boolean;
811
+ /** Organization ID for org-scoped tables. */
812
+ orgId?: string;
813
+ }
814
+ /**
815
+ * Context passed to write hook handlers.
816
+ */
817
+ interface WriteHookContext {
818
+ /** Table that was modified. */
819
+ table: string;
820
+ /** The operation that triggered the hook. */
821
+ op: 'insert' | 'update' | 'delete';
822
+ /** The row data (for insert: full row; for update: changed fields; for delete: { id }). */
823
+ row: Row;
824
+ /** Primary key value(s) of the affected row. */
825
+ pk: string;
826
+ /** For updates: the column names that were changed. */
827
+ changedColumns?: string[];
828
+ }
829
+ /**
830
+ * A write hook fires after insert/update/delete operations.
831
+ *
832
+ * @example
833
+ * ```ts
834
+ * db.defineWriteHook({
835
+ * table: 'agents',
836
+ * on: ['insert', 'update'],
837
+ * watchColumns: ['team_id'],
838
+ * handler: (ctx) => { denormalizeTeamFields(ctx.pk); },
839
+ * });
840
+ * ```
841
+ */
842
+ interface WriteHook {
843
+ /** Table the hook fires on. */
844
+ table: string;
845
+ /** Operations that trigger the hook. */
846
+ on: Array<'insert' | 'update' | 'delete'>;
847
+ /** Only fire on update when these columns changed. Omit = fire on any change. */
848
+ watchColumns?: string[];
849
+ /** Handler function. Runs synchronously after the DB write. */
850
+ handler: (ctx: WriteHookContext) => void;
851
+ }
852
+
752
853
  interface ReconcileOptions {
753
854
  /** Remove entity directories whose slug is no longer in the DB. Default: true. */
754
855
  removeOrphanedDirectories?: boolean;
@@ -801,10 +902,16 @@ declare class Lattice {
801
902
  private readonly _renderHandlers;
802
903
  private readonly _writebackHandlers;
803
904
  private readonly _errorHandlers;
905
+ private readonly _writeHooks;
804
906
  constructor(pathOrConfig: string | LatticeConfigInput, options?: LatticeOptions);
805
907
  define(table: string, def: TableDefinition): this;
806
908
  defineMulti(name: string, def: MultiTableDefinition): this;
807
909
  defineEntityContext(table: string, def: EntityContextDefinition): this;
910
+ /**
911
+ * Register a write hook that fires after insert/update/delete operations.
912
+ * Hooks run synchronously after the DB write and audit emit.
913
+ */
914
+ defineWriteHook(hook: WriteHook): this;
808
915
  defineWriteback(def: WritebackDefinition): this;
809
916
  init(options?: InitOptions): Promise<void>;
810
917
  close(): void;
@@ -814,6 +921,44 @@ declare class Lattice {
814
921
  update(table: string, id: PkLookup, row: Partial<Row>): Promise<void>;
815
922
  delete(table: string, id: PkLookup): Promise<void>;
816
923
  get(table: string, id: PkLookup): Promise<Row | null>;
924
+ /**
925
+ * Upsert a record by natural key. If a non-deleted record with the given
926
+ * natural key exists, update it. Otherwise insert with a new UUID.
927
+ * Auto-handles `org_id`, `updated_at`, `deleted_at`, `source_file`, `source_hash`.
928
+ */
929
+ upsertByNaturalKey(table: string, naturalKeyCol: string, naturalKeyVal: string, data: Row, opts?: UpsertByNaturalKeyOptions): Promise<string>;
930
+ /**
931
+ * Sparse update by natural key — only writes non-null fields on an existing record.
932
+ * Returns true if a row was found and updated.
933
+ */
934
+ enrichByNaturalKey(table: string, naturalKeyCol: string, naturalKeyVal: string, data: Row): Promise<boolean>;
935
+ /**
936
+ * Soft-delete records from a source file whose natural key is NOT in the given set.
937
+ * Returns count of rows soft-deleted.
938
+ */
939
+ softDeleteMissing(table: string, naturalKeyCol: string, sourceFile: string, currentKeys: string[]): Promise<number>;
940
+ /**
941
+ * Get all non-deleted rows from a table, ordered by the given column.
942
+ * Works on any table, not just defined ones.
943
+ */
944
+ getActive(table: string, orderBy?: string): Promise<Row[]>;
945
+ /**
946
+ * Count non-deleted rows in a table.
947
+ */
948
+ countActive(table: string): Promise<number>;
949
+ /**
950
+ * Lookup a single row by natural key (non-deleted).
951
+ */
952
+ getByNaturalKey(table: string, naturalKeyCol: string, naturalKeyVal: string): Promise<Row | null>;
953
+ /**
954
+ * Insert a row into a junction table. Uses INSERT OR IGNORE by default
955
+ * (idempotent). Pass `{ upsert: true }` for INSERT OR REPLACE.
956
+ */
957
+ link(junctionTable: string, data: Row, opts?: LinkOptions): Promise<void>;
958
+ /**
959
+ * Delete rows from a junction table matching all given conditions.
960
+ */
961
+ unlink(junctionTable: string, conditions: Row): Promise<void>;
817
962
  query(table: string, opts?: QueryOptions): Promise<Row[]>;
818
963
  count(table: string, opts?: CountOptions): Promise<number>;
819
964
  render(outputDir: string): Promise<RenderResult>;
@@ -837,6 +982,8 @@ declare class Lattice {
837
982
  * objects are interpolated into SQL, so stripping unknown keys eliminates
838
983
  * any theoretical injection vector from crafted object keys.
839
984
  */
985
+ /** Lazily populate column cache for tables not registered via define(). */
986
+ private _ensureColumnCache;
840
987
  private _filterToSchemaColumns;
841
988
  /**
842
989
  * Build the WHERE clause and params for a PK lookup.
@@ -850,6 +997,7 @@ declare class Lattice {
850
997
  */
851
998
  private _buildFilters;
852
999
  /** Returns a rejected Promise if not initialized; null if ready. */
1000
+ private _fireWriteHooks;
853
1001
  private _notInitError;
854
1002
  /**
855
1003
  * Returns a rejected Promise if any of the given column names are not present
@@ -1305,4 +1453,4 @@ declare function createReadOnlyHeader(options?: ReadOnlyHeaderOptions): string;
1305
1453
  */
1306
1454
  declare const READ_ONLY_HEADER: string;
1307
1455
 
1308
- 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 WritebackDefinition, applyWriteEntry, createReadOnlyHeader, frontmatter, generateEntryId, generateWriteEntryId, manifestPath, markdownTable, parseConfigFile, parseConfigString, parseMarkdownEntries, parseSessionMD, parseSessionWrites, readManifest, slugify, truncate, validateEntryId, writeManifest };
1456
+ 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 LinkOptions, 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 SeedConfig, type SeedLinkSpec, type SeedResult, type SelfSource, type SessionEntry, type SessionParseOptions, type SessionWriteEntry, type SessionWriteOp, type SessionWriteParseResult, type SourceQueryOptions, type StopFn, type SyncResult, type TableDefinition, type TemplateRenderSpec, type UpsertByNaturalKeyOptions, 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
@@ -1307,6 +1307,7 @@ var Lattice = class {
1307
1307
  _renderHandlers = [];
1308
1308
  _writebackHandlers = [];
1309
1309
  _errorHandlers = [];
1310
+ _writeHooks = [];
1310
1311
  constructor(pathOrConfig, options = {}) {
1311
1312
  let dbPath;
1312
1313
  let configTables;
@@ -1366,6 +1367,14 @@ var Lattice = class {
1366
1367
  this._schema.defineEntityContext(table, def);
1367
1368
  return this;
1368
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
+ }
1369
1378
  defineWriteback(def) {
1370
1379
  this._writeback.define(def);
1371
1380
  return this;
@@ -1415,6 +1424,7 @@ var Lattice = class {
1415
1424
  const rawPk = rowWithPk[pkCol];
1416
1425
  const pkValue = rawPk != null ? String(rawPk) : "";
1417
1426
  this._sanitizer.emitAudit(table, "insert", pkValue);
1427
+ this._fireWriteHooks(table, "insert", rowWithPk, pkValue);
1418
1428
  return Promise.resolve(pkValue);
1419
1429
  }
1420
1430
  upsert(table, row) {
@@ -1468,6 +1478,7 @@ var Lattice = class {
1468
1478
  this._adapter.run(`UPDATE "${table}" SET ${setCols} WHERE ${clause}`, values);
1469
1479
  const auditId = typeof id === "string" ? id : JSON.stringify(id);
1470
1480
  this._sanitizer.emitAudit(table, "update", auditId);
1481
+ this._fireWriteHooks(table, "update", sanitized, auditId, Object.keys(sanitized));
1471
1482
  return Promise.resolve();
1472
1483
  }
1473
1484
  delete(table, id) {
@@ -1477,6 +1488,7 @@ var Lattice = class {
1477
1488
  this._adapter.run(`DELETE FROM "${table}" WHERE ${clause}`, params);
1478
1489
  const auditId = typeof id === "string" ? id : JSON.stringify(id);
1479
1490
  this._sanitizer.emitAudit(table, "delete", auditId);
1491
+ this._fireWriteHooks(table, "delete", { id: auditId }, auditId);
1480
1492
  return Promise.resolve();
1481
1493
  }
1482
1494
  get(table, id) {
@@ -1487,6 +1499,160 @@ var Lattice = class {
1487
1499
  this._adapter.get(`SELECT * FROM "${table}" WHERE ${clause}`, params) ?? null
1488
1500
  );
1489
1501
  }
1502
+ // -------------------------------------------------------------------------
1503
+ // Generic CRUD — works on ANY table (v0.11+)
1504
+ // -------------------------------------------------------------------------
1505
+ /**
1506
+ * Upsert a record by natural key. If a non-deleted record with the given
1507
+ * natural key exists, update it. Otherwise insert with a new UUID.
1508
+ * Auto-handles `org_id`, `updated_at`, `deleted_at`, `source_file`, `source_hash`.
1509
+ */
1510
+ upsertByNaturalKey(table, naturalKeyCol, naturalKeyVal, data, opts) {
1511
+ const notInit = this._notInitError();
1512
+ if (notInit) return notInit;
1513
+ const cols = this._ensureColumnCache(table);
1514
+ const sanitized = this._filterToSchemaColumns(table, this._sanitizer.sanitizeRow(data));
1515
+ const withConventions = { ...sanitized };
1516
+ if (cols.has("updated_at")) withConventions.updated_at = (/* @__PURE__ */ new Date()).toISOString();
1517
+ if (opts?.sourceFile && cols.has("source_file")) withConventions.source_file = opts.sourceFile;
1518
+ if (opts?.sourceHash && cols.has("source_hash")) withConventions.source_hash = opts.sourceHash;
1519
+ const existing = this._adapter.get(
1520
+ `SELECT id FROM "${table}" WHERE "${naturalKeyCol}" = ? AND (deleted_at IS NULL OR deleted_at = '')`,
1521
+ [naturalKeyVal]
1522
+ );
1523
+ if (existing) {
1524
+ const entries = Object.entries(withConventions).filter(([k]) => k !== "id");
1525
+ if (entries.length === 0) return Promise.resolve(existing.id);
1526
+ const setCols = entries.map(([k]) => `"${k}" = ?`).join(", ");
1527
+ this._adapter.run(`UPDATE "${table}" SET ${setCols} WHERE id = ?`, [...entries.map(([, v]) => v), existing.id]);
1528
+ this._fireWriteHooks(table, "update", withConventions, existing.id, Object.keys(sanitized));
1529
+ return Promise.resolve(existing.id);
1530
+ }
1531
+ const id = sanitized.id ?? uuidv4();
1532
+ const insertData = { ...withConventions, id, [naturalKeyCol]: naturalKeyVal };
1533
+ if (opts?.orgId && cols.has("org_id") && !insertData.org_id) insertData.org_id = opts.orgId;
1534
+ if (cols.has("deleted_at")) insertData.deleted_at = null;
1535
+ if (cols.has("created_at") && !insertData.created_at) insertData.created_at = (/* @__PURE__ */ new Date()).toISOString();
1536
+ const filtered = this._filterToSchemaColumns(table, insertData);
1537
+ const colNames = Object.keys(filtered).map((c) => `"${c}"`).join(", ");
1538
+ const placeholders = Object.keys(filtered).map(() => "?").join(", ");
1539
+ this._adapter.run(`INSERT INTO "${table}" (${colNames}) VALUES (${placeholders})`, Object.values(filtered));
1540
+ this._fireWriteHooks(table, "insert", filtered, id);
1541
+ return Promise.resolve(id);
1542
+ }
1543
+ /**
1544
+ * Sparse update by natural key — only writes non-null fields on an existing record.
1545
+ * Returns true if a row was found and updated.
1546
+ */
1547
+ enrichByNaturalKey(table, naturalKeyCol, naturalKeyVal, data) {
1548
+ const notInit = this._notInitError();
1549
+ if (notInit) return notInit;
1550
+ const existing = this._adapter.get(
1551
+ `SELECT id FROM "${table}" WHERE "${naturalKeyCol}" = ? AND (deleted_at IS NULL OR deleted_at = '')`,
1552
+ [naturalKeyVal]
1553
+ );
1554
+ if (!existing) return Promise.resolve(false);
1555
+ const sanitized = this._filterToSchemaColumns(table, this._sanitizer.sanitizeRow(data));
1556
+ const entries = Object.entries(sanitized).filter(([k, v]) => v !== null && v !== void 0 && k !== "id");
1557
+ if (entries.length === 0) return Promise.resolve(true);
1558
+ const cols = this._ensureColumnCache(table);
1559
+ const withTs = [...entries];
1560
+ if (cols.has("updated_at")) withTs.push(["updated_at", (/* @__PURE__ */ new Date()).toISOString()]);
1561
+ const setCols = withTs.map(([k]) => `"${k}" = ?`).join(", ");
1562
+ this._adapter.run(`UPDATE "${table}" SET ${setCols} WHERE id = ?`, [...withTs.map(([, v]) => v), existing.id]);
1563
+ this._fireWriteHooks(table, "update", Object.fromEntries(entries), existing.id, entries.map(([k]) => k));
1564
+ return Promise.resolve(true);
1565
+ }
1566
+ /**
1567
+ * Soft-delete records from a source file whose natural key is NOT in the given set.
1568
+ * Returns count of rows soft-deleted.
1569
+ */
1570
+ softDeleteMissing(table, naturalKeyCol, sourceFile, currentKeys) {
1571
+ const notInit = this._notInitError();
1572
+ if (notInit) return notInit;
1573
+ if (currentKeys.length === 0) return Promise.resolve(0);
1574
+ const placeholders = currentKeys.map(() => "?").join(", ");
1575
+ const countRow = this._adapter.get(
1576
+ `SELECT COUNT(*) as cnt FROM "${table}"
1577
+ WHERE source_file = ? AND "${naturalKeyCol}" NOT IN (${placeholders})
1578
+ AND (deleted_at IS NULL OR deleted_at = '')`,
1579
+ [sourceFile, ...currentKeys]
1580
+ );
1581
+ const count = countRow?.cnt ?? 0;
1582
+ if (count > 0) {
1583
+ this._adapter.run(
1584
+ `UPDATE "${table}" SET deleted_at = datetime('now'), updated_at = datetime('now')
1585
+ WHERE source_file = ? AND "${naturalKeyCol}" NOT IN (${placeholders})
1586
+ AND (deleted_at IS NULL OR deleted_at = '')`,
1587
+ [sourceFile, ...currentKeys]
1588
+ );
1589
+ }
1590
+ return Promise.resolve(count);
1591
+ }
1592
+ /**
1593
+ * Get all non-deleted rows from a table, ordered by the given column.
1594
+ * Works on any table, not just defined ones.
1595
+ */
1596
+ getActive(table, orderBy = "name") {
1597
+ const notInit = this._notInitError();
1598
+ if (notInit) return notInit;
1599
+ const cols = this._ensureColumnCache(table);
1600
+ const hasDeletedAt = cols.has("deleted_at");
1601
+ const where = hasDeletedAt ? ` WHERE deleted_at IS NULL` : "";
1602
+ const order = cols.has(orderBy) ? ` ORDER BY "${orderBy}"` : "";
1603
+ return Promise.resolve(this._adapter.all(`SELECT * FROM "${table}"${where}${order}`));
1604
+ }
1605
+ /**
1606
+ * Count non-deleted rows in a table.
1607
+ */
1608
+ countActive(table) {
1609
+ const notInit = this._notInitError();
1610
+ if (notInit) return notInit;
1611
+ const cols = this._ensureColumnCache(table);
1612
+ const hasDeletedAt = cols.has("deleted_at");
1613
+ const where = hasDeletedAt ? ` WHERE deleted_at IS NULL` : "";
1614
+ const row = this._adapter.get(`SELECT COUNT(*) as cnt FROM "${table}"${where}`);
1615
+ return Promise.resolve(row.cnt);
1616
+ }
1617
+ /**
1618
+ * Lookup a single row by natural key (non-deleted).
1619
+ */
1620
+ getByNaturalKey(table, naturalKeyCol, naturalKeyVal) {
1621
+ const notInit = this._notInitError();
1622
+ if (notInit) return notInit;
1623
+ return Promise.resolve(
1624
+ this._adapter.get(
1625
+ `SELECT * FROM "${table}" WHERE "${naturalKeyCol}" = ? AND (deleted_at IS NULL OR deleted_at = '')`,
1626
+ [naturalKeyVal]
1627
+ ) ?? null
1628
+ );
1629
+ }
1630
+ /**
1631
+ * Insert a row into a junction table. Uses INSERT OR IGNORE by default
1632
+ * (idempotent). Pass `{ upsert: true }` for INSERT OR REPLACE.
1633
+ */
1634
+ link(junctionTable, data, opts) {
1635
+ const notInit = this._notInitError();
1636
+ if (notInit) return notInit;
1637
+ const filtered = this._filterToSchemaColumns(junctionTable, data);
1638
+ const colNames = Object.keys(filtered).map((c) => `"${c}"`).join(", ");
1639
+ const placeholders = Object.keys(filtered).map(() => "?").join(", ");
1640
+ const verb = opts?.upsert ? "INSERT OR REPLACE" : "INSERT OR IGNORE";
1641
+ this._adapter.run(`${verb} INTO "${junctionTable}" (${colNames}) VALUES (${placeholders})`, Object.values(filtered));
1642
+ return Promise.resolve();
1643
+ }
1644
+ /**
1645
+ * Delete rows from a junction table matching all given conditions.
1646
+ */
1647
+ unlink(junctionTable, conditions) {
1648
+ const notInit = this._notInitError();
1649
+ if (notInit) return notInit;
1650
+ const entries = Object.entries(conditions);
1651
+ if (entries.length === 0) return Promise.resolve();
1652
+ const where = entries.map(([k]) => `"${k}" = ?`).join(" AND ");
1653
+ this._adapter.run(`DELETE FROM "${junctionTable}" WHERE ${where}`, entries.map(([, v]) => v));
1654
+ return Promise.resolve();
1655
+ }
1490
1656
  query(table, opts = {}) {
1491
1657
  const notInit = this._notInitError();
1492
1658
  if (notInit) return notInit;
@@ -1635,9 +1801,19 @@ var Lattice = class {
1635
1801
  * objects are interpolated into SQL, so stripping unknown keys eliminates
1636
1802
  * any theoretical injection vector from crafted object keys.
1637
1803
  */
1804
+ /** Lazily populate column cache for tables not registered via define(). */
1805
+ _ensureColumnCache(table) {
1806
+ let cols = this._columnCache.get(table);
1807
+ if (!cols) {
1808
+ const rows = this._adapter.all(`PRAGMA table_info("${table}")`);
1809
+ cols = new Set(rows.map((r) => r.name));
1810
+ if (cols.size > 0) this._columnCache.set(table, cols);
1811
+ }
1812
+ return cols;
1813
+ }
1638
1814
  _filterToSchemaColumns(table, row) {
1639
- const cols = this._columnCache.get(table);
1640
- if (!cols) return row;
1815
+ const cols = this._ensureColumnCache(table);
1816
+ if (!cols || cols.size === 0) return row;
1641
1817
  const keys = Object.keys(row);
1642
1818
  if (keys.every((k) => cols.has(k))) return row;
1643
1819
  return Object.fromEntries(keys.filter((k) => cols.has(k)).map((k) => [k, row[k]]));
@@ -1714,6 +1890,22 @@ var Lattice = class {
1714
1890
  return { clauses, params };
1715
1891
  }
1716
1892
  /** Returns a rejected Promise if not initialized; null if ready. */
1893
+ _fireWriteHooks(table, op, row, pk, changedColumns) {
1894
+ for (const hook of this._writeHooks) {
1895
+ if (hook.table !== table) continue;
1896
+ if (!hook.on.includes(op)) continue;
1897
+ if (op === "update" && hook.watchColumns && changedColumns) {
1898
+ if (!hook.watchColumns.some((c) => changedColumns.includes(c))) continue;
1899
+ }
1900
+ try {
1901
+ const ctx = { table, op, row, pk };
1902
+ if (changedColumns) ctx.changedColumns = changedColumns;
1903
+ hook.handler(ctx);
1904
+ } catch (err) {
1905
+ for (const h of this._errorHandlers) h(err instanceof Error ? err : new Error(String(err)));
1906
+ }
1907
+ }
1908
+ }
1717
1909
  _notInitError() {
1718
1910
  if (!this._initialized) {
1719
1911
  return Promise.reject(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latticesql",
3
- "version": "0.9.0",
3
+ "version": "0.11.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",