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/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
+ [![npm version](https://img.shields.io/npm/v/latticesql.svg)](https://www.npmjs.com/package/latticesql)
6
+ [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](./LICENSE)
7
+ [![Node.js >=18](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](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).