latticesql 0.5.1 → 0.5.3
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 +73 -0
- package/dist/cli.js +14 -4
- package/dist/index.cjs +411 -4
- package/dist/index.d.cts +131 -1
- package/dist/index.d.ts +131 -1
- package/dist/index.js +403 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,6 +44,7 @@ Lattice has no opinions about your schema, your agents, or your file format. You
|
|
|
44
44
|
- [Lifecycle hooks](#lifecycle-hooks)
|
|
45
45
|
- [Field interpolation](#field-interpolation)
|
|
46
46
|
- [Entity context directories (v0.5+)](#entity-context-directories-v05)
|
|
47
|
+
- [SESSION.md write pattern](#sessionmd-write-pattern)
|
|
47
48
|
- [YAML config (v0.4+)](#yaml-config-v04)
|
|
48
49
|
- [lattice.config.yml reference](#latticeconfigyml-reference)
|
|
49
50
|
- [Init from config](#init-from-config)
|
|
@@ -1000,6 +1001,78 @@ See [docs/entity-context.md](./docs/entity-context.md) for the complete referenc
|
|
|
1000
1001
|
|
|
1001
1002
|
---
|
|
1002
1003
|
|
|
1004
|
+
## SESSION.md write pattern
|
|
1005
|
+
|
|
1006
|
+
When agents run in a directory-based context system (e.g., one directory per agent with generated Markdown files), SESSION.md provides a **safe write interface** that enforces a clean read/write separation:
|
|
1007
|
+
|
|
1008
|
+
```
|
|
1009
|
+
READ: Lattice DB → render() → object MDs (READ ONLY for agents)
|
|
1010
|
+
WRITE: Agent → SESSION.md → processor → validates → Lattice DB
|
|
1011
|
+
```
|
|
1012
|
+
|
|
1013
|
+
All generated context files carry a read-only header so agents know not to edit them directly. SESSION.md is the only writable file in the directory.
|
|
1014
|
+
|
|
1015
|
+
### Write entry format
|
|
1016
|
+
|
|
1017
|
+
```
|
|
1018
|
+
---
|
|
1019
|
+
id: 2026-03-25T10:30:00Z-agent-abc123
|
|
1020
|
+
type: write
|
|
1021
|
+
timestamp: 2026-03-25T10:30:00Z
|
|
1022
|
+
op: update
|
|
1023
|
+
table: agents
|
|
1024
|
+
target: agent-id-here
|
|
1025
|
+
reason: Updating status after deployment completed.
|
|
1026
|
+
---
|
|
1027
|
+
status: active
|
|
1028
|
+
last_task: piut-deploy
|
|
1029
|
+
===
|
|
1030
|
+
```
|
|
1031
|
+
|
|
1032
|
+
| Header | Required | Description |
|
|
1033
|
+
|--------|----------|-------------|
|
|
1034
|
+
| `type` | Yes | Must be `write` |
|
|
1035
|
+
| `timestamp` | Yes | ISO 8601 |
|
|
1036
|
+
| `op` | Yes | `create`, `update`, or `delete` |
|
|
1037
|
+
| `table` | Yes | Target table name |
|
|
1038
|
+
| `target` | For update/delete | Record primary key |
|
|
1039
|
+
| `reason` | Encouraged | Human-readable reason (audit trail) |
|
|
1040
|
+
|
|
1041
|
+
**Body**: `key: value` pairs — one field per line. Field names are validated against the table schema before any write is applied.
|
|
1042
|
+
|
|
1043
|
+
### Library support
|
|
1044
|
+
|
|
1045
|
+
`latticesql` exports a parser for the SESSION.md write format:
|
|
1046
|
+
|
|
1047
|
+
```ts
|
|
1048
|
+
import { parseSessionWrites } from 'latticesql';
|
|
1049
|
+
|
|
1050
|
+
const result = parseSessionWrites(sessionFileContent);
|
|
1051
|
+
// result.entries: SessionWriteEntry[]
|
|
1052
|
+
// result.errors: Array<{ line: number; message: string }>
|
|
1053
|
+
|
|
1054
|
+
for (const entry of result.entries) {
|
|
1055
|
+
console.log(entry.op, entry.table, entry.target, entry.fields);
|
|
1056
|
+
}
|
|
1057
|
+
```
|
|
1058
|
+
|
|
1059
|
+
**`SessionWriteEntry`:**
|
|
1060
|
+
```ts
|
|
1061
|
+
interface SessionWriteEntry {
|
|
1062
|
+
id: string; // content-addressed ID
|
|
1063
|
+
timestamp: string; // ISO 8601
|
|
1064
|
+
op: 'create' | 'update' | 'delete';
|
|
1065
|
+
table: string;
|
|
1066
|
+
target?: string; // required for update/delete
|
|
1067
|
+
reason?: string;
|
|
1068
|
+
fields: Record<string, string>; // empty for delete
|
|
1069
|
+
}
|
|
1070
|
+
```
|
|
1071
|
+
|
|
1072
|
+
The processor is responsible for applying the parsed entries to your DB and validating field names against your schema. The `parseSessionWrites` function is pure — no DB access, no side effects.
|
|
1073
|
+
|
|
1074
|
+
---
|
|
1075
|
+
|
|
1003
1076
|
## YAML config (v0.4+)
|
|
1004
1077
|
|
|
1005
1078
|
Define your entire schema in a YAML file. Lattice reads it at construction time, creates all tables on `init()`, and wires render functions automatically.
|
package/dist/cli.js
CHANGED
|
@@ -503,12 +503,22 @@ var SchemaManager = class {
|
|
|
503
503
|
}
|
|
504
504
|
}
|
|
505
505
|
}
|
|
506
|
-
/**
|
|
506
|
+
/**
|
|
507
|
+
* Query all rows from a table.
|
|
508
|
+
* Registered tables (via `define()`) are queried directly.
|
|
509
|
+
* Tables used only in entity contexts (schema managed externally) fall back
|
|
510
|
+
* to a raw SELECT with optional `deleted_at IS NULL` soft-delete filtering.
|
|
511
|
+
*/
|
|
507
512
|
queryTable(adapter, name) {
|
|
508
|
-
if (
|
|
509
|
-
|
|
513
|
+
if (this._tables.has(name)) {
|
|
514
|
+
return adapter.all(`SELECT * FROM "${name}"`);
|
|
515
|
+
}
|
|
516
|
+
if (this._entityContexts.has(name)) {
|
|
517
|
+
const cols = adapter.all(`PRAGMA table_info("${name}")`);
|
|
518
|
+
const hasDeletedAt = cols.some((c) => c.name === "deleted_at");
|
|
519
|
+
return adapter.all(`SELECT * FROM "${name}"${hasDeletedAt ? " WHERE deleted_at IS NULL" : ""}`);
|
|
510
520
|
}
|
|
511
|
-
|
|
521
|
+
throw new Error(`Unknown table: "${name}"`);
|
|
512
522
|
}
|
|
513
523
|
_ensureTable(adapter, name, columns, tableConstraints) {
|
|
514
524
|
const colDefs = Object.entries(columns).map(([col, type]) => `"${col}" ${type}`).join(", ");
|
package/dist/index.cjs
CHANGED
|
@@ -31,10 +31,18 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
33
|
Lattice: () => Lattice,
|
|
34
|
+
READ_ONLY_HEADER: () => READ_ONLY_HEADER,
|
|
35
|
+
applyWriteEntry: () => applyWriteEntry,
|
|
36
|
+
generateEntryId: () => generateEntryId,
|
|
37
|
+
generateWriteEntryId: () => generateWriteEntryId,
|
|
34
38
|
manifestPath: () => manifestPath,
|
|
35
39
|
parseConfigFile: () => parseConfigFile,
|
|
36
40
|
parseConfigString: () => parseConfigString,
|
|
41
|
+
parseMarkdownEntries: () => parseMarkdownEntries,
|
|
42
|
+
parseSessionMD: () => parseSessionMD,
|
|
43
|
+
parseSessionWrites: () => parseSessionWrites,
|
|
37
44
|
readManifest: () => readManifest,
|
|
45
|
+
validateEntryId: () => validateEntryId,
|
|
38
46
|
writeManifest: () => writeManifest
|
|
39
47
|
});
|
|
40
48
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -230,12 +238,22 @@ var SchemaManager = class {
|
|
|
230
238
|
}
|
|
231
239
|
}
|
|
232
240
|
}
|
|
233
|
-
/**
|
|
241
|
+
/**
|
|
242
|
+
* Query all rows from a table.
|
|
243
|
+
* Registered tables (via `define()`) are queried directly.
|
|
244
|
+
* Tables used only in entity contexts (schema managed externally) fall back
|
|
245
|
+
* to a raw SELECT with optional `deleted_at IS NULL` soft-delete filtering.
|
|
246
|
+
*/
|
|
234
247
|
queryTable(adapter, name) {
|
|
235
|
-
if (
|
|
236
|
-
|
|
248
|
+
if (this._tables.has(name)) {
|
|
249
|
+
return adapter.all(`SELECT * FROM "${name}"`);
|
|
250
|
+
}
|
|
251
|
+
if (this._entityContexts.has(name)) {
|
|
252
|
+
const cols = adapter.all(`PRAGMA table_info("${name}")`);
|
|
253
|
+
const hasDeletedAt = cols.some((c) => c.name === "deleted_at");
|
|
254
|
+
return adapter.all(`SELECT * FROM "${name}"${hasDeletedAt ? " WHERE deleted_at IS NULL" : ""}`);
|
|
237
255
|
}
|
|
238
|
-
|
|
256
|
+
throw new Error(`Unknown table: "${name}"`);
|
|
239
257
|
}
|
|
240
258
|
_ensureTable(adapter, name, columns, tableConstraints) {
|
|
241
259
|
const colDefs = Object.entries(columns).map(([col, type]) => `"${col}" ${type}`).join(", ");
|
|
@@ -1503,12 +1521,401 @@ var Lattice = class {
|
|
|
1503
1521
|
}
|
|
1504
1522
|
}
|
|
1505
1523
|
};
|
|
1524
|
+
|
|
1525
|
+
// src/session/parser.ts
|
|
1526
|
+
var import_node_crypto3 = require("crypto");
|
|
1527
|
+
function generateWriteEntryId(timestamp, agentName, op, table, target) {
|
|
1528
|
+
const payload = `${op}:${table}:${target ?? ""}:${timestamp}`;
|
|
1529
|
+
const hash = (0, import_node_crypto3.createHash)("sha256").update(payload).digest("hex").slice(0, 6);
|
|
1530
|
+
return `${timestamp}-${agentName}-${hash}`;
|
|
1531
|
+
}
|
|
1532
|
+
function parseSessionWrites(content) {
|
|
1533
|
+
const entries = [];
|
|
1534
|
+
const errors = [];
|
|
1535
|
+
const blocks = splitIntoBlocks(content);
|
|
1536
|
+
for (const block of blocks) {
|
|
1537
|
+
const result = parseBlock(block);
|
|
1538
|
+
if (result === null) continue;
|
|
1539
|
+
if ("error" in result) {
|
|
1540
|
+
errors.push(result.error);
|
|
1541
|
+
} else {
|
|
1542
|
+
entries.push(result.entry);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
return { entries, errors };
|
|
1546
|
+
}
|
|
1547
|
+
function splitIntoBlocks(content) {
|
|
1548
|
+
const lines = content.split("\n");
|
|
1549
|
+
const blocks = [];
|
|
1550
|
+
let i = 0;
|
|
1551
|
+
while (i < lines.length) {
|
|
1552
|
+
while (i < lines.length && lines[i]?.trim() !== "---") {
|
|
1553
|
+
i++;
|
|
1554
|
+
}
|
|
1555
|
+
if (i >= lines.length) break;
|
|
1556
|
+
const startLine = i + 1;
|
|
1557
|
+
i++;
|
|
1558
|
+
const headerLines = [];
|
|
1559
|
+
while (i < lines.length && lines[i]?.trim() !== "---") {
|
|
1560
|
+
headerLines.push(lines[i] ?? "");
|
|
1561
|
+
i++;
|
|
1562
|
+
}
|
|
1563
|
+
if (i >= lines.length) break;
|
|
1564
|
+
i++;
|
|
1565
|
+
const bodyLines = [];
|
|
1566
|
+
while (i < lines.length && lines[i]?.trim() !== "===") {
|
|
1567
|
+
bodyLines.push(lines[i] ?? "");
|
|
1568
|
+
i++;
|
|
1569
|
+
}
|
|
1570
|
+
if (i < lines.length) i++;
|
|
1571
|
+
blocks.push({ startLine, headerLines, bodyLines });
|
|
1572
|
+
}
|
|
1573
|
+
return blocks;
|
|
1574
|
+
}
|
|
1575
|
+
var TABLE_NAME_RE = /^[a-zA-Z0-9_]+$/;
|
|
1576
|
+
var FIELD_NAME_RE = /^[a-zA-Z0-9_]+$/;
|
|
1577
|
+
var KEY_VALUE_RE = /^([^:]+):\s*(.*)$/;
|
|
1578
|
+
function parseBlock(block) {
|
|
1579
|
+
const header = {};
|
|
1580
|
+
for (const line2 of block.headerLines) {
|
|
1581
|
+
const m = KEY_VALUE_RE.exec(line2);
|
|
1582
|
+
if (m) {
|
|
1583
|
+
header[m[1].trim()] = m[2].trim();
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
if (header["type"] !== "write") return null;
|
|
1587
|
+
const line = block.startLine;
|
|
1588
|
+
const timestamp = header["timestamp"];
|
|
1589
|
+
if (!timestamp) {
|
|
1590
|
+
return { error: { line, message: "Missing required field: timestamp" } };
|
|
1591
|
+
}
|
|
1592
|
+
const rawOp = header["op"];
|
|
1593
|
+
if (!rawOp) {
|
|
1594
|
+
return { error: { line, message: "Missing required field: op" } };
|
|
1595
|
+
}
|
|
1596
|
+
if (rawOp !== "create" && rawOp !== "update" && rawOp !== "delete") {
|
|
1597
|
+
return { error: { line, message: `Invalid op: "${rawOp}". Must be create, update, or delete` } };
|
|
1598
|
+
}
|
|
1599
|
+
const op = rawOp;
|
|
1600
|
+
const table = header["table"];
|
|
1601
|
+
if (!table) {
|
|
1602
|
+
return { error: { line, message: "Missing required field: table" } };
|
|
1603
|
+
}
|
|
1604
|
+
if (!TABLE_NAME_RE.test(table)) {
|
|
1605
|
+
return { error: { line, message: `Invalid table name: "${table}". Only [a-zA-Z0-9_] allowed` } };
|
|
1606
|
+
}
|
|
1607
|
+
const target = header["target"] || void 0;
|
|
1608
|
+
if ((op === "update" || op === "delete") && !target) {
|
|
1609
|
+
return { error: { line, message: `Field "target" is required for op "${op}"` } };
|
|
1610
|
+
}
|
|
1611
|
+
const reason = header["reason"] || void 0;
|
|
1612
|
+
const fields = {};
|
|
1613
|
+
if (op !== "delete") {
|
|
1614
|
+
for (const line2 of block.bodyLines) {
|
|
1615
|
+
const m = KEY_VALUE_RE.exec(line2);
|
|
1616
|
+
if (!m) continue;
|
|
1617
|
+
const key = m[1].trim();
|
|
1618
|
+
const value = m[2].trim();
|
|
1619
|
+
if (!FIELD_NAME_RE.test(key)) continue;
|
|
1620
|
+
fields[key] = value;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
const id = header["id"] ?? generateWriteEntryId(timestamp, "agent", op, table, target);
|
|
1624
|
+
const entry = {
|
|
1625
|
+
id,
|
|
1626
|
+
timestamp,
|
|
1627
|
+
op,
|
|
1628
|
+
table,
|
|
1629
|
+
fields,
|
|
1630
|
+
...target !== void 0 ? { target } : {},
|
|
1631
|
+
...reason !== void 0 ? { reason } : {}
|
|
1632
|
+
};
|
|
1633
|
+
return { entry };
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// src/session/entries.ts
|
|
1637
|
+
var import_node_crypto4 = require("crypto");
|
|
1638
|
+
var VALID_TYPES = /* @__PURE__ */ new Set([
|
|
1639
|
+
"event",
|
|
1640
|
+
"learning",
|
|
1641
|
+
"status",
|
|
1642
|
+
"correction",
|
|
1643
|
+
"discovery",
|
|
1644
|
+
"metric",
|
|
1645
|
+
"handoff",
|
|
1646
|
+
"write"
|
|
1647
|
+
]);
|
|
1648
|
+
var TYPE_ALIASES = {
|
|
1649
|
+
task_completion: "event",
|
|
1650
|
+
completion: "event",
|
|
1651
|
+
heartbeat: "status",
|
|
1652
|
+
bug: "discovery",
|
|
1653
|
+
fix: "event",
|
|
1654
|
+
deploy: "event",
|
|
1655
|
+
note: "event"
|
|
1656
|
+
};
|
|
1657
|
+
var FIELD_NAME_RE2 = /^[a-zA-Z0-9_]+$/;
|
|
1658
|
+
function parseSessionMD(content, startOffset = 0) {
|
|
1659
|
+
const entries = [];
|
|
1660
|
+
const errors = [];
|
|
1661
|
+
const text = content.slice(startOffset);
|
|
1662
|
+
const lines = text.split("\n");
|
|
1663
|
+
let i = 0;
|
|
1664
|
+
let currentByteOffset = startOffset;
|
|
1665
|
+
while (i < lines.length) {
|
|
1666
|
+
if (lines[i].trim() !== "---") {
|
|
1667
|
+
currentByteOffset += Buffer.byteLength(lines[i] + "\n", "utf-8");
|
|
1668
|
+
i++;
|
|
1669
|
+
continue;
|
|
1670
|
+
}
|
|
1671
|
+
const entryStartLine = i;
|
|
1672
|
+
currentByteOffset += Buffer.byteLength(lines[i] + "\n", "utf-8");
|
|
1673
|
+
i++;
|
|
1674
|
+
const headers = {};
|
|
1675
|
+
let foundHeaderClose = false;
|
|
1676
|
+
while (i < lines.length) {
|
|
1677
|
+
const line = lines[i];
|
|
1678
|
+
currentByteOffset += Buffer.byteLength(line + "\n", "utf-8");
|
|
1679
|
+
if (line.trim() === "---") {
|
|
1680
|
+
foundHeaderClose = true;
|
|
1681
|
+
i++;
|
|
1682
|
+
break;
|
|
1683
|
+
}
|
|
1684
|
+
const match = line.match(/^(\w+):\s*(.+)$/);
|
|
1685
|
+
if (match) {
|
|
1686
|
+
headers[match[1]] = match[2].trim();
|
|
1687
|
+
}
|
|
1688
|
+
i++;
|
|
1689
|
+
}
|
|
1690
|
+
if (!foundHeaderClose) {
|
|
1691
|
+
errors.push({ line: entryStartLine + 1, message: "Entry header never closed (missing ---)" });
|
|
1692
|
+
break;
|
|
1693
|
+
}
|
|
1694
|
+
const bodyLines = [];
|
|
1695
|
+
while (i < lines.length) {
|
|
1696
|
+
const line = lines[i];
|
|
1697
|
+
if (line.trim() === "===") {
|
|
1698
|
+
currentByteOffset += Buffer.byteLength(line + "\n", "utf-8");
|
|
1699
|
+
i++;
|
|
1700
|
+
break;
|
|
1701
|
+
}
|
|
1702
|
+
if (line.trim() === "---" && bodyLines.length > 0) {
|
|
1703
|
+
const nextLine = lines[i + 1];
|
|
1704
|
+
if (nextLine && /^\w+:\s*.+$/.test(nextLine)) {
|
|
1705
|
+
break;
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
bodyLines.push(line);
|
|
1709
|
+
currentByteOffset += Buffer.byteLength(line + "\n", "utf-8");
|
|
1710
|
+
i++;
|
|
1711
|
+
}
|
|
1712
|
+
const body = bodyLines.join("\n").trim();
|
|
1713
|
+
const rawType = headers["type"] ?? "";
|
|
1714
|
+
const resolvedType = normalizeType(rawType);
|
|
1715
|
+
if (!resolvedType) {
|
|
1716
|
+
errors.push({ line: entryStartLine + 1, message: `Unknown entry type: ${rawType}` });
|
|
1717
|
+
continue;
|
|
1718
|
+
}
|
|
1719
|
+
if (!headers["timestamp"]) {
|
|
1720
|
+
errors.push({ line: entryStartLine + 1, message: "Missing required header: timestamp" });
|
|
1721
|
+
continue;
|
|
1722
|
+
}
|
|
1723
|
+
if (!body) {
|
|
1724
|
+
errors.push({ line: entryStartLine + 1, message: "Entry has empty body" });
|
|
1725
|
+
continue;
|
|
1726
|
+
}
|
|
1727
|
+
const entryId = headers["id"] ?? generateEntryId(headers["timestamp"], "agent", body);
|
|
1728
|
+
let tags;
|
|
1729
|
+
if (headers["tags"]) {
|
|
1730
|
+
const tagMatch = headers["tags"].match(/^\[(.+)\]$/);
|
|
1731
|
+
if (tagMatch) {
|
|
1732
|
+
tags = tagMatch[1].split(",").map((t) => t.trim());
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
let writeFields;
|
|
1736
|
+
if (resolvedType === "write" && headers["op"] !== "delete") {
|
|
1737
|
+
writeFields = {};
|
|
1738
|
+
for (const line of body.split("\n")) {
|
|
1739
|
+
const m = line.match(/^([^:]+):\s*(.*)$/);
|
|
1740
|
+
if (!m) continue;
|
|
1741
|
+
const key = m[1].trim();
|
|
1742
|
+
if (!FIELD_NAME_RE2.test(key)) continue;
|
|
1743
|
+
writeFields[key] = m[2].trim();
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
const newEntry = { id: entryId, type: resolvedType, timestamp: headers["timestamp"], body };
|
|
1747
|
+
if (headers["project"]) newEntry.project = headers["project"];
|
|
1748
|
+
if (headers["task"]) newEntry.task = headers["task"];
|
|
1749
|
+
if (tags) newEntry.tags = tags;
|
|
1750
|
+
if (headers["severity"]) newEntry.severity = headers["severity"];
|
|
1751
|
+
if (headers["target_agent"]) newEntry.target_agent = headers["target_agent"];
|
|
1752
|
+
if (headers["target_table"]) newEntry.target_table = headers["target_table"];
|
|
1753
|
+
if (resolvedType === "write") {
|
|
1754
|
+
if (headers["op"]) newEntry.op = headers["op"];
|
|
1755
|
+
if (headers["table"]) newEntry.table = headers["table"];
|
|
1756
|
+
if (headers["target"]) newEntry.target = headers["target"];
|
|
1757
|
+
if (headers["reason"]) newEntry.reason = headers["reason"];
|
|
1758
|
+
newEntry.fields = writeFields ?? {};
|
|
1759
|
+
}
|
|
1760
|
+
entries.push(newEntry);
|
|
1761
|
+
}
|
|
1762
|
+
return { entries, errors, lastOffset: currentByteOffset };
|
|
1763
|
+
}
|
|
1764
|
+
function parseMarkdownEntries(content, agentName, startOffset = 0) {
|
|
1765
|
+
const entries = [];
|
|
1766
|
+
const errors = [];
|
|
1767
|
+
const text = content.slice(startOffset);
|
|
1768
|
+
const lines = text.split("\n");
|
|
1769
|
+
const headingPattern = /^##\s+([\dT:.Z-]{10,})\s*(?:[—–\-]{1,2}\s*(.+))?$/;
|
|
1770
|
+
let currentByteOffset = startOffset;
|
|
1771
|
+
const entryStarts = [];
|
|
1772
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1773
|
+
const match = lines[i].match(headingPattern);
|
|
1774
|
+
if (match) {
|
|
1775
|
+
entryStarts.push({
|
|
1776
|
+
lineIdx: i,
|
|
1777
|
+
timestamp: match[1],
|
|
1778
|
+
headingType: (match[2] ?? "").trim(),
|
|
1779
|
+
offset: currentByteOffset
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
currentByteOffset += Buffer.byteLength(lines[i] + "\n", "utf-8");
|
|
1783
|
+
}
|
|
1784
|
+
for (let e = 0; e < entryStarts.length; e++) {
|
|
1785
|
+
const start = entryStarts[e];
|
|
1786
|
+
const nextStart = entryStarts[e + 1];
|
|
1787
|
+
const bodyStartLine = start.lineIdx + 1;
|
|
1788
|
+
const bodyEndLine = nextStart ? nextStart.lineIdx : lines.length;
|
|
1789
|
+
const bodyLines = lines.slice(bodyStartLine, bodyEndLine);
|
|
1790
|
+
let bodyType = null;
|
|
1791
|
+
const filteredBody = [];
|
|
1792
|
+
for (const line of bodyLines) {
|
|
1793
|
+
const typeMatch = line.match(/^\*\*type:\*\*\s*(.+)/i);
|
|
1794
|
+
if (typeMatch && !bodyType) {
|
|
1795
|
+
bodyType = typeMatch[1].trim();
|
|
1796
|
+
} else {
|
|
1797
|
+
filteredBody.push(line);
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
const body = filteredBody.join("\n").trim();
|
|
1801
|
+
if (!body) {
|
|
1802
|
+
errors.push({ line: start.lineIdx + 1, message: "Markdown entry has empty body" });
|
|
1803
|
+
continue;
|
|
1804
|
+
}
|
|
1805
|
+
const rawType = bodyType ?? start.headingType ?? "event";
|
|
1806
|
+
const resolvedType = normalizeType(rawType) ?? "event";
|
|
1807
|
+
const id = generateEntryId(start.timestamp, agentName, body);
|
|
1808
|
+
entries.push({
|
|
1809
|
+
id,
|
|
1810
|
+
type: resolvedType,
|
|
1811
|
+
timestamp: start.timestamp,
|
|
1812
|
+
body
|
|
1813
|
+
});
|
|
1814
|
+
}
|
|
1815
|
+
return { entries, errors, lastOffset: currentByteOffset };
|
|
1816
|
+
}
|
|
1817
|
+
function generateEntryId(timestamp, agentName, body) {
|
|
1818
|
+
const hash = (0, import_node_crypto4.createHash)("sha256").update(body).digest("hex").slice(0, 6);
|
|
1819
|
+
return `${timestamp}-${agentName.toLowerCase()}-${hash}`;
|
|
1820
|
+
}
|
|
1821
|
+
function validateEntryId(id, body) {
|
|
1822
|
+
const parts = id.split("-");
|
|
1823
|
+
if (parts.length < 4) return false;
|
|
1824
|
+
const hash = parts[parts.length - 1];
|
|
1825
|
+
if (hash.length !== 6) return false;
|
|
1826
|
+
const expectedHash = (0, import_node_crypto4.createHash)("sha256").update(body).digest("hex").slice(0, 6);
|
|
1827
|
+
return hash === expectedHash;
|
|
1828
|
+
}
|
|
1829
|
+
function normalizeType(raw) {
|
|
1830
|
+
const lower = raw.toLowerCase().trim();
|
|
1831
|
+
if (VALID_TYPES.has(lower)) return lower;
|
|
1832
|
+
const normalized = lower.replace(/-/g, "_");
|
|
1833
|
+
if (TYPE_ALIASES[normalized]) return TYPE_ALIASES[normalized];
|
|
1834
|
+
for (const alias of Object.keys(TYPE_ALIASES)) {
|
|
1835
|
+
if (normalized.startsWith(alias)) return TYPE_ALIASES[alias];
|
|
1836
|
+
}
|
|
1837
|
+
return null;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// src/session/apply.ts
|
|
1841
|
+
var TABLE_NAME_RE2 = /^[a-zA-Z0-9_]+$/;
|
|
1842
|
+
var FIELD_NAME_RE3 = /^[a-zA-Z0-9_]+$/;
|
|
1843
|
+
function applyWriteEntry(db, entry) {
|
|
1844
|
+
const { op, table, target, fields } = entry;
|
|
1845
|
+
if (!TABLE_NAME_RE2.test(table)) {
|
|
1846
|
+
return { ok: false, reason: `Invalid table name: "${table}". Only [a-zA-Z0-9_] allowed` };
|
|
1847
|
+
}
|
|
1848
|
+
const tableExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(table);
|
|
1849
|
+
if (!tableExists) {
|
|
1850
|
+
return { ok: false, reason: `Unknown table: "${table}"` };
|
|
1851
|
+
}
|
|
1852
|
+
const columnRows = db.prepare(`PRAGMA table_info("${table}")`).all();
|
|
1853
|
+
const knownColumns = new Set(columnRows.map((r) => r.name));
|
|
1854
|
+
for (const fieldName of Object.keys(fields)) {
|
|
1855
|
+
if (!FIELD_NAME_RE3.test(fieldName)) {
|
|
1856
|
+
return { ok: false, reason: `Invalid field name: "${fieldName}". Only [a-zA-Z0-9_] allowed` };
|
|
1857
|
+
}
|
|
1858
|
+
if (!knownColumns.has(fieldName)) {
|
|
1859
|
+
return { ok: false, reason: `Unknown field "${fieldName}" in table "${table}"` };
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
try {
|
|
1863
|
+
let recordId;
|
|
1864
|
+
if (op === "create") {
|
|
1865
|
+
const id = fields["id"] ?? crypto.randomUUID();
|
|
1866
|
+
const allFields = { ...fields, id };
|
|
1867
|
+
const cols = Object.keys(allFields).map((c) => `"${c}"`).join(", ");
|
|
1868
|
+
const placeholders = Object.keys(allFields).map(() => "?").join(", ");
|
|
1869
|
+
db.prepare(`INSERT INTO "${table}" (${cols}) VALUES (${placeholders})`).run(...Object.values(allFields));
|
|
1870
|
+
recordId = id;
|
|
1871
|
+
} else if (op === "update") {
|
|
1872
|
+
if (!target) {
|
|
1873
|
+
return { ok: false, reason: 'Field "target" is required for op "update"' };
|
|
1874
|
+
}
|
|
1875
|
+
const pkCol = columnRows.find((r) => r.name === "id") ? "id" : columnRows[0]?.name ?? "id";
|
|
1876
|
+
const setCols = Object.keys(fields).map((c) => `"${c}" = ?`).join(", ");
|
|
1877
|
+
db.prepare(`UPDATE "${table}" SET ${setCols} WHERE "${pkCol}" = ?`).run(...Object.values(fields), target);
|
|
1878
|
+
recordId = target;
|
|
1879
|
+
} else {
|
|
1880
|
+
if (!target) {
|
|
1881
|
+
return { ok: false, reason: 'Field "target" is required for op "delete"' };
|
|
1882
|
+
}
|
|
1883
|
+
const pkCol = columnRows.find((r) => r.name === "id") ? "id" : columnRows[0]?.name ?? "id";
|
|
1884
|
+
if (knownColumns.has("deleted_at")) {
|
|
1885
|
+
db.prepare(`UPDATE "${table}" SET deleted_at = datetime('now') WHERE "${pkCol}" = ?`).run(target);
|
|
1886
|
+
} else {
|
|
1887
|
+
db.prepare(`DELETE FROM "${table}" WHERE "${pkCol}" = ?`).run(target);
|
|
1888
|
+
}
|
|
1889
|
+
recordId = target;
|
|
1890
|
+
}
|
|
1891
|
+
return { ok: true, table, recordId };
|
|
1892
|
+
} catch (err) {
|
|
1893
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1894
|
+
return { ok: false, reason: `DB error: ${message}` };
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
// src/session/constants.ts
|
|
1899
|
+
var READ_ONLY_HEADER = `<!-- READ ONLY \u2014 generated by lattice-sync. Do not edit directly.
|
|
1900
|
+
To update data in Lattice: write entries to SESSION.md in this directory.
|
|
1901
|
+
Format: type: write | op: create/update/delete | table: <name> | target: <id>
|
|
1902
|
+
See agents/shared/SESSION-FORMAT.md for the full spec. -->
|
|
1903
|
+
|
|
1904
|
+
`;
|
|
1506
1905
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1507
1906
|
0 && (module.exports = {
|
|
1508
1907
|
Lattice,
|
|
1908
|
+
READ_ONLY_HEADER,
|
|
1909
|
+
applyWriteEntry,
|
|
1910
|
+
generateEntryId,
|
|
1911
|
+
generateWriteEntryId,
|
|
1509
1912
|
manifestPath,
|
|
1510
1913
|
parseConfigFile,
|
|
1511
1914
|
parseConfigString,
|
|
1915
|
+
parseMarkdownEntries,
|
|
1916
|
+
parseSessionMD,
|
|
1917
|
+
parseSessionWrites,
|
|
1512
1918
|
readManifest,
|
|
1919
|
+
validateEntryId,
|
|
1513
1920
|
writeManifest
|
|
1514
1921
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -866,4 +866,134 @@ declare function parseConfigFile(configPath: string): ParsedConfig;
|
|
|
866
866
|
*/
|
|
867
867
|
declare function parseConfigString(yamlContent: string, configDir: string): ParsedConfig;
|
|
868
868
|
|
|
869
|
-
|
|
869
|
+
type SessionWriteOp = 'create' | 'update' | 'delete';
|
|
870
|
+
interface SessionWriteEntry {
|
|
871
|
+
id: string;
|
|
872
|
+
timestamp: string;
|
|
873
|
+
op: SessionWriteOp;
|
|
874
|
+
table: string;
|
|
875
|
+
target?: string;
|
|
876
|
+
reason?: string;
|
|
877
|
+
fields: Record<string, string>;
|
|
878
|
+
}
|
|
879
|
+
interface SessionWriteParseResult {
|
|
880
|
+
entries: SessionWriteEntry[];
|
|
881
|
+
errors: Array<{
|
|
882
|
+
line: number;
|
|
883
|
+
message: string;
|
|
884
|
+
}>;
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Generate a deterministic entry ID from the given parameters.
|
|
888
|
+
* Uses sha256 of `{op}:{table}:{target ?? ''}:{timestamp}` with the format
|
|
889
|
+
* `{timestamp}-{agentName}-{6-char-hash}`.
|
|
890
|
+
*/
|
|
891
|
+
declare function generateWriteEntryId(timestamp: string, agentName: string, op: string, table: string, target?: string): string;
|
|
892
|
+
/**
|
|
893
|
+
* Parse all `type: write` entries from a SESSION.md file.
|
|
894
|
+
* Non-write entries are silently skipped.
|
|
895
|
+
*/
|
|
896
|
+
declare function parseSessionWrites(content: string): SessionWriteParseResult;
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* A single parsed SESSION.md entry. Covers all entry types:
|
|
900
|
+
* event, learning, status, correction, discovery, metric, handoff, write.
|
|
901
|
+
*
|
|
902
|
+
* When `type === 'write'`, the op/table/target/reason/fields fields are set.
|
|
903
|
+
*/
|
|
904
|
+
interface SessionEntry {
|
|
905
|
+
id: string;
|
|
906
|
+
type: string;
|
|
907
|
+
timestamp: string;
|
|
908
|
+
body: string;
|
|
909
|
+
project?: string;
|
|
910
|
+
task?: string;
|
|
911
|
+
tags?: string[];
|
|
912
|
+
severity?: string;
|
|
913
|
+
target_agent?: string;
|
|
914
|
+
target_table?: string;
|
|
915
|
+
/** Only present when type === 'write' */
|
|
916
|
+
op?: 'create' | 'update' | 'delete';
|
|
917
|
+
table?: string;
|
|
918
|
+
target?: string;
|
|
919
|
+
reason?: string;
|
|
920
|
+
fields?: Record<string, string>;
|
|
921
|
+
}
|
|
922
|
+
interface ParseError {
|
|
923
|
+
line: number;
|
|
924
|
+
message: string;
|
|
925
|
+
}
|
|
926
|
+
interface ParseResult {
|
|
927
|
+
entries: SessionEntry[];
|
|
928
|
+
errors: ParseError[];
|
|
929
|
+
/** Byte offset after the last fully parsed entry — used for incremental parsing. */
|
|
930
|
+
lastOffset: number;
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Parse SESSION.md YAML-delimited entries starting at `startOffset` bytes.
|
|
934
|
+
*
|
|
935
|
+
* Each entry looks like:
|
|
936
|
+
* ```
|
|
937
|
+
* ---
|
|
938
|
+
* id: 2026-03-12T15:30:42Z-cortex-a1b2c3
|
|
939
|
+
* type: event
|
|
940
|
+
* timestamp: 2026-03-12T15:30:42Z
|
|
941
|
+
* ---
|
|
942
|
+
* Entry body text here.
|
|
943
|
+
* ===
|
|
944
|
+
* ```
|
|
945
|
+
*/
|
|
946
|
+
declare function parseSessionMD(content: string, startOffset?: number): ParseResult;
|
|
947
|
+
/**
|
|
948
|
+
* Parse free-form Markdown SESSION.md entries. Agents sometimes write entries
|
|
949
|
+
* as `## {timestamp} — {description}` headings rather than YAML blocks.
|
|
950
|
+
*
|
|
951
|
+
* Runs alongside `parseSessionMD`; the two parsers are merged by caller.
|
|
952
|
+
*/
|
|
953
|
+
declare function parseMarkdownEntries(content: string, agentName: string, startOffset?: number): ParseResult;
|
|
954
|
+
/**
|
|
955
|
+
* Generate a content-addressed entry ID.
|
|
956
|
+
* Format: `{timestamp}-{agentName}-{6-char-sha256-prefix}`
|
|
957
|
+
*/
|
|
958
|
+
declare function generateEntryId(timestamp: string, agentName: string, body: string): string;
|
|
959
|
+
/**
|
|
960
|
+
* Validate that an entry ID's hash suffix matches its body.
|
|
961
|
+
*/
|
|
962
|
+
declare function validateEntryId(id: string, body: string): boolean;
|
|
963
|
+
|
|
964
|
+
type ApplyWriteResult = {
|
|
965
|
+
ok: true;
|
|
966
|
+
table: string;
|
|
967
|
+
recordId: string;
|
|
968
|
+
} | {
|
|
969
|
+
ok: false;
|
|
970
|
+
reason: string;
|
|
971
|
+
};
|
|
972
|
+
/**
|
|
973
|
+
* Apply a SESSION.md write entry to a better-sqlite3 database.
|
|
974
|
+
*
|
|
975
|
+
* Validates:
|
|
976
|
+
* - Table and field names match `[a-zA-Z0-9_]` (SQL injection prevention)
|
|
977
|
+
* - Table exists in `sqlite_master`
|
|
978
|
+
* - All field names are present in the table schema (`PRAGMA table_info`)
|
|
979
|
+
* - `target` is provided for `update` and `delete` ops
|
|
980
|
+
*
|
|
981
|
+
* For `delete`: uses a soft-delete (`deleted_at = datetime('now')`) if the
|
|
982
|
+
* column exists, otherwise performs a hard `DELETE`.
|
|
983
|
+
*
|
|
984
|
+
* Returns `{ ok: true, table, recordId }` on success, or
|
|
985
|
+
* `{ ok: false, reason }` if validation or the DB operation fails.
|
|
986
|
+
* The caller is responsible for logging and audit events.
|
|
987
|
+
*/
|
|
988
|
+
declare function applyWriteEntry(db: Database.Database, entry: SessionWriteEntry): ApplyWriteResult;
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Read-only header prepended to all Lattice-generated context files.
|
|
992
|
+
*
|
|
993
|
+
* Directs agents to SESSION.md for writes rather than editing generated files
|
|
994
|
+
* directly. Include at the top of every rendered markdown context file so
|
|
995
|
+
* agents see it at the start of their context window.
|
|
996
|
+
*/
|
|
997
|
+
declare const READ_ONLY_HEADER = "<!-- READ ONLY \u2014 generated by lattice-sync. Do not edit directly.\n To update data in Lattice: write entries to SESSION.md in this directory.\n Format: type: write | op: create/update/delete | table: <name> | target: <id>\n See agents/shared/SESSION-FORMAT.md for the full spec. -->\n\n";
|
|
998
|
+
|
|
999
|
+
export { type ApplyWriteResult, type AuditEvent, type BelongsToRelation, type BelongsToSource, type BuiltinTemplateName, type CleanupOptions, type CleanupResult, type CountOptions, type CustomSource, type EntityContextDefinition, type EntityContextManifestEntry, type EntityFileSource, type EntityFileSpec, type Filter, type FilterOp, type HasManyRelation, type HasManySource, type InitOptions, Lattice, type LatticeConfig, type LatticeConfigInput, type LatticeEntityDef, type LatticeEntityRenderSpec, type LatticeFieldDef, type LatticeFieldType, type LatticeManifest, type LatticeOptions, type ManyToManySource, type Migration, type MultiTableDefinition, type ParseError, type ParseResult, type ParsedConfig, type PkLookup, type PrimaryKey, type QueryOptions, READ_ONLY_HEADER, type ReconcileOptions, type ReconcileResult, type Relation, type RenderHooks, type RenderResult, type RenderSpec, type Row, type SecurityOptions, type SelfSource, type SessionEntry, type SessionWriteEntry, type SessionWriteOp, type SessionWriteParseResult, type StopFn, type SyncResult, type TableDefinition, type TemplateRenderSpec, type WatchOptions, type WritebackDefinition, applyWriteEntry, generateEntryId, generateWriteEntryId, manifestPath, parseConfigFile, parseConfigString, parseMarkdownEntries, parseSessionMD, parseSessionWrites, readManifest, validateEntryId, writeManifest };
|
package/dist/index.d.ts
CHANGED
|
@@ -866,4 +866,134 @@ declare function parseConfigFile(configPath: string): ParsedConfig;
|
|
|
866
866
|
*/
|
|
867
867
|
declare function parseConfigString(yamlContent: string, configDir: string): ParsedConfig;
|
|
868
868
|
|
|
869
|
-
|
|
869
|
+
type SessionWriteOp = 'create' | 'update' | 'delete';
|
|
870
|
+
interface SessionWriteEntry {
|
|
871
|
+
id: string;
|
|
872
|
+
timestamp: string;
|
|
873
|
+
op: SessionWriteOp;
|
|
874
|
+
table: string;
|
|
875
|
+
target?: string;
|
|
876
|
+
reason?: string;
|
|
877
|
+
fields: Record<string, string>;
|
|
878
|
+
}
|
|
879
|
+
interface SessionWriteParseResult {
|
|
880
|
+
entries: SessionWriteEntry[];
|
|
881
|
+
errors: Array<{
|
|
882
|
+
line: number;
|
|
883
|
+
message: string;
|
|
884
|
+
}>;
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Generate a deterministic entry ID from the given parameters.
|
|
888
|
+
* Uses sha256 of `{op}:{table}:{target ?? ''}:{timestamp}` with the format
|
|
889
|
+
* `{timestamp}-{agentName}-{6-char-hash}`.
|
|
890
|
+
*/
|
|
891
|
+
declare function generateWriteEntryId(timestamp: string, agentName: string, op: string, table: string, target?: string): string;
|
|
892
|
+
/**
|
|
893
|
+
* Parse all `type: write` entries from a SESSION.md file.
|
|
894
|
+
* Non-write entries are silently skipped.
|
|
895
|
+
*/
|
|
896
|
+
declare function parseSessionWrites(content: string): SessionWriteParseResult;
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* A single parsed SESSION.md entry. Covers all entry types:
|
|
900
|
+
* event, learning, status, correction, discovery, metric, handoff, write.
|
|
901
|
+
*
|
|
902
|
+
* When `type === 'write'`, the op/table/target/reason/fields fields are set.
|
|
903
|
+
*/
|
|
904
|
+
interface SessionEntry {
|
|
905
|
+
id: string;
|
|
906
|
+
type: string;
|
|
907
|
+
timestamp: string;
|
|
908
|
+
body: string;
|
|
909
|
+
project?: string;
|
|
910
|
+
task?: string;
|
|
911
|
+
tags?: string[];
|
|
912
|
+
severity?: string;
|
|
913
|
+
target_agent?: string;
|
|
914
|
+
target_table?: string;
|
|
915
|
+
/** Only present when type === 'write' */
|
|
916
|
+
op?: 'create' | 'update' | 'delete';
|
|
917
|
+
table?: string;
|
|
918
|
+
target?: string;
|
|
919
|
+
reason?: string;
|
|
920
|
+
fields?: Record<string, string>;
|
|
921
|
+
}
|
|
922
|
+
interface ParseError {
|
|
923
|
+
line: number;
|
|
924
|
+
message: string;
|
|
925
|
+
}
|
|
926
|
+
interface ParseResult {
|
|
927
|
+
entries: SessionEntry[];
|
|
928
|
+
errors: ParseError[];
|
|
929
|
+
/** Byte offset after the last fully parsed entry — used for incremental parsing. */
|
|
930
|
+
lastOffset: number;
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Parse SESSION.md YAML-delimited entries starting at `startOffset` bytes.
|
|
934
|
+
*
|
|
935
|
+
* Each entry looks like:
|
|
936
|
+
* ```
|
|
937
|
+
* ---
|
|
938
|
+
* id: 2026-03-12T15:30:42Z-cortex-a1b2c3
|
|
939
|
+
* type: event
|
|
940
|
+
* timestamp: 2026-03-12T15:30:42Z
|
|
941
|
+
* ---
|
|
942
|
+
* Entry body text here.
|
|
943
|
+
* ===
|
|
944
|
+
* ```
|
|
945
|
+
*/
|
|
946
|
+
declare function parseSessionMD(content: string, startOffset?: number): ParseResult;
|
|
947
|
+
/**
|
|
948
|
+
* Parse free-form Markdown SESSION.md entries. Agents sometimes write entries
|
|
949
|
+
* as `## {timestamp} — {description}` headings rather than YAML blocks.
|
|
950
|
+
*
|
|
951
|
+
* Runs alongside `parseSessionMD`; the two parsers are merged by caller.
|
|
952
|
+
*/
|
|
953
|
+
declare function parseMarkdownEntries(content: string, agentName: string, startOffset?: number): ParseResult;
|
|
954
|
+
/**
|
|
955
|
+
* Generate a content-addressed entry ID.
|
|
956
|
+
* Format: `{timestamp}-{agentName}-{6-char-sha256-prefix}`
|
|
957
|
+
*/
|
|
958
|
+
declare function generateEntryId(timestamp: string, agentName: string, body: string): string;
|
|
959
|
+
/**
|
|
960
|
+
* Validate that an entry ID's hash suffix matches its body.
|
|
961
|
+
*/
|
|
962
|
+
declare function validateEntryId(id: string, body: string): boolean;
|
|
963
|
+
|
|
964
|
+
type ApplyWriteResult = {
|
|
965
|
+
ok: true;
|
|
966
|
+
table: string;
|
|
967
|
+
recordId: string;
|
|
968
|
+
} | {
|
|
969
|
+
ok: false;
|
|
970
|
+
reason: string;
|
|
971
|
+
};
|
|
972
|
+
/**
|
|
973
|
+
* Apply a SESSION.md write entry to a better-sqlite3 database.
|
|
974
|
+
*
|
|
975
|
+
* Validates:
|
|
976
|
+
* - Table and field names match `[a-zA-Z0-9_]` (SQL injection prevention)
|
|
977
|
+
* - Table exists in `sqlite_master`
|
|
978
|
+
* - All field names are present in the table schema (`PRAGMA table_info`)
|
|
979
|
+
* - `target` is provided for `update` and `delete` ops
|
|
980
|
+
*
|
|
981
|
+
* For `delete`: uses a soft-delete (`deleted_at = datetime('now')`) if the
|
|
982
|
+
* column exists, otherwise performs a hard `DELETE`.
|
|
983
|
+
*
|
|
984
|
+
* Returns `{ ok: true, table, recordId }` on success, or
|
|
985
|
+
* `{ ok: false, reason }` if validation or the DB operation fails.
|
|
986
|
+
* The caller is responsible for logging and audit events.
|
|
987
|
+
*/
|
|
988
|
+
declare function applyWriteEntry(db: Database.Database, entry: SessionWriteEntry): ApplyWriteResult;
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Read-only header prepended to all Lattice-generated context files.
|
|
992
|
+
*
|
|
993
|
+
* Directs agents to SESSION.md for writes rather than editing generated files
|
|
994
|
+
* directly. Include at the top of every rendered markdown context file so
|
|
995
|
+
* agents see it at the start of their context window.
|
|
996
|
+
*/
|
|
997
|
+
declare const READ_ONLY_HEADER = "<!-- READ ONLY \u2014 generated by lattice-sync. Do not edit directly.\n To update data in Lattice: write entries to SESSION.md in this directory.\n Format: type: write | op: create/update/delete | table: <name> | target: <id>\n See agents/shared/SESSION-FORMAT.md for the full spec. -->\n\n";
|
|
998
|
+
|
|
999
|
+
export { type ApplyWriteResult, type AuditEvent, type BelongsToRelation, type BelongsToSource, type BuiltinTemplateName, type CleanupOptions, type CleanupResult, type CountOptions, type CustomSource, type EntityContextDefinition, type EntityContextManifestEntry, type EntityFileSource, type EntityFileSpec, type Filter, type FilterOp, type HasManyRelation, type HasManySource, type InitOptions, Lattice, type LatticeConfig, type LatticeConfigInput, type LatticeEntityDef, type LatticeEntityRenderSpec, type LatticeFieldDef, type LatticeFieldType, type LatticeManifest, type LatticeOptions, type ManyToManySource, type Migration, type MultiTableDefinition, type ParseError, type ParseResult, type ParsedConfig, type PkLookup, type PrimaryKey, type QueryOptions, READ_ONLY_HEADER, type ReconcileOptions, type ReconcileResult, type Relation, type RenderHooks, type RenderResult, type RenderSpec, type Row, type SecurityOptions, type SelfSource, type SessionEntry, type SessionWriteEntry, type SessionWriteOp, type SessionWriteParseResult, type StopFn, type SyncResult, type TableDefinition, type TemplateRenderSpec, type WatchOptions, type WritebackDefinition, applyWriteEntry, generateEntryId, generateWriteEntryId, manifestPath, parseConfigFile, parseConfigString, parseMarkdownEntries, parseSessionMD, parseSessionWrites, readManifest, validateEntryId, writeManifest };
|
package/dist/index.js
CHANGED
|
@@ -189,12 +189,22 @@ var SchemaManager = class {
|
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
}
|
|
192
|
-
/**
|
|
192
|
+
/**
|
|
193
|
+
* Query all rows from a table.
|
|
194
|
+
* Registered tables (via `define()`) are queried directly.
|
|
195
|
+
* Tables used only in entity contexts (schema managed externally) fall back
|
|
196
|
+
* to a raw SELECT with optional `deleted_at IS NULL` soft-delete filtering.
|
|
197
|
+
*/
|
|
193
198
|
queryTable(adapter, name) {
|
|
194
|
-
if (
|
|
195
|
-
|
|
199
|
+
if (this._tables.has(name)) {
|
|
200
|
+
return adapter.all(`SELECT * FROM "${name}"`);
|
|
201
|
+
}
|
|
202
|
+
if (this._entityContexts.has(name)) {
|
|
203
|
+
const cols = adapter.all(`PRAGMA table_info("${name}")`);
|
|
204
|
+
const hasDeletedAt = cols.some((c) => c.name === "deleted_at");
|
|
205
|
+
return adapter.all(`SELECT * FROM "${name}"${hasDeletedAt ? " WHERE deleted_at IS NULL" : ""}`);
|
|
196
206
|
}
|
|
197
|
-
|
|
207
|
+
throw new Error(`Unknown table: "${name}"`);
|
|
198
208
|
}
|
|
199
209
|
_ensureTable(adapter, name, columns, tableConstraints) {
|
|
200
210
|
const colDefs = Object.entries(columns).map(([col, type]) => `"${col}" ${type}`).join(", ");
|
|
@@ -1462,11 +1472,400 @@ var Lattice = class {
|
|
|
1462
1472
|
}
|
|
1463
1473
|
}
|
|
1464
1474
|
};
|
|
1475
|
+
|
|
1476
|
+
// src/session/parser.ts
|
|
1477
|
+
import { createHash as createHash2 } from "crypto";
|
|
1478
|
+
function generateWriteEntryId(timestamp, agentName, op, table, target) {
|
|
1479
|
+
const payload = `${op}:${table}:${target ?? ""}:${timestamp}`;
|
|
1480
|
+
const hash = createHash2("sha256").update(payload).digest("hex").slice(0, 6);
|
|
1481
|
+
return `${timestamp}-${agentName}-${hash}`;
|
|
1482
|
+
}
|
|
1483
|
+
function parseSessionWrites(content) {
|
|
1484
|
+
const entries = [];
|
|
1485
|
+
const errors = [];
|
|
1486
|
+
const blocks = splitIntoBlocks(content);
|
|
1487
|
+
for (const block of blocks) {
|
|
1488
|
+
const result = parseBlock(block);
|
|
1489
|
+
if (result === null) continue;
|
|
1490
|
+
if ("error" in result) {
|
|
1491
|
+
errors.push(result.error);
|
|
1492
|
+
} else {
|
|
1493
|
+
entries.push(result.entry);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
return { entries, errors };
|
|
1497
|
+
}
|
|
1498
|
+
function splitIntoBlocks(content) {
|
|
1499
|
+
const lines = content.split("\n");
|
|
1500
|
+
const blocks = [];
|
|
1501
|
+
let i = 0;
|
|
1502
|
+
while (i < lines.length) {
|
|
1503
|
+
while (i < lines.length && lines[i]?.trim() !== "---") {
|
|
1504
|
+
i++;
|
|
1505
|
+
}
|
|
1506
|
+
if (i >= lines.length) break;
|
|
1507
|
+
const startLine = i + 1;
|
|
1508
|
+
i++;
|
|
1509
|
+
const headerLines = [];
|
|
1510
|
+
while (i < lines.length && lines[i]?.trim() !== "---") {
|
|
1511
|
+
headerLines.push(lines[i] ?? "");
|
|
1512
|
+
i++;
|
|
1513
|
+
}
|
|
1514
|
+
if (i >= lines.length) break;
|
|
1515
|
+
i++;
|
|
1516
|
+
const bodyLines = [];
|
|
1517
|
+
while (i < lines.length && lines[i]?.trim() !== "===") {
|
|
1518
|
+
bodyLines.push(lines[i] ?? "");
|
|
1519
|
+
i++;
|
|
1520
|
+
}
|
|
1521
|
+
if (i < lines.length) i++;
|
|
1522
|
+
blocks.push({ startLine, headerLines, bodyLines });
|
|
1523
|
+
}
|
|
1524
|
+
return blocks;
|
|
1525
|
+
}
|
|
1526
|
+
var TABLE_NAME_RE = /^[a-zA-Z0-9_]+$/;
|
|
1527
|
+
var FIELD_NAME_RE = /^[a-zA-Z0-9_]+$/;
|
|
1528
|
+
var KEY_VALUE_RE = /^([^:]+):\s*(.*)$/;
|
|
1529
|
+
function parseBlock(block) {
|
|
1530
|
+
const header = {};
|
|
1531
|
+
for (const line2 of block.headerLines) {
|
|
1532
|
+
const m = KEY_VALUE_RE.exec(line2);
|
|
1533
|
+
if (m) {
|
|
1534
|
+
header[m[1].trim()] = m[2].trim();
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
if (header["type"] !== "write") return null;
|
|
1538
|
+
const line = block.startLine;
|
|
1539
|
+
const timestamp = header["timestamp"];
|
|
1540
|
+
if (!timestamp) {
|
|
1541
|
+
return { error: { line, message: "Missing required field: timestamp" } };
|
|
1542
|
+
}
|
|
1543
|
+
const rawOp = header["op"];
|
|
1544
|
+
if (!rawOp) {
|
|
1545
|
+
return { error: { line, message: "Missing required field: op" } };
|
|
1546
|
+
}
|
|
1547
|
+
if (rawOp !== "create" && rawOp !== "update" && rawOp !== "delete") {
|
|
1548
|
+
return { error: { line, message: `Invalid op: "${rawOp}". Must be create, update, or delete` } };
|
|
1549
|
+
}
|
|
1550
|
+
const op = rawOp;
|
|
1551
|
+
const table = header["table"];
|
|
1552
|
+
if (!table) {
|
|
1553
|
+
return { error: { line, message: "Missing required field: table" } };
|
|
1554
|
+
}
|
|
1555
|
+
if (!TABLE_NAME_RE.test(table)) {
|
|
1556
|
+
return { error: { line, message: `Invalid table name: "${table}". Only [a-zA-Z0-9_] allowed` } };
|
|
1557
|
+
}
|
|
1558
|
+
const target = header["target"] || void 0;
|
|
1559
|
+
if ((op === "update" || op === "delete") && !target) {
|
|
1560
|
+
return { error: { line, message: `Field "target" is required for op "${op}"` } };
|
|
1561
|
+
}
|
|
1562
|
+
const reason = header["reason"] || void 0;
|
|
1563
|
+
const fields = {};
|
|
1564
|
+
if (op !== "delete") {
|
|
1565
|
+
for (const line2 of block.bodyLines) {
|
|
1566
|
+
const m = KEY_VALUE_RE.exec(line2);
|
|
1567
|
+
if (!m) continue;
|
|
1568
|
+
const key = m[1].trim();
|
|
1569
|
+
const value = m[2].trim();
|
|
1570
|
+
if (!FIELD_NAME_RE.test(key)) continue;
|
|
1571
|
+
fields[key] = value;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
const id = header["id"] ?? generateWriteEntryId(timestamp, "agent", op, table, target);
|
|
1575
|
+
const entry = {
|
|
1576
|
+
id,
|
|
1577
|
+
timestamp,
|
|
1578
|
+
op,
|
|
1579
|
+
table,
|
|
1580
|
+
fields,
|
|
1581
|
+
...target !== void 0 ? { target } : {},
|
|
1582
|
+
...reason !== void 0 ? { reason } : {}
|
|
1583
|
+
};
|
|
1584
|
+
return { entry };
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// src/session/entries.ts
|
|
1588
|
+
import { createHash as createHash3 } from "crypto";
|
|
1589
|
+
var VALID_TYPES = /* @__PURE__ */ new Set([
|
|
1590
|
+
"event",
|
|
1591
|
+
"learning",
|
|
1592
|
+
"status",
|
|
1593
|
+
"correction",
|
|
1594
|
+
"discovery",
|
|
1595
|
+
"metric",
|
|
1596
|
+
"handoff",
|
|
1597
|
+
"write"
|
|
1598
|
+
]);
|
|
1599
|
+
var TYPE_ALIASES = {
|
|
1600
|
+
task_completion: "event",
|
|
1601
|
+
completion: "event",
|
|
1602
|
+
heartbeat: "status",
|
|
1603
|
+
bug: "discovery",
|
|
1604
|
+
fix: "event",
|
|
1605
|
+
deploy: "event",
|
|
1606
|
+
note: "event"
|
|
1607
|
+
};
|
|
1608
|
+
var FIELD_NAME_RE2 = /^[a-zA-Z0-9_]+$/;
|
|
1609
|
+
function parseSessionMD(content, startOffset = 0) {
|
|
1610
|
+
const entries = [];
|
|
1611
|
+
const errors = [];
|
|
1612
|
+
const text = content.slice(startOffset);
|
|
1613
|
+
const lines = text.split("\n");
|
|
1614
|
+
let i = 0;
|
|
1615
|
+
let currentByteOffset = startOffset;
|
|
1616
|
+
while (i < lines.length) {
|
|
1617
|
+
if (lines[i].trim() !== "---") {
|
|
1618
|
+
currentByteOffset += Buffer.byteLength(lines[i] + "\n", "utf-8");
|
|
1619
|
+
i++;
|
|
1620
|
+
continue;
|
|
1621
|
+
}
|
|
1622
|
+
const entryStartLine = i;
|
|
1623
|
+
currentByteOffset += Buffer.byteLength(lines[i] + "\n", "utf-8");
|
|
1624
|
+
i++;
|
|
1625
|
+
const headers = {};
|
|
1626
|
+
let foundHeaderClose = false;
|
|
1627
|
+
while (i < lines.length) {
|
|
1628
|
+
const line = lines[i];
|
|
1629
|
+
currentByteOffset += Buffer.byteLength(line + "\n", "utf-8");
|
|
1630
|
+
if (line.trim() === "---") {
|
|
1631
|
+
foundHeaderClose = true;
|
|
1632
|
+
i++;
|
|
1633
|
+
break;
|
|
1634
|
+
}
|
|
1635
|
+
const match = line.match(/^(\w+):\s*(.+)$/);
|
|
1636
|
+
if (match) {
|
|
1637
|
+
headers[match[1]] = match[2].trim();
|
|
1638
|
+
}
|
|
1639
|
+
i++;
|
|
1640
|
+
}
|
|
1641
|
+
if (!foundHeaderClose) {
|
|
1642
|
+
errors.push({ line: entryStartLine + 1, message: "Entry header never closed (missing ---)" });
|
|
1643
|
+
break;
|
|
1644
|
+
}
|
|
1645
|
+
const bodyLines = [];
|
|
1646
|
+
while (i < lines.length) {
|
|
1647
|
+
const line = lines[i];
|
|
1648
|
+
if (line.trim() === "===") {
|
|
1649
|
+
currentByteOffset += Buffer.byteLength(line + "\n", "utf-8");
|
|
1650
|
+
i++;
|
|
1651
|
+
break;
|
|
1652
|
+
}
|
|
1653
|
+
if (line.trim() === "---" && bodyLines.length > 0) {
|
|
1654
|
+
const nextLine = lines[i + 1];
|
|
1655
|
+
if (nextLine && /^\w+:\s*.+$/.test(nextLine)) {
|
|
1656
|
+
break;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
bodyLines.push(line);
|
|
1660
|
+
currentByteOffset += Buffer.byteLength(line + "\n", "utf-8");
|
|
1661
|
+
i++;
|
|
1662
|
+
}
|
|
1663
|
+
const body = bodyLines.join("\n").trim();
|
|
1664
|
+
const rawType = headers["type"] ?? "";
|
|
1665
|
+
const resolvedType = normalizeType(rawType);
|
|
1666
|
+
if (!resolvedType) {
|
|
1667
|
+
errors.push({ line: entryStartLine + 1, message: `Unknown entry type: ${rawType}` });
|
|
1668
|
+
continue;
|
|
1669
|
+
}
|
|
1670
|
+
if (!headers["timestamp"]) {
|
|
1671
|
+
errors.push({ line: entryStartLine + 1, message: "Missing required header: timestamp" });
|
|
1672
|
+
continue;
|
|
1673
|
+
}
|
|
1674
|
+
if (!body) {
|
|
1675
|
+
errors.push({ line: entryStartLine + 1, message: "Entry has empty body" });
|
|
1676
|
+
continue;
|
|
1677
|
+
}
|
|
1678
|
+
const entryId = headers["id"] ?? generateEntryId(headers["timestamp"], "agent", body);
|
|
1679
|
+
let tags;
|
|
1680
|
+
if (headers["tags"]) {
|
|
1681
|
+
const tagMatch = headers["tags"].match(/^\[(.+)\]$/);
|
|
1682
|
+
if (tagMatch) {
|
|
1683
|
+
tags = tagMatch[1].split(",").map((t) => t.trim());
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
let writeFields;
|
|
1687
|
+
if (resolvedType === "write" && headers["op"] !== "delete") {
|
|
1688
|
+
writeFields = {};
|
|
1689
|
+
for (const line of body.split("\n")) {
|
|
1690
|
+
const m = line.match(/^([^:]+):\s*(.*)$/);
|
|
1691
|
+
if (!m) continue;
|
|
1692
|
+
const key = m[1].trim();
|
|
1693
|
+
if (!FIELD_NAME_RE2.test(key)) continue;
|
|
1694
|
+
writeFields[key] = m[2].trim();
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
const newEntry = { id: entryId, type: resolvedType, timestamp: headers["timestamp"], body };
|
|
1698
|
+
if (headers["project"]) newEntry.project = headers["project"];
|
|
1699
|
+
if (headers["task"]) newEntry.task = headers["task"];
|
|
1700
|
+
if (tags) newEntry.tags = tags;
|
|
1701
|
+
if (headers["severity"]) newEntry.severity = headers["severity"];
|
|
1702
|
+
if (headers["target_agent"]) newEntry.target_agent = headers["target_agent"];
|
|
1703
|
+
if (headers["target_table"]) newEntry.target_table = headers["target_table"];
|
|
1704
|
+
if (resolvedType === "write") {
|
|
1705
|
+
if (headers["op"]) newEntry.op = headers["op"];
|
|
1706
|
+
if (headers["table"]) newEntry.table = headers["table"];
|
|
1707
|
+
if (headers["target"]) newEntry.target = headers["target"];
|
|
1708
|
+
if (headers["reason"]) newEntry.reason = headers["reason"];
|
|
1709
|
+
newEntry.fields = writeFields ?? {};
|
|
1710
|
+
}
|
|
1711
|
+
entries.push(newEntry);
|
|
1712
|
+
}
|
|
1713
|
+
return { entries, errors, lastOffset: currentByteOffset };
|
|
1714
|
+
}
|
|
1715
|
+
function parseMarkdownEntries(content, agentName, startOffset = 0) {
|
|
1716
|
+
const entries = [];
|
|
1717
|
+
const errors = [];
|
|
1718
|
+
const text = content.slice(startOffset);
|
|
1719
|
+
const lines = text.split("\n");
|
|
1720
|
+
const headingPattern = /^##\s+([\dT:.Z-]{10,})\s*(?:[—–\-]{1,2}\s*(.+))?$/;
|
|
1721
|
+
let currentByteOffset = startOffset;
|
|
1722
|
+
const entryStarts = [];
|
|
1723
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1724
|
+
const match = lines[i].match(headingPattern);
|
|
1725
|
+
if (match) {
|
|
1726
|
+
entryStarts.push({
|
|
1727
|
+
lineIdx: i,
|
|
1728
|
+
timestamp: match[1],
|
|
1729
|
+
headingType: (match[2] ?? "").trim(),
|
|
1730
|
+
offset: currentByteOffset
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
currentByteOffset += Buffer.byteLength(lines[i] + "\n", "utf-8");
|
|
1734
|
+
}
|
|
1735
|
+
for (let e = 0; e < entryStarts.length; e++) {
|
|
1736
|
+
const start = entryStarts[e];
|
|
1737
|
+
const nextStart = entryStarts[e + 1];
|
|
1738
|
+
const bodyStartLine = start.lineIdx + 1;
|
|
1739
|
+
const bodyEndLine = nextStart ? nextStart.lineIdx : lines.length;
|
|
1740
|
+
const bodyLines = lines.slice(bodyStartLine, bodyEndLine);
|
|
1741
|
+
let bodyType = null;
|
|
1742
|
+
const filteredBody = [];
|
|
1743
|
+
for (const line of bodyLines) {
|
|
1744
|
+
const typeMatch = line.match(/^\*\*type:\*\*\s*(.+)/i);
|
|
1745
|
+
if (typeMatch && !bodyType) {
|
|
1746
|
+
bodyType = typeMatch[1].trim();
|
|
1747
|
+
} else {
|
|
1748
|
+
filteredBody.push(line);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
const body = filteredBody.join("\n").trim();
|
|
1752
|
+
if (!body) {
|
|
1753
|
+
errors.push({ line: start.lineIdx + 1, message: "Markdown entry has empty body" });
|
|
1754
|
+
continue;
|
|
1755
|
+
}
|
|
1756
|
+
const rawType = bodyType ?? start.headingType ?? "event";
|
|
1757
|
+
const resolvedType = normalizeType(rawType) ?? "event";
|
|
1758
|
+
const id = generateEntryId(start.timestamp, agentName, body);
|
|
1759
|
+
entries.push({
|
|
1760
|
+
id,
|
|
1761
|
+
type: resolvedType,
|
|
1762
|
+
timestamp: start.timestamp,
|
|
1763
|
+
body
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
return { entries, errors, lastOffset: currentByteOffset };
|
|
1767
|
+
}
|
|
1768
|
+
function generateEntryId(timestamp, agentName, body) {
|
|
1769
|
+
const hash = createHash3("sha256").update(body).digest("hex").slice(0, 6);
|
|
1770
|
+
return `${timestamp}-${agentName.toLowerCase()}-${hash}`;
|
|
1771
|
+
}
|
|
1772
|
+
function validateEntryId(id, body) {
|
|
1773
|
+
const parts = id.split("-");
|
|
1774
|
+
if (parts.length < 4) return false;
|
|
1775
|
+
const hash = parts[parts.length - 1];
|
|
1776
|
+
if (hash.length !== 6) return false;
|
|
1777
|
+
const expectedHash = createHash3("sha256").update(body).digest("hex").slice(0, 6);
|
|
1778
|
+
return hash === expectedHash;
|
|
1779
|
+
}
|
|
1780
|
+
function normalizeType(raw) {
|
|
1781
|
+
const lower = raw.toLowerCase().trim();
|
|
1782
|
+
if (VALID_TYPES.has(lower)) return lower;
|
|
1783
|
+
const normalized = lower.replace(/-/g, "_");
|
|
1784
|
+
if (TYPE_ALIASES[normalized]) return TYPE_ALIASES[normalized];
|
|
1785
|
+
for (const alias of Object.keys(TYPE_ALIASES)) {
|
|
1786
|
+
if (normalized.startsWith(alias)) return TYPE_ALIASES[alias];
|
|
1787
|
+
}
|
|
1788
|
+
return null;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// src/session/apply.ts
|
|
1792
|
+
var TABLE_NAME_RE2 = /^[a-zA-Z0-9_]+$/;
|
|
1793
|
+
var FIELD_NAME_RE3 = /^[a-zA-Z0-9_]+$/;
|
|
1794
|
+
function applyWriteEntry(db, entry) {
|
|
1795
|
+
const { op, table, target, fields } = entry;
|
|
1796
|
+
if (!TABLE_NAME_RE2.test(table)) {
|
|
1797
|
+
return { ok: false, reason: `Invalid table name: "${table}". Only [a-zA-Z0-9_] allowed` };
|
|
1798
|
+
}
|
|
1799
|
+
const tableExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(table);
|
|
1800
|
+
if (!tableExists) {
|
|
1801
|
+
return { ok: false, reason: `Unknown table: "${table}"` };
|
|
1802
|
+
}
|
|
1803
|
+
const columnRows = db.prepare(`PRAGMA table_info("${table}")`).all();
|
|
1804
|
+
const knownColumns = new Set(columnRows.map((r) => r.name));
|
|
1805
|
+
for (const fieldName of Object.keys(fields)) {
|
|
1806
|
+
if (!FIELD_NAME_RE3.test(fieldName)) {
|
|
1807
|
+
return { ok: false, reason: `Invalid field name: "${fieldName}". Only [a-zA-Z0-9_] allowed` };
|
|
1808
|
+
}
|
|
1809
|
+
if (!knownColumns.has(fieldName)) {
|
|
1810
|
+
return { ok: false, reason: `Unknown field "${fieldName}" in table "${table}"` };
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
try {
|
|
1814
|
+
let recordId;
|
|
1815
|
+
if (op === "create") {
|
|
1816
|
+
const id = fields["id"] ?? crypto.randomUUID();
|
|
1817
|
+
const allFields = { ...fields, id };
|
|
1818
|
+
const cols = Object.keys(allFields).map((c) => `"${c}"`).join(", ");
|
|
1819
|
+
const placeholders = Object.keys(allFields).map(() => "?").join(", ");
|
|
1820
|
+
db.prepare(`INSERT INTO "${table}" (${cols}) VALUES (${placeholders})`).run(...Object.values(allFields));
|
|
1821
|
+
recordId = id;
|
|
1822
|
+
} else if (op === "update") {
|
|
1823
|
+
if (!target) {
|
|
1824
|
+
return { ok: false, reason: 'Field "target" is required for op "update"' };
|
|
1825
|
+
}
|
|
1826
|
+
const pkCol = columnRows.find((r) => r.name === "id") ? "id" : columnRows[0]?.name ?? "id";
|
|
1827
|
+
const setCols = Object.keys(fields).map((c) => `"${c}" = ?`).join(", ");
|
|
1828
|
+
db.prepare(`UPDATE "${table}" SET ${setCols} WHERE "${pkCol}" = ?`).run(...Object.values(fields), target);
|
|
1829
|
+
recordId = target;
|
|
1830
|
+
} else {
|
|
1831
|
+
if (!target) {
|
|
1832
|
+
return { ok: false, reason: 'Field "target" is required for op "delete"' };
|
|
1833
|
+
}
|
|
1834
|
+
const pkCol = columnRows.find((r) => r.name === "id") ? "id" : columnRows[0]?.name ?? "id";
|
|
1835
|
+
if (knownColumns.has("deleted_at")) {
|
|
1836
|
+
db.prepare(`UPDATE "${table}" SET deleted_at = datetime('now') WHERE "${pkCol}" = ?`).run(target);
|
|
1837
|
+
} else {
|
|
1838
|
+
db.prepare(`DELETE FROM "${table}" WHERE "${pkCol}" = ?`).run(target);
|
|
1839
|
+
}
|
|
1840
|
+
recordId = target;
|
|
1841
|
+
}
|
|
1842
|
+
return { ok: true, table, recordId };
|
|
1843
|
+
} catch (err) {
|
|
1844
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1845
|
+
return { ok: false, reason: `DB error: ${message}` };
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
// src/session/constants.ts
|
|
1850
|
+
var READ_ONLY_HEADER = `<!-- READ ONLY \u2014 generated by lattice-sync. Do not edit directly.
|
|
1851
|
+
To update data in Lattice: write entries to SESSION.md in this directory.
|
|
1852
|
+
Format: type: write | op: create/update/delete | table: <name> | target: <id>
|
|
1853
|
+
See agents/shared/SESSION-FORMAT.md for the full spec. -->
|
|
1854
|
+
|
|
1855
|
+
`;
|
|
1465
1856
|
export {
|
|
1466
1857
|
Lattice,
|
|
1858
|
+
READ_ONLY_HEADER,
|
|
1859
|
+
applyWriteEntry,
|
|
1860
|
+
generateEntryId,
|
|
1861
|
+
generateWriteEntryId,
|
|
1467
1862
|
manifestPath,
|
|
1468
1863
|
parseConfigFile,
|
|
1469
1864
|
parseConfigString,
|
|
1865
|
+
parseMarkdownEntries,
|
|
1866
|
+
parseSessionMD,
|
|
1867
|
+
parseSessionWrites,
|
|
1470
1868
|
readManifest,
|
|
1869
|
+
validateEntryId,
|
|
1471
1870
|
writeManifest
|
|
1472
1871
|
};
|