latticesql 0.5.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/LICENSE +182 -0
- package/README.md +1360 -0
- package/dist/cli.js +1880 -0
- package/dist/index.cjs +1514 -0
- package/dist/index.d.cts +869 -0
- package/dist/index.d.ts +869 -0
- package/dist/index.js +1472 -0
- package/package.json +79 -0
package/README.md
ADDED
|
@@ -0,0 +1,1360 @@
|
|
|
1
|
+
# latticesql
|
|
2
|
+
|
|
3
|
+
**SQLite ↔ LLM context bridge.** Keeps a database and a set of text files in sync so AI agents always start a session with accurate, up-to-date state.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/latticesql)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
[](https://nodejs.org)
|
|
8
|
+
|
|
9
|
+
**[latticeSQL.com](https://latticeSQL.com)** — docs, examples, and guides
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## What it does
|
|
14
|
+
|
|
15
|
+
LLM context windows are ephemeral. Your application state lives in a database. Every agent session starts cold unless something bridges them. Lattice is that bridge — a minimal, generic engine that:
|
|
16
|
+
|
|
17
|
+
1. **Renders** DB rows into agent-readable text files (Markdown, JSON, or any format you define)
|
|
18
|
+
2. **Watches** for DB changes and re-renders automatically
|
|
19
|
+
3. **Ingests** agent-written output back into the DB via the writeback pipeline
|
|
20
|
+
|
|
21
|
+
Lattice has no opinions about your schema, your agents, or your file format. You define the tables. You control the rendering. Lattice runs the sync loop.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Table of contents
|
|
26
|
+
|
|
27
|
+
- [Installation](#installation)
|
|
28
|
+
- [Quick start](#quick-start)
|
|
29
|
+
- [The sync loop](#the-sync-loop)
|
|
30
|
+
- [API reference](#api-reference)
|
|
31
|
+
- [Constructor](#constructor)
|
|
32
|
+
- [define()](#definedefine)
|
|
33
|
+
- [defineMulti()](#definemulti)
|
|
34
|
+
- [defineEntityContext()](#defineentitycontext-v05)
|
|
35
|
+
- [defineWriteback()](#definewriteback)
|
|
36
|
+
- [init() / close()](#init--close)
|
|
37
|
+
- [CRUD operations](#crud-operations)
|
|
38
|
+
- [Query operators](#query-operators)
|
|
39
|
+
- [Render, sync, watch, and reconcile](#render-sync-watch-and-reconcile)
|
|
40
|
+
- [Events](#events)
|
|
41
|
+
- [Raw DB access](#raw-db-access)
|
|
42
|
+
- [Template rendering](#template-rendering)
|
|
43
|
+
- [Built-in templates](#built-in-templates)
|
|
44
|
+
- [Lifecycle hooks](#lifecycle-hooks)
|
|
45
|
+
- [Field interpolation](#field-interpolation)
|
|
46
|
+
- [Entity context directories (v0.5+)](#entity-context-directories-v05)
|
|
47
|
+
- [YAML config (v0.4+)](#yaml-config-v04)
|
|
48
|
+
- [lattice.config.yml reference](#latticeconfigyml-reference)
|
|
49
|
+
- [Init from config](#init-from-config)
|
|
50
|
+
- [Config API](#config-api-programmatic)
|
|
51
|
+
- [CLI — lattice generate](#cli--lattice-generate)
|
|
52
|
+
- [Schema migrations](#schema-migrations)
|
|
53
|
+
- [Security](#security)
|
|
54
|
+
- [Architecture](#architecture)
|
|
55
|
+
- [Examples](#examples)
|
|
56
|
+
- [Contributing](#contributing)
|
|
57
|
+
- [Changelog](#changelog)
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Installation
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm install latticesql
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Requires **Node.js 18+**. Uses `better-sqlite3` — no external database process needed.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Quick start
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
import { Lattice } from 'latticesql';
|
|
75
|
+
|
|
76
|
+
const db = new Lattice('./state.db');
|
|
77
|
+
|
|
78
|
+
db.define('agents', {
|
|
79
|
+
columns: {
|
|
80
|
+
id: 'TEXT PRIMARY KEY',
|
|
81
|
+
name: 'TEXT NOT NULL',
|
|
82
|
+
persona: 'TEXT',
|
|
83
|
+
active: 'INTEGER DEFAULT 1',
|
|
84
|
+
},
|
|
85
|
+
render(rows) {
|
|
86
|
+
return rows
|
|
87
|
+
.filter((r) => r.active)
|
|
88
|
+
.map((r) => `## ${r.name}\n\n${r.persona ?? ''}`)
|
|
89
|
+
.join('\n\n---\n\n');
|
|
90
|
+
},
|
|
91
|
+
outputFile: 'AGENTS.md',
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await db.init();
|
|
95
|
+
|
|
96
|
+
await db.insert('agents', { name: 'Alpha', persona: 'You are Alpha, a research assistant.' });
|
|
97
|
+
await db.insert('agents', { name: 'Beta', persona: 'You are Beta, a code reviewer.' });
|
|
98
|
+
|
|
99
|
+
// Render DB → context files
|
|
100
|
+
await db.render('./context');
|
|
101
|
+
// Writes: context/AGENTS.md
|
|
102
|
+
|
|
103
|
+
// Watch for changes, re-render every 5 seconds
|
|
104
|
+
const stop = await db.watch('./context', { interval: 5000 });
|
|
105
|
+
|
|
106
|
+
// Later:
|
|
107
|
+
stop();
|
|
108
|
+
db.close();
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**YAML config form** (v0.4+) — declare your schema in a file instead:
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
const db = new Lattice({ config: './lattice.config.yml' });
|
|
115
|
+
await db.init();
|
|
116
|
+
// Tables and render functions are wired automatically from the config
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## The sync loop
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
Your DB (SQLite)
|
|
125
|
+
│ Lattice reads rows → render functions → text
|
|
126
|
+
▼
|
|
127
|
+
Context files (Markdown, JSON, etc.)
|
|
128
|
+
│ LLM agents read these at session start
|
|
129
|
+
▼
|
|
130
|
+
Agent output files
|
|
131
|
+
│ Lattice writeback pipeline parses these
|
|
132
|
+
▼
|
|
133
|
+
Your DB (rows inserted/updated)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Lattice never modifies your existing rows — it only reads for rendering and appends via the writeback pipeline.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## API reference
|
|
141
|
+
|
|
142
|
+
### Constructor
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
new Lattice(path: string, options?: LatticeOptions)
|
|
146
|
+
new Lattice(config: LatticeConfigInput, options?: LatticeOptions)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
| Overload | Description |
|
|
150
|
+
| ------------------------------------------------- | --------------------------------------------- |
|
|
151
|
+
| `new Lattice('./app.db')` | Open a SQLite file at the given path |
|
|
152
|
+
| `new Lattice(':memory:')` | In-memory database (useful for tests) |
|
|
153
|
+
| `new Lattice({ config: './lattice.config.yml' })` | Read schema + DB path from a YAML config file |
|
|
154
|
+
|
|
155
|
+
**`LatticeOptions`**
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
interface LatticeOptions {
|
|
159
|
+
wal?: boolean; // WAL journal mode (default: true — recommended for concurrent reads)
|
|
160
|
+
busyTimeout?: number; // SQLite busy_timeout in ms (default: 5000)
|
|
161
|
+
security?: {
|
|
162
|
+
sanitize?: boolean; // Strip control characters from string inputs (default: true)
|
|
163
|
+
auditTables?: string[]; // Tables that emit 'audit' events on write
|
|
164
|
+
fieldLimits?: Record<string, number>; // Max characters per named column
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
const db = new Lattice('./app.db', {
|
|
171
|
+
wal: true,
|
|
172
|
+
busyTimeout: 10_000,
|
|
173
|
+
security: {
|
|
174
|
+
sanitize: true,
|
|
175
|
+
auditTables: ['users', 'credentials'],
|
|
176
|
+
fieldLimits: { notes: 50_000, bio: 2_000 },
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
### `define()`
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
db.define(table: string, definition: TableDefinition): this
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Register a table. Must be called before `init()`. Returns `this` for chaining.
|
|
190
|
+
|
|
191
|
+
**`TableDefinition`**
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
interface TableDefinition {
|
|
195
|
+
/** Column name → SQLite type spec */
|
|
196
|
+
columns: Record<string, string>;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* How rows become context text.
|
|
200
|
+
* - A render function: (rows: Row[]) => string
|
|
201
|
+
* - A built-in template name: 'default-list' | 'default-table' | 'default-detail' | 'default-json'
|
|
202
|
+
* - A template spec with hooks: { template: BuiltinTemplateName, hooks?: RenderHooks }
|
|
203
|
+
*/
|
|
204
|
+
render: RenderSpec;
|
|
205
|
+
|
|
206
|
+
/** Output file path, relative to the outputDir passed to render()/watch() */
|
|
207
|
+
outputFile: string;
|
|
208
|
+
|
|
209
|
+
/** Optional row filter applied before rendering */
|
|
210
|
+
filter?: (rows: Row[]) => Row[];
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Primary key column name or [col1, col2] for composite PKs.
|
|
214
|
+
* Defaults to 'id'. When 'id' is the PK and the field is absent on insert,
|
|
215
|
+
* a UUID v4 is generated automatically.
|
|
216
|
+
*/
|
|
217
|
+
primaryKey?: string | string[];
|
|
218
|
+
|
|
219
|
+
/** Additional SQL constraints (required for composite PKs) */
|
|
220
|
+
tableConstraints?: string[];
|
|
221
|
+
|
|
222
|
+
/** Declared relationships used by template rendering */
|
|
223
|
+
relations?: Record<string, Relation>;
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**Basic example:**
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
db.define('tasks', {
|
|
231
|
+
columns: {
|
|
232
|
+
id: 'TEXT PRIMARY KEY',
|
|
233
|
+
title: 'TEXT NOT NULL',
|
|
234
|
+
status: 'TEXT DEFAULT "open"',
|
|
235
|
+
due: 'TEXT',
|
|
236
|
+
},
|
|
237
|
+
render(rows) {
|
|
238
|
+
const open = rows.filter((r) => r.status === 'open');
|
|
239
|
+
return (
|
|
240
|
+
`# Open Tasks (${open.length})\n\n` +
|
|
241
|
+
open.map((r) => `- [ ] ${r.title}${r.due ? ` — due ${r.due}` : ''}`).join('\n')
|
|
242
|
+
);
|
|
243
|
+
},
|
|
244
|
+
outputFile: 'TASKS.md',
|
|
245
|
+
});
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
**Custom primary key:**
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
db.define('pages', {
|
|
252
|
+
columns: {
|
|
253
|
+
slug: 'TEXT NOT NULL',
|
|
254
|
+
title: 'TEXT NOT NULL',
|
|
255
|
+
content: 'TEXT',
|
|
256
|
+
},
|
|
257
|
+
primaryKey: 'slug', // <-- tell Lattice which column is the PK
|
|
258
|
+
render: 'default-list',
|
|
259
|
+
outputFile: 'pages.md',
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// get/update/delete now use the slug value directly
|
|
263
|
+
const page = await db.get('pages', 'about-us');
|
|
264
|
+
await db.update('pages', 'about-us', { title: 'About' });
|
|
265
|
+
await db.delete('pages', 'about-us');
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
**Composite primary key:**
|
|
269
|
+
|
|
270
|
+
```typescript
|
|
271
|
+
db.define('event_seats', {
|
|
272
|
+
columns: {
|
|
273
|
+
event_id: 'TEXT NOT NULL',
|
|
274
|
+
seat_no: 'INTEGER NOT NULL',
|
|
275
|
+
holder: 'TEXT',
|
|
276
|
+
},
|
|
277
|
+
tableConstraints: ['PRIMARY KEY (event_id, seat_no)'],
|
|
278
|
+
primaryKey: ['event_id', 'seat_no'],
|
|
279
|
+
render: 'default-table',
|
|
280
|
+
outputFile: 'seats.md',
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Pass a Record for get/update/delete
|
|
284
|
+
const seat = await db.get('event_seats', { event_id: 'evt-1', seat_no: 12 });
|
|
285
|
+
await db.update('event_seats', { event_id: 'evt-1', seat_no: 12 }, { holder: 'Alice' });
|
|
286
|
+
await db.delete('event_seats', { event_id: 'evt-1', seat_no: 12 });
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
**Relationship declarations:**
|
|
290
|
+
|
|
291
|
+
```typescript
|
|
292
|
+
db.define('comments', {
|
|
293
|
+
columns: {
|
|
294
|
+
id: 'TEXT PRIMARY KEY',
|
|
295
|
+
post_id: 'TEXT NOT NULL',
|
|
296
|
+
author_id: 'TEXT NOT NULL',
|
|
297
|
+
body: 'TEXT',
|
|
298
|
+
},
|
|
299
|
+
relations: {
|
|
300
|
+
post: { type: 'belongsTo', table: 'posts', foreignKey: 'post_id' },
|
|
301
|
+
author: { type: 'belongsTo', table: 'users', foreignKey: 'author_id' },
|
|
302
|
+
// hasMany: the other table holds the FK
|
|
303
|
+
likes: { type: 'hasMany', table: 'comment_likes', foreignKey: 'comment_id' },
|
|
304
|
+
},
|
|
305
|
+
render: {
|
|
306
|
+
template: 'default-detail',
|
|
307
|
+
hooks: { formatRow: '{{author.name}}: {{body}}' },
|
|
308
|
+
},
|
|
309
|
+
outputFile: 'comments.md',
|
|
310
|
+
});
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
---
|
|
314
|
+
|
|
315
|
+
### `defineMulti()`
|
|
316
|
+
|
|
317
|
+
```typescript
|
|
318
|
+
db.defineMulti(name: string, definition: MultiTableDefinition): this
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Produces one output file per _anchor entity_ — useful for per-agent or per-project context files.
|
|
322
|
+
|
|
323
|
+
```typescript
|
|
324
|
+
db.defineMulti('agent-context', {
|
|
325
|
+
// Returns the anchor entities (one file will be created per agent)
|
|
326
|
+
keys: () => db.query('agents', { where: { active: 1 } }),
|
|
327
|
+
|
|
328
|
+
// Derive the output file path from the anchor entity
|
|
329
|
+
outputFile: (agent) => `agents/${agent.slug as string}/CONTEXT.md`,
|
|
330
|
+
|
|
331
|
+
// Extra tables to query and pass into render
|
|
332
|
+
tables: ['tasks', 'notes'],
|
|
333
|
+
|
|
334
|
+
render(agent, { tasks, notes }) {
|
|
335
|
+
const myTasks = tasks.filter((t) => t.assigned_to === agent.id);
|
|
336
|
+
const myNotes = notes.filter((n) => n.agent_id === agent.id);
|
|
337
|
+
return [
|
|
338
|
+
`# ${agent.name} — context`,
|
|
339
|
+
'',
|
|
340
|
+
'## Pending tasks',
|
|
341
|
+
myTasks.map((t) => `- ${t.title}`).join('\n') || '_none_',
|
|
342
|
+
'',
|
|
343
|
+
'## Notes',
|
|
344
|
+
myNotes.map((n) => `- ${n.body}`).join('\n') || '_none_',
|
|
345
|
+
].join('\n');
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
### `defineEntityContext()` (v0.5+)
|
|
353
|
+
|
|
354
|
+
```typescript
|
|
355
|
+
db.defineEntityContext(table: string, def: EntityContextDefinition): this
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
Generate a **parallel file-system tree** for an entity type — one subdirectory per row, one file per declared relationship, and an optional combined context file. Must be called before `init()`.
|
|
359
|
+
|
|
360
|
+
```typescript
|
|
361
|
+
db.defineEntityContext('agents', {
|
|
362
|
+
// Derive the subdirectory name for each entity
|
|
363
|
+
slug: (row) => row.slug as string,
|
|
364
|
+
|
|
365
|
+
// Global index file listing all entities
|
|
366
|
+
index: {
|
|
367
|
+
outputFile: 'agents/AGENTS.md',
|
|
368
|
+
render: (rows) => `# Agents\n\n${rows.map((r) => `- ${r.name as string}`).join('\n')}`,
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
// Files inside each entity's directory
|
|
372
|
+
files: {
|
|
373
|
+
'AGENT.md': {
|
|
374
|
+
source: { type: 'self' }, // entity's own row
|
|
375
|
+
render: ([r]) => `# ${r.name as string}\n\n${r.bio as string ?? ''}`,
|
|
376
|
+
},
|
|
377
|
+
'TASKS.md': {
|
|
378
|
+
source: { type: 'hasMany', table: 'tasks', foreignKey: 'agent_id' },
|
|
379
|
+
render: (rows) => rows.map((r) => `- ${r.title as string}`).join('\n'),
|
|
380
|
+
omitIfEmpty: true, // skip if no tasks
|
|
381
|
+
budget: 4000, // truncate at 4 000 chars
|
|
382
|
+
},
|
|
383
|
+
'SKILLS.md': {
|
|
384
|
+
source: {
|
|
385
|
+
type: 'manyToMany',
|
|
386
|
+
junctionTable: 'agent_skills',
|
|
387
|
+
localKey: 'agent_id',
|
|
388
|
+
remoteKey: 'skill_id',
|
|
389
|
+
remoteTable: 'skills',
|
|
390
|
+
},
|
|
391
|
+
render: (rows) => rows.map((r) => `- ${r.name as string}`).join('\n'),
|
|
392
|
+
omitIfEmpty: true,
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
|
|
396
|
+
// Concatenate all files into one combined context file per entity
|
|
397
|
+
combined: { outputFile: 'CONTEXT.md', exclude: [] },
|
|
398
|
+
|
|
399
|
+
// Files agents may write — Lattice never deletes these during cleanup
|
|
400
|
+
protectedFiles: ['SESSION.md'],
|
|
401
|
+
});
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
**On each `render()` / `reconcile()` call this produces:**
|
|
405
|
+
|
|
406
|
+
```
|
|
407
|
+
context/
|
|
408
|
+
├── agents/
|
|
409
|
+
│ └── AGENTS.md ← global index
|
|
410
|
+
├── agents/alpha/
|
|
411
|
+
│ ├── AGENT.md
|
|
412
|
+
│ ├── TASKS.md ← omitted when empty
|
|
413
|
+
│ ├── SKILLS.md ← omitted when empty
|
|
414
|
+
│ └── CONTEXT.md ← AGENT.md + TASKS.md + SKILLS.md combined
|
|
415
|
+
└── agents/beta/
|
|
416
|
+
├── AGENT.md
|
|
417
|
+
└── CONTEXT.md
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
**Source types:**
|
|
421
|
+
|
|
422
|
+
| Type | What it queries |
|
|
423
|
+
|---|---|
|
|
424
|
+
| `{ type: 'self' }` | The entity row itself |
|
|
425
|
+
| `{ type: 'hasMany', table, foreignKey, references? }` | Rows in `table` where `foreignKey = entityPk` |
|
|
426
|
+
| `{ type: 'manyToMany', junctionTable, localKey, remoteKey, remoteTable, references? }` | Remote rows via a junction table |
|
|
427
|
+
| `{ type: 'belongsTo', table, foreignKey, references? }` | Single parent row via FK on this entity (`null` FK → empty) |
|
|
428
|
+
| `{ type: 'custom', query: (row, adapter) => Row[] }` | Fully custom synchronous query |
|
|
429
|
+
|
|
430
|
+
See [docs/entity-context.md](./docs/entity-context.md) for the complete guide.
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
### `defineWriteback()`
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
db.defineWriteback(definition: WritebackDefinition): this
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Register an agent-output file for parsing and DB ingestion. Lattice tracks file offsets and handles rotation (truncation) automatically.
|
|
441
|
+
|
|
442
|
+
```typescript
|
|
443
|
+
db.defineWriteback({
|
|
444
|
+
// Path or glob to agent-written output files
|
|
445
|
+
file: './context/agents/*/SESSION.md',
|
|
446
|
+
|
|
447
|
+
parse(content, fromOffset) {
|
|
448
|
+
// Parse new content since last read
|
|
449
|
+
const newContent = content.slice(fromOffset);
|
|
450
|
+
const entries = parseMarkdownItems(newContent);
|
|
451
|
+
return { entries, nextOffset: content.length };
|
|
452
|
+
},
|
|
453
|
+
|
|
454
|
+
async persist(entry, filePath) {
|
|
455
|
+
await db.insert('events', {
|
|
456
|
+
source_file: filePath,
|
|
457
|
+
...(entry as Row),
|
|
458
|
+
});
|
|
459
|
+
},
|
|
460
|
+
|
|
461
|
+
// Optional: skip entries with the same dedupeKey seen before
|
|
462
|
+
dedupeKey: (entry) => (entry as { id: string }).id,
|
|
463
|
+
});
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
---
|
|
467
|
+
|
|
468
|
+
### `init()` / `close()`
|
|
469
|
+
|
|
470
|
+
```typescript
|
|
471
|
+
await db.init(options?: InitOptions): Promise<void>
|
|
472
|
+
db.close(): void
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
`init()` opens the SQLite file, runs `CREATE TABLE IF NOT EXISTS` for all defined tables, and applies any migrations. Must be called once before any CRUD or render operations.
|
|
476
|
+
|
|
477
|
+
```typescript
|
|
478
|
+
await db.init({
|
|
479
|
+
migrations: [
|
|
480
|
+
{ version: 1, sql: 'ALTER TABLE tasks ADD COLUMN due_date TEXT' },
|
|
481
|
+
{ version: 2, sql: 'ALTER TABLE tasks ADD COLUMN priority INTEGER DEFAULT 0' },
|
|
482
|
+
],
|
|
483
|
+
});
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
Migrations are idempotent — each `version` number is applied exactly once, tracked in a `__lattice_migrations` internal table.
|
|
487
|
+
|
|
488
|
+
`close()` closes the SQLite connection. Call it when the process shuts down.
|
|
489
|
+
|
|
490
|
+
---
|
|
491
|
+
|
|
492
|
+
### CRUD operations
|
|
493
|
+
|
|
494
|
+
All CRUD methods return Promises and are safe to `await`.
|
|
495
|
+
|
|
496
|
+
#### `insert()`
|
|
497
|
+
|
|
498
|
+
```typescript
|
|
499
|
+
await db.insert(table: string, row: Row): Promise<string>
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
Insert a row. Returns the primary key value (as a string). For the default `id` column, a UUID is auto-generated when absent.
|
|
503
|
+
|
|
504
|
+
```typescript
|
|
505
|
+
const id = await db.insert('tasks', { title: 'Write docs', status: 'open' });
|
|
506
|
+
// id → 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
|
|
507
|
+
|
|
508
|
+
// With a custom PK — caller must supply the value
|
|
509
|
+
await db.insert('pages', { slug: 'about', title: 'About Us' });
|
|
510
|
+
|
|
511
|
+
// With explicit id
|
|
512
|
+
await db.insert('tasks', { id: 'task-001', title: 'Specific task' });
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
#### `upsert()`
|
|
516
|
+
|
|
517
|
+
```typescript
|
|
518
|
+
await db.upsert(table: string, row: Row): Promise<string>
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
Insert or update a row by primary key (`ON CONFLICT DO UPDATE`). All PK columns must be present in `row`.
|
|
522
|
+
|
|
523
|
+
```typescript
|
|
524
|
+
await db.upsert('tasks', { id: 'task-001', title: 'Updated title', status: 'done' });
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
#### `upsertBy()`
|
|
528
|
+
|
|
529
|
+
```typescript
|
|
530
|
+
await db.upsertBy(table: string, col: string, val: unknown, row: Row): Promise<string>
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
Upsert by an arbitrary column — looks up the row by `col = val`, updates if found, inserts if not. Useful for `email`-keyed users, `slug`-keyed posts, etc.
|
|
534
|
+
|
|
535
|
+
```typescript
|
|
536
|
+
await db.upsertBy('users', 'email', 'alice@example.com', { name: 'Alice' });
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
#### `update()`
|
|
540
|
+
|
|
541
|
+
```typescript
|
|
542
|
+
await db.update(table: string, id: PkLookup, row: Partial<Row>): Promise<void>
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
Update specific columns on an existing row.
|
|
546
|
+
|
|
547
|
+
```typescript
|
|
548
|
+
await db.update('tasks', 'task-001', { status: 'done' });
|
|
549
|
+
|
|
550
|
+
// Composite PK
|
|
551
|
+
await db.update('event_seats', { event_id: 'e-1', seat_no: 3 }, { holder: 'Bob' });
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
#### `delete()`
|
|
555
|
+
|
|
556
|
+
```typescript
|
|
557
|
+
await db.delete(table: string, id: PkLookup): Promise<void>
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
```typescript
|
|
561
|
+
await db.delete('tasks', 'task-001');
|
|
562
|
+
await db.delete('event_seats', { event_id: 'e-1', seat_no: 3 });
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
#### `get()`
|
|
566
|
+
|
|
567
|
+
```typescript
|
|
568
|
+
await db.get(table: string, id: PkLookup): Promise<Row | null>
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
Fetch a single row by PK. Returns `null` if not found.
|
|
572
|
+
|
|
573
|
+
```typescript
|
|
574
|
+
const task = await db.get('tasks', 'task-001');
|
|
575
|
+
// { id: 'task-001', title: 'Write docs', status: 'open' } | null
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
#### `query()`
|
|
579
|
+
|
|
580
|
+
```typescript
|
|
581
|
+
await db.query(table: string, opts?: QueryOptions): Promise<Row[]>
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
```typescript
|
|
585
|
+
interface QueryOptions {
|
|
586
|
+
where?: Record<string, unknown>; // Equality shorthand
|
|
587
|
+
filters?: Filter[]; // Advanced operators (see below)
|
|
588
|
+
orderBy?: string;
|
|
589
|
+
orderDir?: 'asc' | 'desc';
|
|
590
|
+
limit?: number;
|
|
591
|
+
offset?: number;
|
|
592
|
+
}
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
```typescript
|
|
596
|
+
// Simple equality filter
|
|
597
|
+
const open = await db.query('tasks', { where: { status: 'open' } });
|
|
598
|
+
|
|
599
|
+
// Sorted + paginated
|
|
600
|
+
const page1 = await db.query('tasks', {
|
|
601
|
+
where: { status: 'open' },
|
|
602
|
+
orderBy: 'created_at',
|
|
603
|
+
orderDir: 'desc',
|
|
604
|
+
limit: 20,
|
|
605
|
+
offset: 0,
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
// All rows
|
|
609
|
+
const all = await db.query('tasks');
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
#### `count()`
|
|
613
|
+
|
|
614
|
+
```typescript
|
|
615
|
+
await db.count(table: string, opts?: CountOptions): Promise<number>
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
```typescript
|
|
619
|
+
const n = await db.count('tasks', { where: { status: 'open' } });
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
---
|
|
623
|
+
|
|
624
|
+
### Query operators
|
|
625
|
+
|
|
626
|
+
The `filters` array supports operators beyond equality. `where` and `filters` are combined with `AND`.
|
|
627
|
+
|
|
628
|
+
```typescript
|
|
629
|
+
interface Filter {
|
|
630
|
+
col: string;
|
|
631
|
+
op: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'in' | 'isNull' | 'isNotNull';
|
|
632
|
+
val?: unknown; // not needed for isNull / isNotNull
|
|
633
|
+
}
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
**Examples:**
|
|
637
|
+
|
|
638
|
+
```typescript
|
|
639
|
+
// Comparison
|
|
640
|
+
const highPriority = await db.query('tasks', {
|
|
641
|
+
filters: [{ col: 'priority', op: 'gte', val: 4 }],
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// Pattern match
|
|
645
|
+
const search = await db.query('tasks', {
|
|
646
|
+
filters: [{ col: 'title', op: 'like', val: '%refactor%' }],
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
// IN list
|
|
650
|
+
const active = await db.query('tasks', {
|
|
651
|
+
filters: [{ col: 'status', op: 'in', val: ['open', 'in-progress'] }],
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
// NULL checks
|
|
655
|
+
const unassigned = await db.query('tasks', {
|
|
656
|
+
filters: [{ col: 'assignee_id', op: 'isNull' }],
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// Combine where + filters (ANDed)
|
|
660
|
+
const results = await db.query('tasks', {
|
|
661
|
+
where: { project_id: 'proj-1' },
|
|
662
|
+
filters: [
|
|
663
|
+
{ col: 'priority', op: 'gte', val: 3 },
|
|
664
|
+
{ col: 'deleted_at', op: 'isNull' },
|
|
665
|
+
],
|
|
666
|
+
orderBy: 'priority',
|
|
667
|
+
orderDir: 'desc',
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
// count() supports filters too
|
|
671
|
+
const n = await db.count('tasks', {
|
|
672
|
+
filters: [{ col: 'status', op: 'ne', val: 'done' }],
|
|
673
|
+
});
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
---
|
|
677
|
+
|
|
678
|
+
### Render, sync, watch, and reconcile
|
|
679
|
+
|
|
680
|
+
#### `render()`
|
|
681
|
+
|
|
682
|
+
```typescript
|
|
683
|
+
await db.render(outputDir: string): Promise<RenderResult>
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
Render all tables to text files in `outputDir`. Files are written atomically (write to temp, rename). Files whose content hasn't changed are skipped.
|
|
687
|
+
|
|
688
|
+
```typescript
|
|
689
|
+
const result = await db.render('./context');
|
|
690
|
+
// { filesWritten: ['context/TASKS.md'], filesSkipped: 2, durationMs: 12 }
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
#### `sync()`
|
|
694
|
+
|
|
695
|
+
```typescript
|
|
696
|
+
await db.sync(outputDir: string): Promise<SyncResult>
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
`render()` + writeback pipeline in one call.
|
|
700
|
+
|
|
701
|
+
```typescript
|
|
702
|
+
const result = await db.sync('./context');
|
|
703
|
+
// { filesWritten: [...], filesSkipped: 0, durationMs: 18, writebackProcessed: 3 }
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
#### `watch()`
|
|
707
|
+
|
|
708
|
+
```typescript
|
|
709
|
+
await db.watch(outputDir: string, opts?: WatchOptions): Promise<StopFn>
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
Poll the DB every `interval` ms and re-render when content changes.
|
|
713
|
+
|
|
714
|
+
```typescript
|
|
715
|
+
const stop = await db.watch('./context', {
|
|
716
|
+
interval: 5_000, // default: 5000 ms
|
|
717
|
+
onRender: (r) => console.log('rendered', r.filesWritten.length, 'files'),
|
|
718
|
+
onError: (e) => console.error('render error:', e.message),
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// Stop the loop later
|
|
722
|
+
stop();
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
**With automatic orphan cleanup (v0.5+):**
|
|
726
|
+
|
|
727
|
+
```typescript
|
|
728
|
+
const stop = await db.watch('./context', {
|
|
729
|
+
interval: 10_000,
|
|
730
|
+
cleanup: {
|
|
731
|
+
removeOrphanedDirectories: true, // delete dirs for deleted entities
|
|
732
|
+
removeOrphanedFiles: true, // delete stale relationship files
|
|
733
|
+
protectedFiles: ['SESSION.md'], // never delete these
|
|
734
|
+
dryRun: false,
|
|
735
|
+
},
|
|
736
|
+
onCleanup: (r) => {
|
|
737
|
+
if (r.directoriesRemoved.length > 0) {
|
|
738
|
+
console.log('removed orphaned dirs:', r.directoriesRemoved);
|
|
739
|
+
}
|
|
740
|
+
},
|
|
741
|
+
});
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
#### `reconcile()` (v0.5+)
|
|
745
|
+
|
|
746
|
+
```typescript
|
|
747
|
+
await db.reconcile(outputDir: string, options?: ReconcileOptions): Promise<ReconcileResult>
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
One-shot render + orphan cleanup. Reads the previous manifest, renders all tables and entity contexts (writing a new manifest), then removes orphaned directories and files.
|
|
751
|
+
|
|
752
|
+
```typescript
|
|
753
|
+
const result = await db.reconcile('./context', {
|
|
754
|
+
removeOrphanedDirectories: true,
|
|
755
|
+
removeOrphanedFiles: true,
|
|
756
|
+
protectedFiles: ['SESSION.md'],
|
|
757
|
+
dryRun: false, // set true to preview without deleting
|
|
758
|
+
onOrphan: (path, kind) => console.log(`would remove ${kind}: ${path}`),
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
console.log(result.filesWritten); // files written this cycle
|
|
762
|
+
console.log(result.cleanup.directoriesRemoved); // orphaned dirs removed
|
|
763
|
+
console.log(result.cleanup.warnings); // dirs left in place (user files)
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
`ReconcileResult` extends `RenderResult` with a `cleanup: CleanupResult` field:
|
|
767
|
+
|
|
768
|
+
```typescript
|
|
769
|
+
interface ReconcileResult {
|
|
770
|
+
filesWritten: string[];
|
|
771
|
+
filesSkipped: number;
|
|
772
|
+
durationMs: number;
|
|
773
|
+
cleanup: {
|
|
774
|
+
directoriesRemoved: string[]; // absolute paths
|
|
775
|
+
filesRemoved: string[];
|
|
776
|
+
directoriesSkipped: string[]; // had user files — left in place
|
|
777
|
+
warnings: string[];
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
---
|
|
783
|
+
|
|
784
|
+
### Events
|
|
785
|
+
|
|
786
|
+
```typescript
|
|
787
|
+
db.on('audit', ({ table, operation, id, timestamp }) => void)
|
|
788
|
+
db.on('render', ({ filesWritten, filesSkipped, durationMs }) => void)
|
|
789
|
+
db.on('writeback', ({ filePath, entriesProcessed }) => void)
|
|
790
|
+
db.on('error', (err: Error) => void)
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
`audit` events fire on every insert/update/delete for tables listed in `security.auditTables`. Use them to build an audit log.
|
|
794
|
+
|
|
795
|
+
```typescript
|
|
796
|
+
db.on('audit', ({ table, operation, id, timestamp }) => {
|
|
797
|
+
console.log(`[AUDIT] ${operation} on ${table}#${id} at ${timestamp}`);
|
|
798
|
+
});
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
---
|
|
802
|
+
|
|
803
|
+
### Raw DB access
|
|
804
|
+
|
|
805
|
+
```typescript
|
|
806
|
+
db.db: Database.Database // better-sqlite3 instance
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
Escape hatch for queries Lattice doesn't cover (JOINs, aggregates, etc.):
|
|
810
|
+
|
|
811
|
+
```typescript
|
|
812
|
+
const rows = db.db
|
|
813
|
+
.prepare(
|
|
814
|
+
`
|
|
815
|
+
SELECT t.*, u.name AS assignee_name
|
|
816
|
+
FROM tasks t
|
|
817
|
+
LEFT JOIN users u ON u.id = t.assignee_id
|
|
818
|
+
WHERE t.status = ?
|
|
819
|
+
`,
|
|
820
|
+
)
|
|
821
|
+
.all('open');
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
---
|
|
825
|
+
|
|
826
|
+
## Template rendering
|
|
827
|
+
|
|
828
|
+
### Built-in templates
|
|
829
|
+
|
|
830
|
+
Pass a `BuiltinTemplateName` string as `render` to use a built-in template without writing a render function:
|
|
831
|
+
|
|
832
|
+
```typescript
|
|
833
|
+
db.define('users', {
|
|
834
|
+
columns: { id: 'TEXT PRIMARY KEY', name: 'TEXT', email: 'TEXT', role: 'TEXT' },
|
|
835
|
+
render: 'default-table', // or 'default-list' | 'default-detail' | 'default-json'
|
|
836
|
+
outputFile: 'USERS.md',
|
|
837
|
+
});
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
| Template | Output |
|
|
841
|
+
| ---------------- | --------------------------------------------------- |
|
|
842
|
+
| `default-list` | One bullet per row: `- key: value, key: value, ...` |
|
|
843
|
+
| `default-table` | GitHub-flavoured Markdown table with a header row |
|
|
844
|
+
| `default-detail` | `## <pk>` section per row with `key: value` body |
|
|
845
|
+
| `default-json` | `JSON.stringify(rows, null, 2)` |
|
|
846
|
+
|
|
847
|
+
All templates return empty string for zero rows.
|
|
848
|
+
|
|
849
|
+
---
|
|
850
|
+
|
|
851
|
+
### Lifecycle hooks
|
|
852
|
+
|
|
853
|
+
Add a `hooks` object to customise any built-in template:
|
|
854
|
+
|
|
855
|
+
```typescript
|
|
856
|
+
db.define('tasks', {
|
|
857
|
+
columns: { id: 'TEXT PRIMARY KEY', title: 'TEXT', status: 'TEXT', priority: 'INTEGER' },
|
|
858
|
+
render: {
|
|
859
|
+
template: 'default-list',
|
|
860
|
+
hooks: {
|
|
861
|
+
// Transform or filter rows before rendering
|
|
862
|
+
beforeRender: (rows) =>
|
|
863
|
+
rows
|
|
864
|
+
.filter((r) => r.status !== 'done')
|
|
865
|
+
.sort((a, b) => (b.priority as number) - (a.priority as number)),
|
|
866
|
+
|
|
867
|
+
// Customise how each row becomes a line
|
|
868
|
+
formatRow: '{{title}} [priority {{priority}}]',
|
|
869
|
+
},
|
|
870
|
+
},
|
|
871
|
+
outputFile: 'TASKS.md',
|
|
872
|
+
});
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
| Hook | Applies to | Type |
|
|
876
|
+
| -------------------- | -------------------------------- | ---------------------------------- |
|
|
877
|
+
| `beforeRender(rows)` | All templates | `(rows: Row[]) => Row[]` |
|
|
878
|
+
| `formatRow` | `default-list`, `default-detail` | `((row: Row) => string) \| string` |
|
|
879
|
+
|
|
880
|
+
`formatRow` can be a function or a `{{field}}` template string. When it's a string, `belongsTo` relation fields are resolved and available as `{{relationName.field}}`.
|
|
881
|
+
|
|
882
|
+
---
|
|
883
|
+
|
|
884
|
+
### Field interpolation
|
|
885
|
+
|
|
886
|
+
Any `formatRow` string supports `{{field}}` tokens with dot-notation for related rows:
|
|
887
|
+
|
|
888
|
+
```typescript
|
|
889
|
+
db.define('users', {
|
|
890
|
+
columns: { id: 'TEXT PRIMARY KEY', name: 'TEXT', team: 'TEXT' },
|
|
891
|
+
render: 'default-list',
|
|
892
|
+
outputFile: 'USERS.md',
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
db.define('tickets', {
|
|
896
|
+
columns: {
|
|
897
|
+
id: 'TEXT PRIMARY KEY',
|
|
898
|
+
title: 'TEXT',
|
|
899
|
+
assignee_id: 'TEXT',
|
|
900
|
+
status: 'TEXT',
|
|
901
|
+
},
|
|
902
|
+
relations: {
|
|
903
|
+
assignee: { type: 'belongsTo', table: 'users', foreignKey: 'assignee_id' },
|
|
904
|
+
},
|
|
905
|
+
render: {
|
|
906
|
+
template: 'default-list',
|
|
907
|
+
hooks: {
|
|
908
|
+
formatRow: '{{title}} → {{assignee.name}} ({{status}})',
|
|
909
|
+
},
|
|
910
|
+
},
|
|
911
|
+
outputFile: 'TICKETS.md',
|
|
912
|
+
});
|
|
913
|
+
// Output line: "- Fix login → Alice (open)"
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
**Rules:**
|
|
917
|
+
|
|
918
|
+
- `{{field}}` — value of `field` in the current row
|
|
919
|
+
- `{{relation.field}}` — value of `field` in the related row (resolved via `belongsTo`)
|
|
920
|
+
- Unknown paths, `null`, and `undefined` all render as empty string
|
|
921
|
+
- Non-string values are coerced with `String()`
|
|
922
|
+
- Leading/trailing whitespace in token names is trimmed: `{{ name }}` works
|
|
923
|
+
|
|
924
|
+
---
|
|
925
|
+
|
|
926
|
+
## Entity context directories (v0.5+)
|
|
927
|
+
|
|
928
|
+
`defineEntityContext()` is the high-level API for per-entity file generation — the pattern where each entity type gets its own directory tree, with a separate file for each relationship type.
|
|
929
|
+
|
|
930
|
+
### Why use it instead of `defineMulti()`?
|
|
931
|
+
|
|
932
|
+
`defineMulti()` produces one file per anchor entity but you manage queries yourself. `defineEntityContext()` declares the _structure_ — which tables to pull, how to render them, what budget to enforce — and Lattice handles all the querying, directory creation, hash-skip deduplication, and orphan cleanup.
|
|
933
|
+
|
|
934
|
+
### Minimal example
|
|
935
|
+
|
|
936
|
+
```typescript
|
|
937
|
+
db.defineEntityContext('projects', {
|
|
938
|
+
slug: (r) => r.slug as string,
|
|
939
|
+
files: {
|
|
940
|
+
'PROJECT.md': {
|
|
941
|
+
source: { type: 'self' },
|
|
942
|
+
render: ([r]) => `# ${r.name as string}\n\n${r.description as string ?? ''}`,
|
|
943
|
+
},
|
|
944
|
+
},
|
|
945
|
+
});
|
|
946
|
+
```
|
|
947
|
+
|
|
948
|
+
After `db.render('./ctx')` this creates:
|
|
949
|
+
|
|
950
|
+
```
|
|
951
|
+
ctx/
|
|
952
|
+
└── projects/
|
|
953
|
+
├── my-project/
|
|
954
|
+
│ └── PROJECT.md
|
|
955
|
+
└── another-project/
|
|
956
|
+
└── PROJECT.md
|
|
957
|
+
```
|
|
958
|
+
|
|
959
|
+
### Lifecycle — orphan cleanup
|
|
960
|
+
|
|
961
|
+
When you delete an entity from the database the old directory becomes an orphan. Use `reconcile()` to clean it up:
|
|
962
|
+
|
|
963
|
+
```typescript
|
|
964
|
+
await db.delete('projects', 'old-id');
|
|
965
|
+
|
|
966
|
+
const result = await db.reconcile('./ctx', {
|
|
967
|
+
removeOrphanedDirectories: true,
|
|
968
|
+
protectedFiles: ['NOTES.md'], // agents wrote these — keep them
|
|
969
|
+
});
|
|
970
|
+
// result.cleanup.directoriesRemoved → ['/.../ctx/projects/old-project']
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
Lattice writes a `.lattice/manifest.json` inside `outputDir` after every render cycle — this is what `reconcile()` uses to know which directories it owns and what it previously wrote in each.
|
|
974
|
+
|
|
975
|
+
### Protected files
|
|
976
|
+
|
|
977
|
+
Declare files that agents write inside entity directories. Lattice will never delete them during cleanup:
|
|
978
|
+
|
|
979
|
+
```typescript
|
|
980
|
+
db.defineEntityContext('agents', {
|
|
981
|
+
slug: (r) => r.slug as string,
|
|
982
|
+
protectedFiles: ['SESSION.md', 'NOTES.md'],
|
|
983
|
+
files: { /* ... */ },
|
|
984
|
+
});
|
|
985
|
+
```
|
|
986
|
+
|
|
987
|
+
If an entity is deleted and its directory still contains `SESSION.md`, Lattice removes only its own managed files, leaves the directory in place, and adds a warning to `CleanupResult.warnings`.
|
|
988
|
+
|
|
989
|
+
### Reading the manifest
|
|
990
|
+
|
|
991
|
+
```typescript
|
|
992
|
+
import { readManifest } from 'latticesql';
|
|
993
|
+
|
|
994
|
+
const manifest = readManifest('./ctx');
|
|
995
|
+
// manifest?.entityContexts.agents.entities['alpha']
|
|
996
|
+
// → ['AGENT.md', 'TASKS.md', 'CONTEXT.md'] (files written last cycle for agent 'alpha')
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
See [docs/entity-context.md](./docs/entity-context.md) for the complete reference.
|
|
1000
|
+
|
|
1001
|
+
---
|
|
1002
|
+
|
|
1003
|
+
## YAML config (v0.4+)
|
|
1004
|
+
|
|
1005
|
+
Define your entire schema in a YAML file. Lattice reads it at construction time, creates all tables on `init()`, and wires render functions automatically.
|
|
1006
|
+
|
|
1007
|
+
### `lattice.config.yml` reference
|
|
1008
|
+
|
|
1009
|
+
```yaml
|
|
1010
|
+
# Path to the SQLite database file (relative to this config file)
|
|
1011
|
+
db: ./data/app.db
|
|
1012
|
+
|
|
1013
|
+
entities:
|
|
1014
|
+
# ── Entity name = table name ──────────────────────────────────────────────
|
|
1015
|
+
user:
|
|
1016
|
+
fields:
|
|
1017
|
+
id: { type: uuid, primaryKey: true } # auto-UUID on insert
|
|
1018
|
+
name: { type: text, required: true } # NOT NULL
|
|
1019
|
+
email: { type: text } # nullable
|
|
1020
|
+
score: { type: integer, default: 0 } # DEFAULT 0
|
|
1021
|
+
render: default-table
|
|
1022
|
+
outputFile: context/USERS.md
|
|
1023
|
+
|
|
1024
|
+
ticket:
|
|
1025
|
+
fields:
|
|
1026
|
+
id: { type: uuid, primaryKey: true }
|
|
1027
|
+
title: { type: text, required: true }
|
|
1028
|
+
status: { type: text, default: open }
|
|
1029
|
+
priority: { type: integer, default: 1 }
|
|
1030
|
+
assignee_id: { type: uuid, ref: user } # creates belongsTo relation
|
|
1031
|
+
render:
|
|
1032
|
+
template: default-list
|
|
1033
|
+
formatRow: '{{title}} ({{status}}) — {{assignee.name}}'
|
|
1034
|
+
outputFile: context/TICKETS.md
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
**Field types**
|
|
1038
|
+
|
|
1039
|
+
| YAML type | SQLite type | TypeScript type |
|
|
1040
|
+
| ---------- | ----------- | --------------- |
|
|
1041
|
+
| `uuid` | TEXT | `string` |
|
|
1042
|
+
| `text` | TEXT | `string` |
|
|
1043
|
+
| `integer` | INTEGER | `number` |
|
|
1044
|
+
| `int` | INTEGER | `number` |
|
|
1045
|
+
| `real` | REAL | `number` |
|
|
1046
|
+
| `float` | REAL | `number` |
|
|
1047
|
+
| `boolean` | INTEGER | `boolean` |
|
|
1048
|
+
| `bool` | INTEGER | `boolean` |
|
|
1049
|
+
| `datetime` | TEXT | `string` |
|
|
1050
|
+
| `date` | TEXT | `string` |
|
|
1051
|
+
| `blob` | BLOB | `Buffer` |
|
|
1052
|
+
|
|
1053
|
+
**Field options**
|
|
1054
|
+
|
|
1055
|
+
| Option | Type | Description |
|
|
1056
|
+
| ------------ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1057
|
+
| `type` | `LatticeFieldType` | Column data type (required) |
|
|
1058
|
+
| `primaryKey` | boolean | Primary key column (`TEXT PRIMARY KEY` for uuid/text) |
|
|
1059
|
+
| `required` | boolean | `NOT NULL` constraint |
|
|
1060
|
+
| `default` | string/number/bool | SQL `DEFAULT` value |
|
|
1061
|
+
| `ref` | string | Foreign-key reference to another entity. Creates a `belongsTo` relation; `_id` suffix is stripped from the relation name (`assignee_id` → `assignee`) |
|
|
1062
|
+
|
|
1063
|
+
**Entity-level options**
|
|
1064
|
+
|
|
1065
|
+
| Option | Type | Description |
|
|
1066
|
+
| ------------ | -------------------------- | ------------------------------------------------------------------ |
|
|
1067
|
+
| `fields` | `Record<string, FieldDef>` | Column definitions (required) |
|
|
1068
|
+
| `render` | string or object | Built-in template name, or `{ template, formatRow }` |
|
|
1069
|
+
| `outputFile` | string | Render output path (relative to config file) |
|
|
1070
|
+
| `primaryKey` | string or string[] | Override PK — takes precedence over field-level `primaryKey: true` |
|
|
1071
|
+
|
|
1072
|
+
**Render spec forms in YAML:**
|
|
1073
|
+
|
|
1074
|
+
```yaml
|
|
1075
|
+
# String form — plain BuiltinTemplateName
|
|
1076
|
+
render: default-table
|
|
1077
|
+
|
|
1078
|
+
# Object form — template + formatRow hook
|
|
1079
|
+
render:
|
|
1080
|
+
template: default-list
|
|
1081
|
+
formatRow: "{{title}} ({{status}})"
|
|
1082
|
+
```
|
|
1083
|
+
|
|
1084
|
+
---
|
|
1085
|
+
|
|
1086
|
+
### Init from config
|
|
1087
|
+
|
|
1088
|
+
```typescript
|
|
1089
|
+
import { Lattice } from 'latticesql';
|
|
1090
|
+
|
|
1091
|
+
const db = new Lattice({ config: './lattice.config.yml' });
|
|
1092
|
+
await db.init();
|
|
1093
|
+
|
|
1094
|
+
// All entities are available immediately
|
|
1095
|
+
await db.insert('user', { name: 'Alice', email: 'alice@example.com' });
|
|
1096
|
+
await db.insert('ticket', { title: 'Fix login', assignee_id: 'u-1' });
|
|
1097
|
+
|
|
1098
|
+
const tickets = await db.query('ticket', { where: { status: 'open' } });
|
|
1099
|
+
await db.render('./context');
|
|
1100
|
+
```
|
|
1101
|
+
|
|
1102
|
+
The `{ config }` constructor reads the YAML file synchronously, extracts the `db` path, and calls `define()` for each entity. It is exactly equivalent to:
|
|
1103
|
+
|
|
1104
|
+
```typescript
|
|
1105
|
+
// Equivalent manual setup (no YAML)
|
|
1106
|
+
const db = new Lattice('./data/app.db');
|
|
1107
|
+
db.define('user', { columns: { ... }, render: 'default-table', outputFile: '...' });
|
|
1108
|
+
db.define('ticket', { columns: { ... }, render: { ... }, outputFile: '...' });
|
|
1109
|
+
await db.init();
|
|
1110
|
+
```
|
|
1111
|
+
|
|
1112
|
+
---
|
|
1113
|
+
|
|
1114
|
+
### Config API (programmatic)
|
|
1115
|
+
|
|
1116
|
+
Parse a config file or string without constructing a Lattice instance:
|
|
1117
|
+
|
|
1118
|
+
```typescript
|
|
1119
|
+
import { parseConfigFile, parseConfigString } from 'latticesql';
|
|
1120
|
+
|
|
1121
|
+
// From a file (throws on missing/invalid file or YAML parse error)
|
|
1122
|
+
const { dbPath, tables } = parseConfigFile('./lattice.config.yml');
|
|
1123
|
+
|
|
1124
|
+
// From a YAML string — configDir is used to resolve relative outputFile paths
|
|
1125
|
+
const { tables } = parseConfigString(yamlContent, '/project/root');
|
|
1126
|
+
|
|
1127
|
+
// Wire into any Lattice instance manually
|
|
1128
|
+
const db = new Lattice(':memory:');
|
|
1129
|
+
for (const { name, definition } of tables) {
|
|
1130
|
+
db.define(name, definition);
|
|
1131
|
+
}
|
|
1132
|
+
await db.init();
|
|
1133
|
+
```
|
|
1134
|
+
|
|
1135
|
+
`ParsedConfig`:
|
|
1136
|
+
|
|
1137
|
+
```typescript
|
|
1138
|
+
interface ParsedConfig {
|
|
1139
|
+
dbPath: string; // Absolute path to the SQLite file
|
|
1140
|
+
tables: ReadonlyArray<{ name: string; definition: TableDefinition }>;
|
|
1141
|
+
}
|
|
1142
|
+
```
|
|
1143
|
+
|
|
1144
|
+
---
|
|
1145
|
+
|
|
1146
|
+
## CLI — `lattice generate`
|
|
1147
|
+
|
|
1148
|
+
Generate TypeScript interfaces, an initial SQL migration file, and optional scaffold files from a YAML config.
|
|
1149
|
+
|
|
1150
|
+
```bash
|
|
1151
|
+
npx lattice generate
|
|
1152
|
+
|
|
1153
|
+
# With options
|
|
1154
|
+
npx lattice generate --config ./lattice.config.yml --out ./generated --scaffold
|
|
1155
|
+
```
|
|
1156
|
+
|
|
1157
|
+
**Options**
|
|
1158
|
+
|
|
1159
|
+
| Flag | Default | Description |
|
|
1160
|
+
| --------------------- | ---------------------- | ---------------------------------------------------------------------------- |
|
|
1161
|
+
| `--config, -c <path>` | `./lattice.config.yml` | Path to the config file |
|
|
1162
|
+
| `--out, -o <dir>` | `./generated` | Output directory |
|
|
1163
|
+
| `--scaffold` | off | Create empty files at each entity's `outputFile` path (skips existing files) |
|
|
1164
|
+
| `--help, -h` | — | Show help |
|
|
1165
|
+
| `--version, -v` | — | Print version |
|
|
1166
|
+
|
|
1167
|
+
**Output structure**
|
|
1168
|
+
|
|
1169
|
+
```
|
|
1170
|
+
generated/
|
|
1171
|
+
├── types.ts # TypeScript interface per entity
|
|
1172
|
+
└── migrations/
|
|
1173
|
+
└── 0001_initial.sql # CREATE TABLE IF NOT EXISTS statements
|
|
1174
|
+
```
|
|
1175
|
+
|
|
1176
|
+
**Example output — `generated/types.ts`:**
|
|
1177
|
+
|
|
1178
|
+
```typescript
|
|
1179
|
+
// Auto-generated by `lattice generate`. Do not edit manually.
|
|
1180
|
+
|
|
1181
|
+
export interface User {
|
|
1182
|
+
id: string;
|
|
1183
|
+
name: string; // required: true → no ?
|
|
1184
|
+
email?: string;
|
|
1185
|
+
score?: number;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
export interface Ticket {
|
|
1189
|
+
id: string;
|
|
1190
|
+
title: string;
|
|
1191
|
+
status?: string;
|
|
1192
|
+
priority?: number;
|
|
1193
|
+
assignee_id?: string; // → user
|
|
1194
|
+
}
|
|
1195
|
+
```
|
|
1196
|
+
|
|
1197
|
+
**Example output — `generated/migrations/0001_initial.sql`:**
|
|
1198
|
+
|
|
1199
|
+
```sql
|
|
1200
|
+
-- Auto-generated by `lattice generate`. Do not edit manually.
|
|
1201
|
+
|
|
1202
|
+
CREATE TABLE IF NOT EXISTS "user" (
|
|
1203
|
+
"id" TEXT PRIMARY KEY,
|
|
1204
|
+
"name" TEXT NOT NULL,
|
|
1205
|
+
"email" TEXT,
|
|
1206
|
+
"score" INTEGER DEFAULT 0
|
|
1207
|
+
);
|
|
1208
|
+
|
|
1209
|
+
CREATE TABLE IF NOT EXISTS "ticket" (
|
|
1210
|
+
"id" TEXT PRIMARY KEY,
|
|
1211
|
+
"title" TEXT NOT NULL,
|
|
1212
|
+
"status" TEXT DEFAULT 'open',
|
|
1213
|
+
"priority" INTEGER DEFAULT 1,
|
|
1214
|
+
"assignee_id" TEXT
|
|
1215
|
+
);
|
|
1216
|
+
```
|
|
1217
|
+
|
|
1218
|
+
---
|
|
1219
|
+
|
|
1220
|
+
## Schema migrations
|
|
1221
|
+
|
|
1222
|
+
Lattice auto-creates tables and adds missing columns on every `init()` — you never need to manually write `CREATE TABLE` or `ALTER TABLE ADD COLUMN` for schema evolution.
|
|
1223
|
+
|
|
1224
|
+
For changes that require data transformation (renaming a column, dropping a column, changing a type), use the `migrations` option:
|
|
1225
|
+
|
|
1226
|
+
```typescript
|
|
1227
|
+
await db.init({
|
|
1228
|
+
migrations: [
|
|
1229
|
+
// version 1: rename 'notes' → 'description'
|
|
1230
|
+
{
|
|
1231
|
+
version: 1,
|
|
1232
|
+
sql: `
|
|
1233
|
+
ALTER TABLE tasks ADD COLUMN description TEXT;
|
|
1234
|
+
UPDATE tasks SET description = notes;
|
|
1235
|
+
`,
|
|
1236
|
+
},
|
|
1237
|
+
// version 2: add index
|
|
1238
|
+
{
|
|
1239
|
+
version: 2,
|
|
1240
|
+
sql: `CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks (status)`,
|
|
1241
|
+
},
|
|
1242
|
+
// version 3: add computed default via UPDATE
|
|
1243
|
+
{
|
|
1244
|
+
version: 3,
|
|
1245
|
+
sql: `UPDATE tasks SET priority = 1 WHERE priority IS NULL`,
|
|
1246
|
+
},
|
|
1247
|
+
],
|
|
1248
|
+
});
|
|
1249
|
+
```
|
|
1250
|
+
|
|
1251
|
+
Migrations are applied in ascending `version` order. Each version is applied at most once, tracked in a `__lattice_migrations` internal table. Safe to call `init()` multiple times across restarts — already-applied migrations are skipped.
|
|
1252
|
+
|
|
1253
|
+
For the YAML config workflow, generate the initial migration with `lattice generate` and add subsequent migrations manually to the `migrations/` directory (Lattice doesn't manage multi-file migrations — that's intentionally left to external tools like `flyway` or `dbmate` if you need it).
|
|
1254
|
+
|
|
1255
|
+
See [docs/migrations.md](./docs/migrations.md) for a step-by-step migration workflow.
|
|
1256
|
+
|
|
1257
|
+
---
|
|
1258
|
+
|
|
1259
|
+
## Security
|
|
1260
|
+
|
|
1261
|
+
**Input sanitization** — enabled by default. Strips control characters from string columns before storing. Disable per-instance with `security: { sanitize: false }`.
|
|
1262
|
+
|
|
1263
|
+
**Audit events** — declare which tables emit audit events:
|
|
1264
|
+
|
|
1265
|
+
```typescript
|
|
1266
|
+
const db = new Lattice('./app.db', {
|
|
1267
|
+
security: { auditTables: ['users', 'api_keys'] },
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
db.on('audit', ({ table, operation, id, timestamp }) => {
|
|
1271
|
+
auditLog.write({ table, operation, id, timestamp });
|
|
1272
|
+
});
|
|
1273
|
+
```
|
|
1274
|
+
|
|
1275
|
+
**Field limits** — cap string lengths per column:
|
|
1276
|
+
|
|
1277
|
+
```typescript
|
|
1278
|
+
const db = new Lattice('./app.db', {
|
|
1279
|
+
security: { fieldLimits: { notes: 50_000, bio: 500 } },
|
|
1280
|
+
});
|
|
1281
|
+
```
|
|
1282
|
+
|
|
1283
|
+
**SQL injection** — all values are passed as bound parameters; no user input is ever interpolated into SQL strings.
|
|
1284
|
+
|
|
1285
|
+
---
|
|
1286
|
+
|
|
1287
|
+
## Architecture
|
|
1288
|
+
|
|
1289
|
+
```
|
|
1290
|
+
┌──────────────────────────────────────────────────────────────────────┐
|
|
1291
|
+
│ Lattice class │
|
|
1292
|
+
│ define() / defineMulti() / defineEntityContext() / defineWriteback()│
|
|
1293
|
+
│ CRUD: insert / upsert / upsertBy / update / delete │
|
|
1294
|
+
│ Query: get / query / count │
|
|
1295
|
+
│ Render: render / sync / watch / reconcile │
|
|
1296
|
+
├──────────────────┬──────────────────┬───────────────────────────────┤
|
|
1297
|
+
│ SchemaManager │ RenderEngine │ WritebackPipeline │
|
|
1298
|
+
│ │ │ │
|
|
1299
|
+
│ Stores table, │ Queries rows → │ Watches output files for │
|
|
1300
|
+
│ multi-table, and │ render → atomic │ new agent-written content, │
|
|
1301
|
+
│ entity context │ write. Writes │ calls parse() + persist() │
|
|
1302
|
+
│ definitions │ manifest after │ │
|
|
1303
|
+
│ │ entity contexts │ │
|
|
1304
|
+
├──────────────────┴──────────────────┴───────────────────────────────┤
|
|
1305
|
+
│ SQLiteAdapter │
|
|
1306
|
+
│ (better-sqlite3 — synchronous I/O) │
|
|
1307
|
+
└──────────────────────────────────────────────────────────────────────┘
|
|
1308
|
+
│ │
|
|
1309
|
+
│ compileRender() │ lifecycle/
|
|
1310
|
+
│ at define()-time │ manifest.ts ← readManifest/writeManifest
|
|
1311
|
+
▼ │ cleanup.ts ← cleanupEntityContexts
|
|
1312
|
+
┌────────────────────────┐ ▼
|
|
1313
|
+
│ render/templates.ts │ ┌────────────────────────┐
|
|
1314
|
+
│ • compileRender(spec) │ │ render/entity-query.ts│
|
|
1315
|
+
│ • _enrichRow() │ │ • resolveEntitySource │
|
|
1316
|
+
│ • renderList/Table/ │ │ (self/hasMany/m2m/ │
|
|
1317
|
+
│ Detail/Json │ │ belongsTo/custom) │
|
|
1318
|
+
│ • interpolate() │ │ • truncateContent() │
|
|
1319
|
+
└────────────────────────┘ └────────────────────────┘
|
|
1320
|
+
```
|
|
1321
|
+
|
|
1322
|
+
**Key design decisions:**
|
|
1323
|
+
|
|
1324
|
+
- **Synchronous SQLite** — `better-sqlite3` gives synchronous reads; all Lattice CRUD methods return Promises for API consistency but resolve synchronously under the hood.
|
|
1325
|
+
- **Compile-time render** — `RenderSpec` is compiled to a plain `(rows: Row[]) => string` function at `define()`-time, not at render-time. `RenderEngine` stays unchanged.
|
|
1326
|
+
- **Atomic writes** — files are written to a `.tmp` sibling then renamed. No partial writes, no reader sees incomplete content.
|
|
1327
|
+
- **Schema-additive only** — Lattice never drops tables or columns automatically; it only adds missing ones.
|
|
1328
|
+
- **Manifest-driven cleanup** — `reconcile()` compares the previous manifest (what Lattice wrote last cycle) against the current DB state and the new manifest (what was written this cycle) to safely remove orphaned directories and stale files.
|
|
1329
|
+
|
|
1330
|
+
See [docs/architecture.md](./docs/architecture.md) for a deeper walkthrough.
|
|
1331
|
+
|
|
1332
|
+
---
|
|
1333
|
+
|
|
1334
|
+
## Examples
|
|
1335
|
+
|
|
1336
|
+
Three complete, commented examples are in [docs/examples/](./docs/examples/):
|
|
1337
|
+
|
|
1338
|
+
| Example | Description |
|
|
1339
|
+
| --------------------------------------------------- | ----------------------------------------------------------- |
|
|
1340
|
+
| [Agent system](./docs/examples/agent-system.md) | Multi-agent context management with per-agent context files |
|
|
1341
|
+
| [Ticket tracker](./docs/examples/ticket-tracker.md) | Project management system with relationships and templates |
|
|
1342
|
+
| [CMS](./docs/examples/cms.md) | Content management with writeback pipeline for agent edits |
|
|
1343
|
+
|
|
1344
|
+
---
|
|
1345
|
+
|
|
1346
|
+
## Contributing
|
|
1347
|
+
|
|
1348
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) for dev setup, test commands, and contribution guidelines.
|
|
1349
|
+
|
|
1350
|
+
---
|
|
1351
|
+
|
|
1352
|
+
## Changelog
|
|
1353
|
+
|
|
1354
|
+
See [CHANGELOG.md](./CHANGELOG.md) for the full history.
|
|
1355
|
+
|
|
1356
|
+
---
|
|
1357
|
+
|
|
1358
|
+
## License
|
|
1359
|
+
|
|
1360
|
+
[Apache 2.0](./LICENSE) — includes explicit patent grant (Section 3).
|