latticesql 3.1.0 → 3.2.1
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 +4 -0
- package/dist/cli.js +2174 -1016
- package/dist/index.cjs +393 -190
- package/dist/index.d.cts +15 -20
- package/dist/index.d.ts +15 -20
- package/dist/index.js +393 -190
- package/docs/api-reference.md +1370 -0
- package/docs/architecture.md +331 -0
- package/docs/assistant.md +138 -0
- package/docs/cli.md +515 -0
- package/docs/cloud.md +675 -0
- package/docs/collaboration.md +85 -0
- package/docs/configuration.md +416 -0
- package/docs/entity-context.md +510 -0
- package/docs/examples/agent-system.md +313 -0
- package/docs/examples/cms.md +366 -0
- package/docs/examples/ticket-tracker.md +313 -0
- package/docs/migrations.md +272 -0
- package/docs/templates.md +338 -0
- package/docs/workspaces.md +81 -0
- package/package.json +3 -2
|
@@ -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.
|