latticesql 3.1.0 → 3.2.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.
@@ -0,0 +1,510 @@
1
+ # Entity Context Directories
2
+
3
+ Entity context directories are a high-level API for generating a parallel file-system tree that mirrors your database — one directory per entity, one file per relationship type. They replace ad-hoc `defineMulti()` patterns for per-entity context generation.
4
+
5
+ ## Overview
6
+
7
+ When an agent system manages many entities (agents, projects, users, tickets…), each entity often needs its own context directory:
8
+
9
+ ```
10
+ context/
11
+ ├── agents/
12
+ │ ├── AGENTS.md ← index listing all agents
13
+ │ ├── alpha/
14
+ │ │ ├── AGENT.md ← the agent row itself
15
+ │ │ ├── TASKS.md ← tasks assigned to Alpha
16
+ │ │ ├── SKILLS.md ← skills via junction table
17
+ │ │ └── CONTEXT.md ← combined file (all of the above)
18
+ │ └── craft/
19
+ │ ├── AGENT.md
20
+ │ └── CONTEXT.md
21
+ └── .lattice/
22
+ └── manifest.json ← tracks what Lattice generated
23
+ ```
24
+
25
+ Without entity context directories, you'd build this with `defineMulti()` — one definition per output file, many definitions per entity type. Entity context directories collapse the entire pattern into a single `defineEntityContext()` call.
26
+
27
+ ## Basic Example
28
+
29
+ ```ts
30
+ import { Lattice } from 'latticesql';
31
+
32
+ const db = new Lattice('./data/app.db');
33
+
34
+ db.define('agent', {
35
+ columns: {
36
+ id: 'TEXT PRIMARY KEY',
37
+ slug: 'TEXT NOT NULL',
38
+ name: 'TEXT NOT NULL',
39
+ bio: 'TEXT',
40
+ },
41
+ render: 'default-list',
42
+ outputFile: 'agents-flat.md',
43
+ });
44
+
45
+ db.define('task', {
46
+ columns: {
47
+ id: 'TEXT PRIMARY KEY',
48
+ agent_id: 'TEXT',
49
+ title: 'TEXT NOT NULL',
50
+ status: 'TEXT DEFAULT "open"',
51
+ },
52
+ render: 'default-list',
53
+ outputFile: 'tasks-flat.md',
54
+ });
55
+
56
+ db.defineEntityContext('agent', {
57
+ slug: (row) => row.slug as string,
58
+
59
+ index: {
60
+ outputFile: 'AGENTS.md',
61
+ render: (rows) => rows.map((r) => `- [${r.name}](${r.slug}/)`).join('\n'),
62
+ },
63
+
64
+ files: [
65
+ {
66
+ filename: 'AGENT.md',
67
+ source: { type: 'self' },
68
+ render: (rows) => {
69
+ const agent = rows[0]!;
70
+ return `# ${agent.name}\n\n${agent.bio ?? '_No bio._'}`;
71
+ },
72
+ },
73
+ {
74
+ filename: 'TASKS.md',
75
+ source: { type: 'hasMany', table: 'task', foreignKey: 'agent_id' },
76
+ render: (rows) => rows.map((r) => `- [ ] ${r.title}`).join('\n'),
77
+ omitIfEmpty: true,
78
+ },
79
+ ],
80
+
81
+ combined: {
82
+ outputFile: 'CONTEXT.md',
83
+ },
84
+
85
+ directoryRoot: 'agents',
86
+ protectedFiles: ['SESSION.md'],
87
+ });
88
+
89
+ await db.init();
90
+ await db.render('./context');
91
+ ```
92
+
93
+ ## Source Types
94
+
95
+ The `source` field on each `EntityFileSpec` determines how rows are resolved for that file.
96
+
97
+ ### `self`
98
+
99
+ The entity row itself. Always exactly one row.
100
+
101
+ ```ts
102
+ {
103
+ filename: 'AGENT.md',
104
+ source: { type: 'self' },
105
+ render: (rows) => `# ${rows[0]!.name}`,
106
+ }
107
+ ```
108
+
109
+ ### `hasMany`
110
+
111
+ Rows on a related table where a foreign key points back to this entity.
112
+
113
+ ```ts
114
+ {
115
+ filename: 'TASKS.md',
116
+ source: {
117
+ type: 'hasMany',
118
+ table: 'task',
119
+ foreignKey: 'agent_id', // FK column on the task table
120
+ references: 'id', // optional — PK on the agent table (default: agent's first PK)
121
+ },
122
+ render: (rows) => rows.map((r) => `- ${r.title}`).join('\n'),
123
+ }
124
+ ```
125
+
126
+ SQL equivalent: `SELECT * FROM task WHERE agent_id = :entityPk`
127
+
128
+ ### `manyToMany`
129
+
130
+ Rows from a remote table via a junction table.
131
+
132
+ ```ts
133
+ {
134
+ filename: 'SKILLS.md',
135
+ source: {
136
+ type: 'manyToMany',
137
+ junctionTable: 'agent_skill',
138
+ localKey: 'agent_id', // FK to agent on the junction table
139
+ remoteKey: 'skill_id', // FK to skill on the junction table
140
+ remoteTable: 'skill', // table to fetch rows from
141
+ references: 'id', // optional — PK on the skill table
142
+ },
143
+ render: (rows) => rows.map((r) => `- ${r.name}`).join('\n'),
144
+ }
145
+ ```
146
+
147
+ SQL equivalent: `SELECT skill.* FROM agent_skill JOIN skill ON skill.id = agent_skill.skill_id WHERE agent_skill.agent_id = :entityPk`
148
+
149
+ ### `belongsTo`
150
+
151
+ A single parent row accessed via a foreign key on this entity.
152
+
153
+ ```ts
154
+ {
155
+ filename: 'ORG.md',
156
+ source: {
157
+ type: 'belongsTo',
158
+ table: 'org',
159
+ foreignKey: 'org_id', // FK column on THIS entity's table
160
+ references: 'id', // optional — PK on the org table
161
+ },
162
+ render: (rows) => rows.length ? `Org: ${rows[0]!.name}` : '_No org._',
163
+ }
164
+ ```
165
+
166
+ SQL equivalent: `SELECT * FROM org WHERE id = :entityRow.org_id`
167
+
168
+ ### `custom`
169
+
170
+ A fully custom synchronous query using the raw `StorageAdapter`.
171
+
172
+ ```ts
173
+ {
174
+ filename: 'RECENT.md',
175
+ source: {
176
+ type: 'custom',
177
+ query: (row, adapter) => {
178
+ return adapter.query('event', {
179
+ where: { agent_id: row.id },
180
+ orderBy: 'created_at',
181
+ orderDir: 'DESC',
182
+ limit: 10,
183
+ });
184
+ },
185
+ },
186
+ render: (rows) => rows.map((r) => `- ${r.description}`).join('\n'),
187
+ }
188
+ ```
189
+
190
+ ## `omitIfEmpty`
191
+
192
+ When a source resolves zero rows, Lattice normally writes an empty file. Set `omitIfEmpty: true` to skip writing the file entirely.
193
+
194
+ ```ts
195
+ {
196
+ filename: 'TASKS.md',
197
+ source: { type: 'hasMany', table: 'task', foreignKey: 'agent_id' },
198
+ render: (rows) => rows.map((r) => `- ${r.title}`).join('\n'),
199
+ omitIfEmpty: true, // skip file if no tasks
200
+ }
201
+ ```
202
+
203
+ If the entity previously had tasks and the `TASKS.md` file existed, enabling `omitIfEmpty` alone does not delete it. Pair with lifecycle cleanup to remove stale files — see [Lifecycle Management](#lifecycle-management).
204
+
205
+ ## `budget`
206
+
207
+ Limit the rendered output of a file to a maximum number of characters. When the content exceeds the budget, it is truncated with a notice:
208
+
209
+ ```
210
+ *[truncated — context budget exceeded]*
211
+ ```
212
+
213
+ ```ts
214
+ {
215
+ filename: 'NOTES.md',
216
+ source: { type: 'hasMany', table: 'note', foreignKey: 'agent_id' },
217
+ render: (rows) => rows.map((r) => r.body).join('\n\n'),
218
+ budget: 4000,
219
+ }
220
+ ```
221
+
222
+ ## Combined file
223
+
224
+ The `combined` option writes a single file per entity that concatenates all rendered files (joined with `\n\n---\n\n`). This is useful for LLM context injection where you want one file per entity.
225
+
226
+ ```ts
227
+ db.defineEntityContext('agent', {
228
+ slug: (row) => row.slug as string,
229
+ files: [
230
+ { filename: 'AGENT.md', source: { type: 'self' }, render: renderAgent },
231
+ { filename: 'TASKS.md', source: { type: 'hasMany', ... }, render: renderTasks },
232
+ ],
233
+ combined: {
234
+ outputFile: 'CONTEXT.md',
235
+ exclude: ['TASKS.md'], // optional — skip TASKS.md from combined output
236
+ },
237
+ });
238
+ ```
239
+
240
+ Files skipped via `omitIfEmpty` are automatically excluded from the combined output.
241
+
242
+ ## Index file
243
+
244
+ The `index` option writes one file at the `directoryRoot` level (not inside per-entity subdirectories) with a listing of all entities.
245
+
246
+ ```ts
247
+ db.defineEntityContext('agent', {
248
+ slug: (row) => row.slug as string,
249
+ index: {
250
+ outputFile: 'AGENTS.md',
251
+ render: (rows) => rows.map((r) => `- [${r.name}](${r.slug}/CONTEXT.md)`).join('\n'),
252
+ },
253
+ files: [...],
254
+ directoryRoot: 'agents',
255
+ });
256
+ ```
257
+
258
+ The `render` function receives **all entity rows** for the table — not per-entity rows.
259
+
260
+ ## Custom directory path
261
+
262
+ By default Lattice writes each entity to `{directoryRoot}/{slug(row)}/`. Override with the `directory` function:
263
+
264
+ ```ts
265
+ db.defineEntityContext('project', {
266
+ slug: (row) => row.slug as string,
267
+ directory: (row) => `projects/${row.org_slug}/${row.slug}`,
268
+ files: [...],
269
+ });
270
+ ```
271
+
272
+ ## `protectedFiles`
273
+
274
+ Files listed in `protectedFiles` are never deleted by Lattice's cleanup, even if they appear to be orphaned. Use this for files that agents write into entity directories:
275
+
276
+ ```ts
277
+ db.defineEntityContext('agent', {
278
+ slug: (row) => row.slug as string,
279
+ files: [...],
280
+ protectedFiles: ['SESSION.md', 'NOTES.md'],
281
+ });
282
+ ```
283
+
284
+ Protected files are recorded in the manifest so the protection survives across restarts.
285
+
286
+ ## Reverse-Sync (v0.16+)
287
+
288
+ In agentic systems, AI agents frequently edit rendered context files directly. Without reverse-sync, those edits are destroyed on the next render cycle because Lattice overwrites files from DB state.
289
+
290
+ Reverse-sync solves this by running **before** the render phase inside `reconcile()`:
291
+
292
+ 1. For each entity file with a `reverseSync` function, reads the current file from disk
293
+ 2. Compares its SHA-256 hash against the last-rendered hash stored in the manifest
294
+ 3. If the file was modified, calls the `reverseSync` function to parse changes back into DB updates
295
+ 4. Applies those updates to the database
296
+ 5. The subsequent render writes from the now-updated DB — preserving the agent's edits
297
+
298
+ ### Defining a reverse-sync function
299
+
300
+ Add an optional `reverseSync` function to any `EntityFileSpec`:
301
+
302
+ ```ts
303
+ db.defineEntityContext('agent', {
304
+ slug: (row) => row.slug as string,
305
+ files: {
306
+ 'AGENT.md': {
307
+ source: { type: 'self' },
308
+ render: ([r]) => `# ${r.name}\n**Role:** ${r.role}\n`,
309
+ reverseSync: (content, entityRow) => {
310
+ const updates: ReverseSyncUpdate[] = [];
311
+ const nameMatch = content.match(/^# (.+)$/m);
312
+ if (nameMatch && nameMatch[1] !== entityRow.name) {
313
+ updates.push({
314
+ table: 'agent',
315
+ pk: { id: entityRow.id },
316
+ set: { name: nameMatch[1] },
317
+ });
318
+ }
319
+ return updates;
320
+ },
321
+ },
322
+ },
323
+ });
324
+ ```
325
+
326
+ Each `ReverseSyncUpdate` describes a single row-level mutation:
327
+
328
+ | Field | Type | Description |
329
+ | ------- | ------------------------- | --------------------------------------- |
330
+ | `table` | `string` | Target table name |
331
+ | `pk` | `Record<string, unknown>` | Primary key columns identifying the row |
332
+ | `set` | `Record<string, unknown>` | Columns to update |
333
+
334
+ ### Controlling reverse-sync behavior
335
+
336
+ Pass the `reverseSync` option to `reconcile()`:
337
+
338
+ ```ts
339
+ // Default: reverse-sync enabled
340
+ await db.reconcile(outputDir);
341
+
342
+ // Dry-run: detect changes, count updates, but don't modify DB
343
+ const result = await db.reconcile(outputDir, { reverseSync: 'dry-run' });
344
+ console.log(result.reverseSync);
345
+ // { filesScanned: 5, filesChanged: 2, updatesApplied: 3, errors: [] }
346
+
347
+ // Disabled: skip reverse-sync entirely
348
+ await db.reconcile(outputDir, { reverseSync: false });
349
+ // result.reverseSync is null
350
+ ```
351
+
352
+ ### Edge cases
353
+
354
+ - **File deleted externally**: Skipped (no content to parse).
355
+ - **`reverseSync` throws**: Error captured in `result.reverseSync.errors`; other files still processed. DB transaction for that file is rolled back.
356
+ - **No manifest yet (first render)**: Reverse-sync has no baseline hashes — all files skipped.
357
+ - **v1 manifest (pre-0.16)**: Empty hashes — reverse-sync skips gracefully. After the first v2 render, hashes are populated and reverse-sync activates.
358
+ - **Files without `reverseSync`**: Not scanned. Agent edits to those files are still overwritten on render.
359
+
360
+ ## Lifecycle Management
361
+
362
+ Over time entities are created, renamed, and deleted. Without cleanup, Lattice leaves behind directories and files for entities that no longer exist. The lifecycle system uses a manifest to track what was generated and remove orphans.
363
+
364
+ ### The Manifest
365
+
366
+ After every render cycle that includes entity contexts, Lattice writes:
367
+
368
+ ```
369
+ {outputDir}/.lattice/manifest.json
370
+ ```
371
+
372
+ The manifest records which directories and files were generated for each entity. It is the single source of truth for lifecycle management.
373
+
374
+ ### `reconcile()` — one-shot render + cleanup
375
+
376
+ Use `reconcile()` instead of `render()` when you want lifecycle management in a script or one-off invocation:
377
+
378
+ ```ts
379
+ const result = await db.reconcile('./context', {
380
+ removeOrphanedDirectories: true,
381
+ removeOrphanedFiles: true,
382
+ });
383
+
384
+ console.log(`Removed ${result.cleanup.directoriesRemoved} stale directories`);
385
+ console.log(`Removed ${result.cleanup.filesRemoved} stale files`);
386
+ ```
387
+
388
+ `reconcile()` always renders before cleaning up, so the new manifest is used to determine what is current.
389
+
390
+ ### `watch()` with cleanup
391
+
392
+ In a long-running process, pass `cleanup` options to `watch()`:
393
+
394
+ ```ts
395
+ const stop = await db.watch('./context', {
396
+ interval: 5_000,
397
+ cleanup: {
398
+ removeOrphanedDirectories: true,
399
+ removeOrphanedFiles: true,
400
+ },
401
+ onCleanup: (result) => {
402
+ if (result.directoriesRemoved || result.filesRemoved) {
403
+ console.log(`Cleaned ${result.directoriesRemoved} dirs, ${result.filesRemoved} files`);
404
+ }
405
+ },
406
+ });
407
+ ```
408
+
409
+ ### Dry run
410
+
411
+ Inspect what would be deleted without modifying anything:
412
+
413
+ ```ts
414
+ const result = await db.reconcile('./context', {
415
+ removeOrphanedDirectories: true,
416
+ removeOrphanedFiles: true,
417
+ dryRun: true,
418
+ onOrphan: (path, kind) => console.log(`Would remove ${kind}: ${path}`),
419
+ });
420
+ ```
421
+
422
+ `dryRun: true` is safe to run in CI or staging to audit cleanup without side effects.
423
+
424
+ ### What gets cleaned up
425
+
426
+ **Orphaned directories** — subdirectories inside `directoryRoot` that match no current entity slug. On deletion, Lattice removes all non-protected files first; if the directory is then empty it is removed. If protected files remain, the directory is skipped (counted in `directoriesSkipped`).
427
+
428
+ **Orphaned files** — files inside a surviving entity directory that appear in the previous manifest but were not written in the current render cycle. This catches files that were removed because `omitIfEmpty` now applies or because the file spec was removed from the definition.
429
+
430
+ Files listed in `protectedFiles` (at the definition level or passed via `CleanupOptions`) are never touched.
431
+
432
+ ## Reading the manifest directly
433
+
434
+ ```ts
435
+ import { readManifest, manifestPath } from 'latticesql';
436
+
437
+ const manifest = readManifest('./context');
438
+ if (manifest) {
439
+ console.log('Manifest path:', manifestPath('./context'));
440
+ console.log('Generated at:', manifest.generated_at);
441
+
442
+ for (const [table, entry] of Object.entries(manifest.entityContexts)) {
443
+ const slugCount = Object.keys(entry.entities).length;
444
+ console.log(`${table}: ${slugCount} entities in ${entry.directoryRoot}/`);
445
+ }
446
+ }
447
+ ```
448
+
449
+ ## Protected Entity Contexts (v0.18+)
450
+
451
+ Protected entity contexts prevent data from leaking across context windows. When an entity context is marked `protected: true`, its data is **never auto-rendered** into other entities' files.
452
+
453
+ ```ts
454
+ db.defineEntityContext('agents', {
455
+ slug: (r) => r.slug,
456
+ protected: true, // ← agents can't see each other's context
457
+ files: {
458
+ 'AGENT.md': { source: { type: 'self' }, render: ... },
459
+ },
460
+ });
461
+
462
+ db.defineEntityContext('projects', {
463
+ slug: (r) => r.slug,
464
+ files: {
465
+ 'PROJECT.md': { source: { type: 'self' }, render: ... },
466
+ // This would normally list all agents — but agents is protected,
467
+ // so it returns empty results:
468
+ 'AGENTS.md': {
469
+ source: { type: 'hasMany', table: 'agents', foreignKey: 'project_id' },
470
+ render: (rows) => `Found ${rows.length} agents`, // always 0
471
+ omitIfEmpty: true,
472
+ },
473
+ },
474
+ });
475
+ ```
476
+
477
+ **Rules:**
478
+
479
+ - Protected entity's own files render normally (agent A gets its own `AGENT.md`)
480
+ - Sources from other entity contexts referencing a protected table → empty `[]`
481
+ - Sources within the same protected entity referencing itself → self-only (current row)
482
+ - `self` sources are never affected
483
+ - `custom` sources bypass protection (caller has full control)
484
+
485
+ ## At-Rest Encryption (v0.18+)
486
+
487
+ Entity contexts can enable transparent at-rest encryption for their table's columns.
488
+
489
+ ```ts
490
+ const db = new Lattice('./secrets.db', {
491
+ encryptionKey: process.env.MASTER_KEY, // required
492
+ });
493
+
494
+ db.defineEntityContext('secrets', {
495
+ slug: (r) => r.name,
496
+ protected: true,
497
+ encrypted: { columns: ['value'] }, // only encrypt the 'value' column
498
+ files: { ... },
499
+ });
500
+ ```
501
+
502
+ - **`encrypted: true`** — Encrypt all text columns except structural ones (`id`, `created_at`, `updated_at`, `deleted_at`)
503
+ - **`encrypted: { columns: ['value', 'notes'] }`** — Encrypt only named columns
504
+ - Values stored as `enc:<base64(iv + authTag + ciphertext)>` using AES-256-GCM
505
+ - Plaintext values pass through unchanged on read (migration-safe)
506
+ - Rendered files contain decrypted content (encryption is at the DB layer)
507
+
508
+ ## Full API reference
509
+
510
+ See [API Reference — Entity Context types](./api-reference.md#entity-context-types) for complete type signatures.