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 +128 -0
- package/dist/cli.js +194 -2
- package/dist/index.cjs +194 -2
- package/dist/index.d.cts +149 -1
- package/dist/index.d.ts +149 -1
- package/dist/index.js +194 -2
- package/package.json +1 -1
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.
|
|
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.
|
|
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.
|
|
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(
|