latticesql 0.6.0 → 0.8.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 CHANGED
@@ -356,13 +356,16 @@ db.defineMulti('agent-context', {
356
356
  db.defineEntityContext(table: string, def: EntityContextDefinition): this
357
357
  ```
358
358
 
359
- 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
+ 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. Can be called before or after `init()`.
360
360
 
361
361
  ```typescript
362
362
  db.defineEntityContext('agents', {
363
363
  // Derive the subdirectory name for each entity
364
364
  slug: (row) => row.slug as string,
365
365
 
366
+ // Default query options for all relationship sources (v0.6+)
367
+ sourceDefaults: { softDelete: true },
368
+
366
369
  // Global index file listing all entities
367
370
  index: {
368
371
  outputFile: 'agents/AGENTS.md',
@@ -376,7 +379,8 @@ db.defineEntityContext('agents', {
376
379
  render: ([r]) => `# ${r.name as string}\n\n${r.bio as string ?? ''}`,
377
380
  },
378
381
  'TASKS.md': {
379
- source: { type: 'hasMany', table: 'tasks', foreignKey: 'agent_id' },
382
+ source: { type: 'hasMany', table: 'tasks', foreignKey: 'agent_id',
383
+ orderBy: 'created_at', orderDir: 'desc', limit: 20 },
380
384
  render: (rows) => rows.map((r) => `- ${r.title as string}`).join('\n'),
381
385
  omitIfEmpty: true, // skip if no tasks
382
386
  budget: 4000, // truncate at 4 000 chars
@@ -388,6 +392,7 @@ db.defineEntityContext('agents', {
388
392
  localKey: 'agent_id',
389
393
  remoteKey: 'skill_id',
390
394
  remoteTable: 'skills',
395
+ orderBy: 'name', // softDelete inherited from sourceDefaults
391
396
  },
392
397
  render: (rows) => rows.map((r) => `- ${r.name as string}`).join('\n'),
393
398
  omitIfEmpty: true,
@@ -423,11 +428,82 @@ context/
423
428
  | Type | What it queries |
424
429
  |---|---|
425
430
  | `{ type: 'self' }` | The entity row itself |
426
- | `{ type: 'hasMany', table, foreignKey, references? }` | Rows in `table` where `foreignKey = entityPk` |
427
- | `{ type: 'manyToMany', junctionTable, localKey, remoteKey, remoteTable, references? }` | Remote rows via a junction table |
428
- | `{ type: 'belongsTo', table, foreignKey, references? }` | Single parent row via FK on this entity (`null` FK → empty) |
431
+ | `{ type: 'hasMany', table, foreignKey, ... }` | Rows in `table` where `foreignKey = entityPk` |
432
+ | `{ type: 'manyToMany', junctionTable, localKey, remoteKey, remoteTable, ... }` | Remote rows via a junction table |
433
+ | `{ type: 'belongsTo', table, foreignKey, ... }` | Single parent row via FK on this entity (`null` FK → empty) |
434
+ | `{ type: 'enriched', include: { ... } }` | Entity row + related data attached as `_key` JSON fields (v0.7+) |
429
435
  | `{ type: 'custom', query: (row, adapter) => Row[] }` | Fully custom synchronous query |
430
436
 
437
+ #### Source query options (v0.6+)
438
+
439
+ `hasMany`, `manyToMany`, and `belongsTo` sources accept optional query refinements:
440
+
441
+ ```typescript
442
+ {
443
+ type: 'hasMany',
444
+ table: 'tasks',
445
+ foreignKey: 'agent_id',
446
+ // Query options (all optional):
447
+ softDelete: true, // exclude rows where deleted_at IS NULL
448
+ filters: [ // additional WHERE clauses (uses existing Filter type)
449
+ { col: 'status', op: 'eq', val: 'active' },
450
+ ],
451
+ orderBy: 'created_at', // ORDER BY column
452
+ orderDir: 'desc', // 'asc' (default) or 'desc'
453
+ limit: 20, // LIMIT N
454
+ }
455
+ ```
456
+
457
+ The `softDelete: true` shorthand is equivalent to `filters: [{ col: 'deleted_at', op: 'isNull' }]`.
458
+
459
+ #### sourceDefaults (v0.6+)
460
+
461
+ Set default query options for all relationship sources in an entity context:
462
+
463
+ ```typescript
464
+ db.defineEntityContext('agents', {
465
+ slug: (row) => row.slug as string,
466
+ sourceDefaults: { softDelete: true }, // applied to all hasMany/manyToMany/belongsTo
467
+ files: {
468
+ 'TASKS.md': {
469
+ // softDelete: true is inherited from sourceDefaults
470
+ source: { type: 'hasMany', table: 'tasks', foreignKey: 'agent_id', orderBy: 'created_at' },
471
+ render: (rows) => rows.map((r) => `- ${r.title as string}`).join('\n'),
472
+ },
473
+ },
474
+ });
475
+ ```
476
+
477
+ Per-file source options override defaults. `custom`, `self`, and `enriched` sources are unaffected.
478
+
479
+ #### Enriched source (v0.7+)
480
+
481
+ Starts with the entity's own row and attaches related data as JSON string fields. Each key in `include` becomes a `_key` field containing `JSON.stringify(resolvedRows)`.
482
+
483
+ ```typescript
484
+ 'PROFILE.md': {
485
+ source: {
486
+ type: 'enriched',
487
+ include: {
488
+ // Declarative sub-lookups (support all query options)
489
+ skills: { type: 'manyToMany', junctionTable: 'agent_skills',
490
+ localKey: 'agent_id', remoteKey: 'skill_id',
491
+ remoteTable: 'skills', softDelete: true },
492
+ projects: { type: 'hasMany', table: 'projects', foreignKey: 'org_id',
493
+ softDelete: true, orderBy: 'name' },
494
+ // Custom sub-lookup for complex queries
495
+ stats: { type: 'custom', query: (row, adapter) =>
496
+ adapter.all('SELECT COUNT(*) as cnt FROM events WHERE actor_id = ?', [row.id]) },
497
+ },
498
+ },
499
+ render: ([row]) => {
500
+ const skills = JSON.parse(row._skills as string);
501
+ const projects = JSON.parse(row._projects as string);
502
+ return `# ${row.name}\n\nSkills: ${skills.length}\nProjects: ${projects.length}`;
503
+ },
504
+ }
505
+ ```
506
+
431
507
  See [docs/entity-context.md](./docs/entity-context.md) for the complete guide.
432
508
 
433
509
  ---
@@ -924,6 +1000,71 @@ db.define('tickets', {
924
1000
 
925
1001
  ---
926
1002
 
1003
+ ## Markdown utilities (v0.6+)
1004
+
1005
+ Composable helper functions for building render functions. Use inside `render: (rows) => ...` callbacks to reduce boilerplate.
1006
+
1007
+ ### `frontmatter(fields)`
1008
+
1009
+ Generate a YAML-style frontmatter block. Automatically includes `generated_at` with the current ISO timestamp.
1010
+
1011
+ ```typescript
1012
+ import { frontmatter } from 'latticesql';
1013
+
1014
+ const header = frontmatter({ agent: 'Alice', skill_count: 5 });
1015
+ // ---
1016
+ // generated_at: "2026-03-27T..."
1017
+ // agent: "Alice"
1018
+ // skill_count: 5
1019
+ // ---
1020
+ ```
1021
+
1022
+ ### `markdownTable(rows, columns)`
1023
+
1024
+ Generate a GitHub-Flavoured Markdown table from rows with explicit column configuration and optional per-cell formatters.
1025
+
1026
+ ```typescript
1027
+ import { markdownTable } from 'latticesql';
1028
+
1029
+ const md = markdownTable(rows, [
1030
+ { key: 'name', header: 'Name' },
1031
+ { key: 'status', header: 'Status', format: (v) => String(v || '—') },
1032
+ { key: 'name', header: 'Detail', format: (v, row) => `[view](${row.slug}/DETAIL.md)` },
1033
+ ]);
1034
+ // | Name | Status | Detail |
1035
+ // | --- | --- | --- |
1036
+ // | Alice | active | [view](alice/DETAIL.md) |
1037
+ ```
1038
+
1039
+ Returns empty string for zero rows. The `format` callback receives `(cellValue, fullRow)`.
1040
+
1041
+ ### `slugify(name)`
1042
+
1043
+ Generate a URL-safe slug from a display name — lowercases, strips diacritics, replaces non-alphanumeric runs with hyphens.
1044
+
1045
+ ```typescript
1046
+ import { slugify } from 'latticesql';
1047
+
1048
+ slugify('My Agent Name'); // 'my-agent-name'
1049
+ slugify('Jose Garcia'); // 'jose-garcia'
1050
+ ```
1051
+
1052
+ ### `truncate(content, maxChars, notice?)`
1053
+
1054
+ Truncate content at a character budget. Appends a notice when truncation occurs.
1055
+
1056
+ ```typescript
1057
+ import { truncate } from 'latticesql';
1058
+
1059
+ const md = truncate(longContent, 4000);
1060
+ // Appends: "\n\n*[truncated — context budget exceeded]*"
1061
+
1062
+ const md2 = truncate(longContent, 4000, '\n\n[...truncated]');
1063
+ // Custom notice
1064
+ ```
1065
+
1066
+ ---
1067
+
927
1068
  ## Entity context directories (v0.5+)
928
1069
 
929
1070
  `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.
@@ -1071,6 +1212,74 @@ interface SessionWriteEntry {
1071
1212
 
1072
1213
  The processor is responsible for applying the parsed entries to your DB and validating field names against your schema. The `parseSessionWrites` function is pure — no DB access, no side effects.
1073
1214
 
1215
+ ### Full session parser (v0.5.2+)
1216
+
1217
+ For parsing **all** entry types (not just writes), use `parseSessionMD`:
1218
+
1219
+ ```ts
1220
+ import { parseSessionMD, parseMarkdownEntries } from 'latticesql';
1221
+
1222
+ // Parse YAML-delimited entries (--- header --- body ===)
1223
+ const result = parseSessionMD(content, startOffset);
1224
+ // result.entries: SessionEntry[] — all types: event, learning, status, write, etc.
1225
+ // result.errors: ParseError[]
1226
+ // result.lastOffset: number — for incremental parsing
1227
+
1228
+ // Parse markdown heading entries (## timestamp — description)
1229
+ const mdResult = parseMarkdownEntries(content, 'agent-name', startOffset);
1230
+ ```
1231
+
1232
+ #### Configurable entry types (v0.5.5+)
1233
+
1234
+ By default, the parser validates against a built-in set of entry types. Override via `SessionParseOptions`:
1235
+
1236
+ ```ts
1237
+ import { parseSessionMD, DEFAULT_ENTRY_TYPES, DEFAULT_TYPE_ALIASES } from 'latticesql';
1238
+
1239
+ // Accept any type (no validation)
1240
+ parseSessionMD(content, 0, { validTypes: null });
1241
+
1242
+ // Custom type set
1243
+ parseSessionMD(content, 0, {
1244
+ validTypes: new Set(['alert', 'todo', 'write']),
1245
+ typeAliases: { warning: 'alert', task: 'todo' },
1246
+ });
1247
+ ```
1248
+
1249
+ ### Read-only header (v0.5.5+)
1250
+
1251
+ All generated context files should carry a read-only header. Use the default or create a custom one:
1252
+
1253
+ ```ts
1254
+ import { READ_ONLY_HEADER, createReadOnlyHeader } from 'latticesql';
1255
+
1256
+ // Default: "generated by Lattice"
1257
+ const header = READ_ONLY_HEADER;
1258
+
1259
+ // Custom generator name and docs reference
1260
+ const custom = createReadOnlyHeader({
1261
+ generator: 'my-sync-tool',
1262
+ docsRef: 'https://example.com/docs/sessions',
1263
+ });
1264
+ ```
1265
+
1266
+ ### Write applicator (v0.5.2+)
1267
+
1268
+ Apply parsed write entries to a better-sqlite3 database with schema validation:
1269
+
1270
+ ```ts
1271
+ import { applyWriteEntry } from 'latticesql';
1272
+
1273
+ const result = applyWriteEntry(db, writeEntry);
1274
+ if (result.ok) {
1275
+ console.log(`Applied to ${result.table}, record ${result.recordId}`);
1276
+ } else {
1277
+ console.error(result.reason);
1278
+ }
1279
+ ```
1280
+
1281
+ Validates table existence, field names against schema, and uses soft-delete when a `deleted_at` column exists.
1282
+
1074
1283
  ---
1075
1284
 
1076
1285
  ## YAML config (v0.4+)
package/dist/cli.js CHANGED
@@ -666,9 +666,18 @@ function appendQueryOptions(baseSql, params, opts, tableAlias) {
666
666
  break;
667
667
  }
668
668
  }
669
- if (opts.orderBy && SAFE_COL_RE.test(opts.orderBy)) {
670
- const dir = opts.orderDir === "desc" ? "DESC" : "ASC";
671
- sql += ` ORDER BY ${prefix}"${opts.orderBy}" ${dir}`;
669
+ if (opts.orderBy) {
670
+ if (typeof opts.orderBy === "string") {
671
+ if (SAFE_COL_RE.test(opts.orderBy)) {
672
+ const dir = opts.orderDir === "desc" ? "DESC" : "ASC";
673
+ sql += ` ORDER BY ${prefix}"${opts.orderBy}" ${dir}`;
674
+ }
675
+ } else {
676
+ const clauses = opts.orderBy.filter((spec) => SAFE_COL_RE.test(spec.col)).map((spec) => `${prefix}"${spec.col}" ${spec.dir === "desc" ? "DESC" : "ASC"}`);
677
+ if (clauses.length > 0) {
678
+ sql += ` ORDER BY ${clauses.join(", ")}`;
679
+ }
680
+ }
672
681
  }
673
682
  if (opts.limit !== void 0 && opts.limit > 0) {
674
683
  sql += ` LIMIT ${Math.floor(opts.limit)}`;
@@ -691,7 +700,19 @@ function resolveEntitySource(source, entityRow, entityPk, adapter) {
691
700
  const pkVal = entityRow[entityPk];
692
701
  const remotePk = source.references ?? "id";
693
702
  const params = [pkVal];
694
- let sql = `SELECT r.* FROM "${source.remoteTable}" r
703
+ let selectCols = "r.*";
704
+ if (source.junctionColumns?.length) {
705
+ const jCols = source.junctionColumns.map((jc) => {
706
+ if (typeof jc === "string") {
707
+ if (!SAFE_COL_RE.test(jc)) return null;
708
+ return `j."${jc}"`;
709
+ }
710
+ if (!SAFE_COL_RE.test(jc.col) || !SAFE_COL_RE.test(jc.as)) return null;
711
+ return `j."${jc.col}" AS "${jc.as}"`;
712
+ }).filter(Boolean);
713
+ if (jCols.length > 0) selectCols += ", " + jCols.join(", ");
714
+ }
715
+ let sql = `SELECT ${selectCols} FROM "${source.remoteTable}" r
695
716
  JOIN "${source.junctionTable}" j ON j."${source.remoteKey}" = r."${remotePk}"
696
717
  WHERE j."${source.localKey}" = ?`;
697
718
  sql = appendQueryOptions(sql, params, source, "r");
@@ -716,6 +737,19 @@ function resolveEntitySource(source, entityRow, entityPk, adapter) {
716
737
  }
717
738
  case "custom":
718
739
  return source.query(entityRow, adapter);
740
+ case "enriched": {
741
+ const enriched = { ...entityRow };
742
+ for (const [key, lookup] of Object.entries(source.include)) {
743
+ const fieldName = `_${key}`;
744
+ if (lookup.type === "custom") {
745
+ enriched[fieldName] = JSON.stringify(lookup.query(entityRow, adapter));
746
+ } else {
747
+ const resolved = resolveEntitySource(lookup, entityRow, entityPk, adapter);
748
+ enriched[fieldName] = JSON.stringify(resolved);
749
+ }
750
+ }
751
+ return [enriched];
752
+ }
719
753
  }
720
754
  }
721
755
  function truncateContent(content, budget) {
@@ -928,7 +962,8 @@ var RenderEngine = class {
928
962
  mkdirSync3(entityDir, { recursive: true });
929
963
  const renderedFiles = /* @__PURE__ */ new Map();
930
964
  for (const [filename, spec] of Object.entries(def.files)) {
931
- const source = def.sourceDefaults && spec.source.type !== "self" && spec.source.type !== "custom" ? { ...def.sourceDefaults, ...spec.source } : spec.source;
965
+ const mergeDefaults = def.sourceDefaults && spec.source.type !== "self" && spec.source.type !== "custom" && spec.source.type !== "enriched";
966
+ const source = mergeDefaults ? { ...def.sourceDefaults, ...spec.source } : spec.source;
932
967
  const rows = resolveEntitySource(source, entityRow, entityPk, this._adapter);
933
968
  if (spec.omitIfEmpty && rows.length === 0) continue;
934
969
  const content = truncateContent(spec.render(rows), spec.budget);
package/dist/index.cjs CHANGED
@@ -408,9 +408,18 @@ function appendQueryOptions(baseSql, params, opts, tableAlias) {
408
408
  break;
409
409
  }
410
410
  }
411
- if (opts.orderBy && SAFE_COL_RE.test(opts.orderBy)) {
412
- const dir = opts.orderDir === "desc" ? "DESC" : "ASC";
413
- sql += ` ORDER BY ${prefix}"${opts.orderBy}" ${dir}`;
411
+ if (opts.orderBy) {
412
+ if (typeof opts.orderBy === "string") {
413
+ if (SAFE_COL_RE.test(opts.orderBy)) {
414
+ const dir = opts.orderDir === "desc" ? "DESC" : "ASC";
415
+ sql += ` ORDER BY ${prefix}"${opts.orderBy}" ${dir}`;
416
+ }
417
+ } else {
418
+ const clauses = opts.orderBy.filter((spec) => SAFE_COL_RE.test(spec.col)).map((spec) => `${prefix}"${spec.col}" ${spec.dir === "desc" ? "DESC" : "ASC"}`);
419
+ if (clauses.length > 0) {
420
+ sql += ` ORDER BY ${clauses.join(", ")}`;
421
+ }
422
+ }
414
423
  }
415
424
  if (opts.limit !== void 0 && opts.limit > 0) {
416
425
  sql += ` LIMIT ${Math.floor(opts.limit)}`;
@@ -433,7 +442,19 @@ function resolveEntitySource(source, entityRow, entityPk, adapter) {
433
442
  const pkVal = entityRow[entityPk];
434
443
  const remotePk = source.references ?? "id";
435
444
  const params = [pkVal];
436
- let sql = `SELECT r.* FROM "${source.remoteTable}" r
445
+ let selectCols = "r.*";
446
+ if (source.junctionColumns?.length) {
447
+ const jCols = source.junctionColumns.map((jc) => {
448
+ if (typeof jc === "string") {
449
+ if (!SAFE_COL_RE.test(jc)) return null;
450
+ return `j."${jc}"`;
451
+ }
452
+ if (!SAFE_COL_RE.test(jc.col) || !SAFE_COL_RE.test(jc.as)) return null;
453
+ return `j."${jc.col}" AS "${jc.as}"`;
454
+ }).filter(Boolean);
455
+ if (jCols.length > 0) selectCols += ", " + jCols.join(", ");
456
+ }
457
+ let sql = `SELECT ${selectCols} FROM "${source.remoteTable}" r
437
458
  JOIN "${source.junctionTable}" j ON j."${source.remoteKey}" = r."${remotePk}"
438
459
  WHERE j."${source.localKey}" = ?`;
439
460
  sql = appendQueryOptions(sql, params, source, "r");
@@ -458,6 +479,19 @@ function resolveEntitySource(source, entityRow, entityPk, adapter) {
458
479
  }
459
480
  case "custom":
460
481
  return source.query(entityRow, adapter);
482
+ case "enriched": {
483
+ const enriched = { ...entityRow };
484
+ for (const [key, lookup] of Object.entries(source.include)) {
485
+ const fieldName = `_${key}`;
486
+ if (lookup.type === "custom") {
487
+ enriched[fieldName] = JSON.stringify(lookup.query(entityRow, adapter));
488
+ } else {
489
+ const resolved = resolveEntitySource(lookup, entityRow, entityPk, adapter);
490
+ enriched[fieldName] = JSON.stringify(resolved);
491
+ }
492
+ }
493
+ return [enriched];
494
+ }
461
495
  }
462
496
  }
463
497
  function truncateContent(content, budget) {
@@ -670,7 +704,8 @@ var RenderEngine = class {
670
704
  (0, import_node_fs4.mkdirSync)(entityDir, { recursive: true });
671
705
  const renderedFiles = /* @__PURE__ */ new Map();
672
706
  for (const [filename, spec] of Object.entries(def.files)) {
673
- const source = def.sourceDefaults && spec.source.type !== "self" && spec.source.type !== "custom" ? { ...def.sourceDefaults, ...spec.source } : spec.source;
707
+ const mergeDefaults = def.sourceDefaults && spec.source.type !== "self" && spec.source.type !== "custom" && spec.source.type !== "enriched";
708
+ const source = mergeDefaults ? { ...def.sourceDefaults, ...spec.source } : spec.source;
674
709
  const rows = resolveEntitySource(source, entityRow, entityPk, this._adapter);
675
710
  if (spec.omitIfEmpty && rows.length === 0) continue;
676
711
  const content = truncateContent(spec.render(rows), spec.budget);
package/dist/index.d.cts CHANGED
@@ -60,13 +60,33 @@ interface SourceQueryOptions {
60
60
  * and an explicit `deleted_at` filter are present, the explicit filter wins.
61
61
  */
62
62
  softDelete?: boolean;
63
- /** Column to ORDER BY. Validated against `[a-zA-Z0-9_]`. */
64
- orderBy?: string;
65
- /** Sort direction. Defaults to `'asc'`. */
63
+ /**
64
+ * Column(s) to ORDER BY. Validated against `[a-zA-Z0-9_]`.
65
+ * - `string` single column (use `orderDir` for direction)
66
+ * - `OrderBySpec[]` — multi-column with per-column direction
67
+ *
68
+ * @example
69
+ * ```ts
70
+ * orderBy: 'name' // single column
71
+ * orderBy: [{ col: 'severity' }, { col: 'timestamp', dir: 'desc' }] // multi
72
+ * ```
73
+ */
74
+ orderBy?: string | OrderBySpec[];
75
+ /** Sort direction when `orderBy` is a string. Defaults to `'asc'`. */
66
76
  orderDir?: 'asc' | 'desc';
67
77
  /** Maximum number of rows to return. */
68
78
  limit?: number;
69
79
  }
80
+ /**
81
+ * A single ORDER BY column with optional direction.
82
+ * Used in the array form of `SourceQueryOptions.orderBy`.
83
+ */
84
+ interface OrderBySpec {
85
+ /** Column name (validated against `[a-zA-Z0-9_]`). */
86
+ col: string;
87
+ /** Sort direction. Defaults to `'asc'`. */
88
+ dir?: 'asc' | 'desc';
89
+ }
70
90
  /**
71
91
  * Yield the entity row itself as a single-element array.
72
92
  * Use for the primary entity file (e.g. `AGENT.md`).
@@ -125,6 +145,20 @@ interface ManyToManySource extends SourceQueryOptions {
125
145
  * Defaults to `'id'`.
126
146
  */
127
147
  references?: string;
148
+ /**
149
+ * Columns from the junction table to include in each result row.
150
+ * Use a string for the column name as-is, or `{ col, as }` to alias.
151
+ *
152
+ * @example
153
+ * ```ts
154
+ * junctionColumns: ['source', { col: 'role', as: 'agent_role' }]
155
+ * // Adds j."source" and j."role" AS "agent_role" to each row
156
+ * ```
157
+ */
158
+ junctionColumns?: Array<string | {
159
+ col: string;
160
+ as: string;
161
+ }>;
128
162
  }
129
163
  /**
130
164
  * Query the single row that this entity belongs to via a foreign key on
@@ -167,8 +201,57 @@ interface CustomSource {
167
201
  type: 'custom';
168
202
  query: (row: Row, adapter: StorageAdapter) => Row[];
169
203
  }
204
+ /**
205
+ * Sub-lookup definition for an {@link EnrichedSource}.
206
+ * Each lookup resolves related rows and attaches them to the entity row
207
+ * as a `_key` JSON string field.
208
+ *
209
+ * Declarative lookups reuse the same types as file sources (with all
210
+ * query options). Custom lookups provide full control.
211
+ */
212
+ type EnrichmentLookup = ({
213
+ as?: string;
214
+ } & Omit<HasManySource, 'type'> & {
215
+ type: 'hasMany';
216
+ }) | ({
217
+ as?: string;
218
+ } & Omit<ManyToManySource, 'type'> & {
219
+ type: 'manyToMany';
220
+ }) | ({
221
+ as?: string;
222
+ } & Omit<BelongsToSource, 'type'> & {
223
+ type: 'belongsTo';
224
+ }) | {
225
+ type: 'custom';
226
+ query: (row: Row, adapter: StorageAdapter) => Row[];
227
+ };
228
+ /**
229
+ * Start with the entity's own row (like `self`) and attach related data
230
+ * as JSON string fields. Each key in `include` becomes a `_key` field
231
+ * on the returned row, containing `JSON.stringify(resolvedRows)`.
232
+ *
233
+ * Use when a single file needs the entity's own data plus related lists
234
+ * (e.g. an org profile that includes its agents and projects).
235
+ *
236
+ * @example
237
+ * ```ts
238
+ * source: {
239
+ * type: 'enriched',
240
+ * include: {
241
+ * agents: { type: 'hasMany', table: 'agents', foreignKey: 'org_id', softDelete: true },
242
+ * projects: { type: 'hasMany', table: 'projects', foreignKey: 'org_id', softDelete: true },
243
+ * },
244
+ * }
245
+ * // Result: [{ ...entityRow, _agents: '[...]', _projects: '[...]' }]
246
+ * ```
247
+ */
248
+ interface EnrichedSource {
249
+ type: 'enriched';
250
+ /** Named lookups whose results are attached as `_key` JSON fields. */
251
+ include: Record<string, EnrichmentLookup>;
252
+ }
170
253
  /** Union of all supported source types for {@link EntityFileSpec}. */
171
- type EntityFileSource = SelfSource | HasManySource | ManyToManySource | BelongsToSource | CustomSource;
254
+ type EntityFileSource = SelfSource | HasManySource | ManyToManySource | BelongsToSource | CustomSource | EnrichedSource;
172
255
  /**
173
256
  * Specification for a single file generated inside an entity's directory.
174
257
  *
@@ -1147,4 +1230,4 @@ declare function createReadOnlyHeader(options?: ReadOnlyHeaderOptions): string;
1147
1230
  */
1148
1231
  declare const READ_ONLY_HEADER: string;
1149
1232
 
1150
- export { type ApplyWriteResult, type AuditEvent, type BelongsToRelation, type BelongsToSource, type BuiltinTemplateName, type CleanupOptions, type CleanupResult, type CountOptions, type CustomSource, DEFAULT_ENTRY_TYPES, DEFAULT_TYPE_ALIASES, type EntityContextDefinition, type EntityContextManifestEntry, type EntityFileSource, type EntityFileSpec, type Filter, type FilterOp, type HasManyRelation, type HasManySource, type InitOptions, Lattice, type LatticeConfig, type LatticeConfigInput, type LatticeEntityDef, type LatticeEntityRenderSpec, type LatticeFieldDef, type LatticeFieldType, type LatticeManifest, type LatticeOptions, type ManyToManySource, type MarkdownTableColumn, type Migration, type MultiTableDefinition, type ParseError, type ParseResult, type ParsedConfig, type PkLookup, type PrimaryKey, type QueryOptions, READ_ONLY_HEADER, type ReadOnlyHeaderOptions, type ReconcileOptions, type ReconcileResult, type Relation, type RenderHooks, type RenderResult, type RenderSpec, type Row, type SecurityOptions, type SelfSource, type SessionEntry, type SessionParseOptions, type SessionWriteEntry, type SessionWriteOp, type SessionWriteParseResult, type SourceQueryOptions, type StopFn, type SyncResult, type TableDefinition, type TemplateRenderSpec, type WatchOptions, type WritebackDefinition, applyWriteEntry, createReadOnlyHeader, frontmatter, generateEntryId, generateWriteEntryId, manifestPath, markdownTable, parseConfigFile, parseConfigString, parseMarkdownEntries, parseSessionMD, parseSessionWrites, readManifest, slugify, truncate, validateEntryId, writeManifest };
1233
+ export { type ApplyWriteResult, type AuditEvent, type BelongsToRelation, type BelongsToSource, type BuiltinTemplateName, type CleanupOptions, type CleanupResult, type CountOptions, type CustomSource, DEFAULT_ENTRY_TYPES, DEFAULT_TYPE_ALIASES, type EnrichedSource, type EnrichmentLookup, type EntityContextDefinition, type EntityContextManifestEntry, type EntityFileSource, type EntityFileSpec, type Filter, type FilterOp, type HasManyRelation, type HasManySource, type InitOptions, Lattice, type LatticeConfig, type LatticeConfigInput, type LatticeEntityDef, type LatticeEntityRenderSpec, type LatticeFieldDef, type LatticeFieldType, type LatticeManifest, type LatticeOptions, type ManyToManySource, type MarkdownTableColumn, type Migration, type MultiTableDefinition, type OrderBySpec, type ParseError, type ParseResult, type ParsedConfig, type PkLookup, type PrimaryKey, type QueryOptions, READ_ONLY_HEADER, type ReadOnlyHeaderOptions, type ReconcileOptions, type ReconcileResult, type Relation, type RenderHooks, type RenderResult, type RenderSpec, type Row, type SecurityOptions, type SelfSource, type SessionEntry, type SessionParseOptions, type SessionWriteEntry, type SessionWriteOp, type SessionWriteParseResult, type SourceQueryOptions, type StopFn, type SyncResult, type TableDefinition, type TemplateRenderSpec, type WatchOptions, type WritebackDefinition, applyWriteEntry, createReadOnlyHeader, frontmatter, generateEntryId, generateWriteEntryId, manifestPath, markdownTable, parseConfigFile, parseConfigString, parseMarkdownEntries, parseSessionMD, parseSessionWrites, readManifest, slugify, truncate, validateEntryId, writeManifest };
package/dist/index.d.ts CHANGED
@@ -60,13 +60,33 @@ interface SourceQueryOptions {
60
60
  * and an explicit `deleted_at` filter are present, the explicit filter wins.
61
61
  */
62
62
  softDelete?: boolean;
63
- /** Column to ORDER BY. Validated against `[a-zA-Z0-9_]`. */
64
- orderBy?: string;
65
- /** Sort direction. Defaults to `'asc'`. */
63
+ /**
64
+ * Column(s) to ORDER BY. Validated against `[a-zA-Z0-9_]`.
65
+ * - `string` single column (use `orderDir` for direction)
66
+ * - `OrderBySpec[]` — multi-column with per-column direction
67
+ *
68
+ * @example
69
+ * ```ts
70
+ * orderBy: 'name' // single column
71
+ * orderBy: [{ col: 'severity' }, { col: 'timestamp', dir: 'desc' }] // multi
72
+ * ```
73
+ */
74
+ orderBy?: string | OrderBySpec[];
75
+ /** Sort direction when `orderBy` is a string. Defaults to `'asc'`. */
66
76
  orderDir?: 'asc' | 'desc';
67
77
  /** Maximum number of rows to return. */
68
78
  limit?: number;
69
79
  }
80
+ /**
81
+ * A single ORDER BY column with optional direction.
82
+ * Used in the array form of `SourceQueryOptions.orderBy`.
83
+ */
84
+ interface OrderBySpec {
85
+ /** Column name (validated against `[a-zA-Z0-9_]`). */
86
+ col: string;
87
+ /** Sort direction. Defaults to `'asc'`. */
88
+ dir?: 'asc' | 'desc';
89
+ }
70
90
  /**
71
91
  * Yield the entity row itself as a single-element array.
72
92
  * Use for the primary entity file (e.g. `AGENT.md`).
@@ -125,6 +145,20 @@ interface ManyToManySource extends SourceQueryOptions {
125
145
  * Defaults to `'id'`.
126
146
  */
127
147
  references?: string;
148
+ /**
149
+ * Columns from the junction table to include in each result row.
150
+ * Use a string for the column name as-is, or `{ col, as }` to alias.
151
+ *
152
+ * @example
153
+ * ```ts
154
+ * junctionColumns: ['source', { col: 'role', as: 'agent_role' }]
155
+ * // Adds j."source" and j."role" AS "agent_role" to each row
156
+ * ```
157
+ */
158
+ junctionColumns?: Array<string | {
159
+ col: string;
160
+ as: string;
161
+ }>;
128
162
  }
129
163
  /**
130
164
  * Query the single row that this entity belongs to via a foreign key on
@@ -167,8 +201,57 @@ interface CustomSource {
167
201
  type: 'custom';
168
202
  query: (row: Row, adapter: StorageAdapter) => Row[];
169
203
  }
204
+ /**
205
+ * Sub-lookup definition for an {@link EnrichedSource}.
206
+ * Each lookup resolves related rows and attaches them to the entity row
207
+ * as a `_key` JSON string field.
208
+ *
209
+ * Declarative lookups reuse the same types as file sources (with all
210
+ * query options). Custom lookups provide full control.
211
+ */
212
+ type EnrichmentLookup = ({
213
+ as?: string;
214
+ } & Omit<HasManySource, 'type'> & {
215
+ type: 'hasMany';
216
+ }) | ({
217
+ as?: string;
218
+ } & Omit<ManyToManySource, 'type'> & {
219
+ type: 'manyToMany';
220
+ }) | ({
221
+ as?: string;
222
+ } & Omit<BelongsToSource, 'type'> & {
223
+ type: 'belongsTo';
224
+ }) | {
225
+ type: 'custom';
226
+ query: (row: Row, adapter: StorageAdapter) => Row[];
227
+ };
228
+ /**
229
+ * Start with the entity's own row (like `self`) and attach related data
230
+ * as JSON string fields. Each key in `include` becomes a `_key` field
231
+ * on the returned row, containing `JSON.stringify(resolvedRows)`.
232
+ *
233
+ * Use when a single file needs the entity's own data plus related lists
234
+ * (e.g. an org profile that includes its agents and projects).
235
+ *
236
+ * @example
237
+ * ```ts
238
+ * source: {
239
+ * type: 'enriched',
240
+ * include: {
241
+ * agents: { type: 'hasMany', table: 'agents', foreignKey: 'org_id', softDelete: true },
242
+ * projects: { type: 'hasMany', table: 'projects', foreignKey: 'org_id', softDelete: true },
243
+ * },
244
+ * }
245
+ * // Result: [{ ...entityRow, _agents: '[...]', _projects: '[...]' }]
246
+ * ```
247
+ */
248
+ interface EnrichedSource {
249
+ type: 'enriched';
250
+ /** Named lookups whose results are attached as `_key` JSON fields. */
251
+ include: Record<string, EnrichmentLookup>;
252
+ }
170
253
  /** Union of all supported source types for {@link EntityFileSpec}. */
171
- type EntityFileSource = SelfSource | HasManySource | ManyToManySource | BelongsToSource | CustomSource;
254
+ type EntityFileSource = SelfSource | HasManySource | ManyToManySource | BelongsToSource | CustomSource | EnrichedSource;
172
255
  /**
173
256
  * Specification for a single file generated inside an entity's directory.
174
257
  *
@@ -1147,4 +1230,4 @@ declare function createReadOnlyHeader(options?: ReadOnlyHeaderOptions): string;
1147
1230
  */
1148
1231
  declare const READ_ONLY_HEADER: string;
1149
1232
 
1150
- export { type ApplyWriteResult, type AuditEvent, type BelongsToRelation, type BelongsToSource, type BuiltinTemplateName, type CleanupOptions, type CleanupResult, type CountOptions, type CustomSource, DEFAULT_ENTRY_TYPES, DEFAULT_TYPE_ALIASES, type EntityContextDefinition, type EntityContextManifestEntry, type EntityFileSource, type EntityFileSpec, type Filter, type FilterOp, type HasManyRelation, type HasManySource, type InitOptions, Lattice, type LatticeConfig, type LatticeConfigInput, type LatticeEntityDef, type LatticeEntityRenderSpec, type LatticeFieldDef, type LatticeFieldType, type LatticeManifest, type LatticeOptions, type ManyToManySource, type MarkdownTableColumn, type Migration, type MultiTableDefinition, type ParseError, type ParseResult, type ParsedConfig, type PkLookup, type PrimaryKey, type QueryOptions, READ_ONLY_HEADER, type ReadOnlyHeaderOptions, type ReconcileOptions, type ReconcileResult, type Relation, type RenderHooks, type RenderResult, type RenderSpec, type Row, type SecurityOptions, type SelfSource, type SessionEntry, type SessionParseOptions, type SessionWriteEntry, type SessionWriteOp, type SessionWriteParseResult, type SourceQueryOptions, type StopFn, type SyncResult, type TableDefinition, type TemplateRenderSpec, type WatchOptions, type WritebackDefinition, applyWriteEntry, createReadOnlyHeader, frontmatter, generateEntryId, generateWriteEntryId, manifestPath, markdownTable, parseConfigFile, parseConfigString, parseMarkdownEntries, parseSessionMD, parseSessionWrites, readManifest, slugify, truncate, validateEntryId, writeManifest };
1233
+ export { type ApplyWriteResult, type AuditEvent, type BelongsToRelation, type BelongsToSource, type BuiltinTemplateName, type CleanupOptions, type CleanupResult, type CountOptions, type CustomSource, DEFAULT_ENTRY_TYPES, DEFAULT_TYPE_ALIASES, type EnrichedSource, type EnrichmentLookup, type EntityContextDefinition, type EntityContextManifestEntry, type EntityFileSource, type EntityFileSpec, type Filter, type FilterOp, type HasManyRelation, type HasManySource, type InitOptions, Lattice, type LatticeConfig, type LatticeConfigInput, type LatticeEntityDef, type LatticeEntityRenderSpec, type LatticeFieldDef, type LatticeFieldType, type LatticeManifest, type LatticeOptions, type ManyToManySource, type MarkdownTableColumn, type Migration, type MultiTableDefinition, type OrderBySpec, type ParseError, type ParseResult, type ParsedConfig, type PkLookup, type PrimaryKey, type QueryOptions, READ_ONLY_HEADER, type ReadOnlyHeaderOptions, type ReconcileOptions, type ReconcileResult, type Relation, type RenderHooks, type RenderResult, type RenderSpec, type Row, type SecurityOptions, type SelfSource, type SessionEntry, type SessionParseOptions, type SessionWriteEntry, type SessionWriteOp, type SessionWriteParseResult, type SourceQueryOptions, type StopFn, type SyncResult, type TableDefinition, type TemplateRenderSpec, type WatchOptions, type WritebackDefinition, applyWriteEntry, createReadOnlyHeader, frontmatter, generateEntryId, generateWriteEntryId, manifestPath, markdownTable, parseConfigFile, parseConfigString, parseMarkdownEntries, parseSessionMD, parseSessionWrites, readManifest, slugify, truncate, validateEntryId, writeManifest };
package/dist/index.js CHANGED
@@ -352,9 +352,18 @@ function appendQueryOptions(baseSql, params, opts, tableAlias) {
352
352
  break;
353
353
  }
354
354
  }
355
- if (opts.orderBy && SAFE_COL_RE.test(opts.orderBy)) {
356
- const dir = opts.orderDir === "desc" ? "DESC" : "ASC";
357
- sql += ` ORDER BY ${prefix}"${opts.orderBy}" ${dir}`;
355
+ if (opts.orderBy) {
356
+ if (typeof opts.orderBy === "string") {
357
+ if (SAFE_COL_RE.test(opts.orderBy)) {
358
+ const dir = opts.orderDir === "desc" ? "DESC" : "ASC";
359
+ sql += ` ORDER BY ${prefix}"${opts.orderBy}" ${dir}`;
360
+ }
361
+ } else {
362
+ const clauses = opts.orderBy.filter((spec) => SAFE_COL_RE.test(spec.col)).map((spec) => `${prefix}"${spec.col}" ${spec.dir === "desc" ? "DESC" : "ASC"}`);
363
+ if (clauses.length > 0) {
364
+ sql += ` ORDER BY ${clauses.join(", ")}`;
365
+ }
366
+ }
358
367
  }
359
368
  if (opts.limit !== void 0 && opts.limit > 0) {
360
369
  sql += ` LIMIT ${Math.floor(opts.limit)}`;
@@ -377,7 +386,19 @@ function resolveEntitySource(source, entityRow, entityPk, adapter) {
377
386
  const pkVal = entityRow[entityPk];
378
387
  const remotePk = source.references ?? "id";
379
388
  const params = [pkVal];
380
- let sql = `SELECT r.* FROM "${source.remoteTable}" r
389
+ let selectCols = "r.*";
390
+ if (source.junctionColumns?.length) {
391
+ const jCols = source.junctionColumns.map((jc) => {
392
+ if (typeof jc === "string") {
393
+ if (!SAFE_COL_RE.test(jc)) return null;
394
+ return `j."${jc}"`;
395
+ }
396
+ if (!SAFE_COL_RE.test(jc.col) || !SAFE_COL_RE.test(jc.as)) return null;
397
+ return `j."${jc.col}" AS "${jc.as}"`;
398
+ }).filter(Boolean);
399
+ if (jCols.length > 0) selectCols += ", " + jCols.join(", ");
400
+ }
401
+ let sql = `SELECT ${selectCols} FROM "${source.remoteTable}" r
381
402
  JOIN "${source.junctionTable}" j ON j."${source.remoteKey}" = r."${remotePk}"
382
403
  WHERE j."${source.localKey}" = ?`;
383
404
  sql = appendQueryOptions(sql, params, source, "r");
@@ -402,6 +423,19 @@ function resolveEntitySource(source, entityRow, entityPk, adapter) {
402
423
  }
403
424
  case "custom":
404
425
  return source.query(entityRow, adapter);
426
+ case "enriched": {
427
+ const enriched = { ...entityRow };
428
+ for (const [key, lookup] of Object.entries(source.include)) {
429
+ const fieldName = `_${key}`;
430
+ if (lookup.type === "custom") {
431
+ enriched[fieldName] = JSON.stringify(lookup.query(entityRow, adapter));
432
+ } else {
433
+ const resolved = resolveEntitySource(lookup, entityRow, entityPk, adapter);
434
+ enriched[fieldName] = JSON.stringify(resolved);
435
+ }
436
+ }
437
+ return [enriched];
438
+ }
405
439
  }
406
440
  }
407
441
  function truncateContent(content, budget) {
@@ -614,7 +648,8 @@ var RenderEngine = class {
614
648
  mkdirSync2(entityDir, { recursive: true });
615
649
  const renderedFiles = /* @__PURE__ */ new Map();
616
650
  for (const [filename, spec] of Object.entries(def.files)) {
617
- const source = def.sourceDefaults && spec.source.type !== "self" && spec.source.type !== "custom" ? { ...def.sourceDefaults, ...spec.source } : spec.source;
651
+ const mergeDefaults = def.sourceDefaults && spec.source.type !== "self" && spec.source.type !== "custom" && spec.source.type !== "enriched";
652
+ const source = mergeDefaults ? { ...def.sourceDefaults, ...spec.source } : spec.source;
618
653
  const rows = resolveEntitySource(source, entityRow, entityPk, this._adapter);
619
654
  if (spec.omitIfEmpty && rows.length === 0) continue;
620
655
  const content = truncateContent(spec.render(rows), spec.budget);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latticesql",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Persistent structured memory for AI agent systems — SQLite ↔ LLM context bridge",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",