latticesql 0.7.0 → 0.9.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");
@@ -736,6 +757,165 @@ function truncateContent(content, budget) {
736
757
  return content.slice(0, budget) + "\n\n*[truncated \u2014 context budget exceeded]*";
737
758
  }
738
759
 
760
+ // src/render/markdown.ts
761
+ function frontmatter(fields) {
762
+ const lines = [`generated_at: "${(/* @__PURE__ */ new Date()).toISOString()}"`];
763
+ for (const [key, val] of Object.entries(fields)) {
764
+ lines.push(typeof val === "string" ? `${key}: "${val}"` : `${key}: ${String(val)}`);
765
+ }
766
+ return `---
767
+ ${lines.join("\n")}
768
+ ---
769
+
770
+ `;
771
+ }
772
+ function markdownTable(rows, columns) {
773
+ if (rows.length === 0 || columns.length === 0) return "";
774
+ const header = "| " + columns.map((c) => c.header).join(" | ") + " |";
775
+ const separator = "| " + columns.map(() => "---").join(" | ") + " |";
776
+ const body = rows.map((row) => {
777
+ const cells = columns.map((col) => {
778
+ const raw = row[col.key];
779
+ return col.format ? col.format(raw, row) : String(raw ?? "");
780
+ });
781
+ return "| " + cells.join(" | ") + " |";
782
+ });
783
+ return [header, separator, ...body].join("\n") + "\n";
784
+ }
785
+
786
+ // src/session/constants.ts
787
+ function createReadOnlyHeader(options) {
788
+ const generator = options?.generator ?? "Lattice";
789
+ const docsRef = options?.docsRef ?? "the Lattice documentation";
790
+ return `<!-- READ ONLY \u2014 generated by ${generator}. Do not edit directly.
791
+ To update data in Lattice: write entries to SESSION.md in this directory.
792
+ Format: type: write | op: create/update/delete | table: <name> | target: <id>
793
+ See ${docsRef} for the SESSION.md format spec. -->
794
+
795
+ `;
796
+ }
797
+ var READ_ONLY_HEADER = createReadOnlyHeader();
798
+
799
+ // src/render/entity-templates.ts
800
+ var DEFAULT_HEADER = createReadOnlyHeader();
801
+ function compileEntityRender(spec) {
802
+ if (typeof spec === "function") return spec;
803
+ return compileTemplate(spec);
804
+ }
805
+ function compileTemplate(tmpl) {
806
+ switch (tmpl.template) {
807
+ case "entity-table":
808
+ return compileEntityTable(tmpl);
809
+ case "entity-profile":
810
+ return compileEntityProfile(tmpl);
811
+ case "entity-sections":
812
+ return compileEntitySections(tmpl);
813
+ }
814
+ }
815
+ function compileEntityTable(tmpl) {
816
+ return (rows) => {
817
+ const data = tmpl.beforeRender ? tmpl.beforeRender(rows) : rows;
818
+ let md = DEFAULT_HEADER;
819
+ md += frontmatter(tmpl.frontmatter ?? {});
820
+ md += `# ${tmpl.heading}
821
+
822
+ `;
823
+ if (data.length === 0) {
824
+ md += tmpl.emptyMessage ?? "*No data.*\n";
825
+ } else {
826
+ md += markdownTable(data, tmpl.columns);
827
+ }
828
+ return md;
829
+ };
830
+ }
831
+ function compileEntityProfile(tmpl) {
832
+ return (rows) => {
833
+ const data = tmpl.beforeRender ? tmpl.beforeRender(rows) : rows;
834
+ const r = data[0];
835
+ if (!r) return "";
836
+ let md = DEFAULT_HEADER;
837
+ if (tmpl.frontmatter) {
838
+ const fm = typeof tmpl.frontmatter === "function" ? tmpl.frontmatter(r) : tmpl.frontmatter;
839
+ md += frontmatter(fm);
840
+ } else {
841
+ md += frontmatter({});
842
+ }
843
+ const heading = typeof tmpl.heading === "function" ? tmpl.heading(r) : tmpl.heading;
844
+ md += `# ${heading}
845
+
846
+ `;
847
+ for (const field of tmpl.fields) {
848
+ const val = r[field.key];
849
+ if (val === null || val === void 0) continue;
850
+ const formatted = field.format ? field.format(val, r) : String(val);
851
+ if (formatted) {
852
+ md += `**${field.label}:** ${formatted}
853
+ `;
854
+ }
855
+ }
856
+ if (tmpl.sections) {
857
+ for (const section of tmpl.sections) {
858
+ const rawJson = r[`_${section.key}`];
859
+ if (!rawJson) continue;
860
+ if (section.condition && !section.condition(r)) continue;
861
+ const items = JSON.parse(rawJson);
862
+ if (items.length === 0) continue;
863
+ const sectionHeading = typeof section.heading === "function" ? section.heading(r) : section.heading;
864
+ md += `
865
+ ## ${sectionHeading}
866
+
867
+ `;
868
+ if (section.render === "table" && section.columns) {
869
+ md += markdownTable(items, section.columns);
870
+ } else if (section.render === "list" && section.formatItem) {
871
+ for (const item of items) {
872
+ md += `- ${section.formatItem(item)}
873
+ `;
874
+ }
875
+ } else if (typeof section.render === "function") {
876
+ md += section.render(items);
877
+ }
878
+ }
879
+ }
880
+ return md;
881
+ };
882
+ }
883
+ function compileEntitySections(tmpl) {
884
+ return (rows) => {
885
+ const data = tmpl.beforeRender ? tmpl.beforeRender(rows) : rows;
886
+ let md = DEFAULT_HEADER;
887
+ md += frontmatter(tmpl.frontmatter ?? {});
888
+ md += `# ${tmpl.heading}
889
+
890
+ `;
891
+ if (data.length === 0) {
892
+ md += tmpl.emptyMessage ?? "*No data.*\n";
893
+ return md;
894
+ }
895
+ for (const row of data) {
896
+ md += `## ${tmpl.perRow.heading(row)}
897
+ `;
898
+ if (tmpl.perRow.metadata?.length) {
899
+ const parts = tmpl.perRow.metadata.map((m) => {
900
+ const val = row[m.key];
901
+ const formatted = m.format ? m.format(val) : String(val ?? "");
902
+ return `**${m.label}:** ${formatted}`;
903
+ }).filter(Boolean);
904
+ if (parts.length > 0) {
905
+ md += parts.join(" | ") + "\n";
906
+ }
907
+ }
908
+ if (tmpl.perRow.body) {
909
+ md += `
910
+ ${tmpl.perRow.body(row)}
911
+ `;
912
+ }
913
+ md += "\n";
914
+ }
915
+ return md;
916
+ };
917
+ }
918
+
739
919
  // src/lifecycle/cleanup.ts
740
920
  import { join as join4 } from "path";
741
921
  import { existsSync as existsSync4, readdirSync, unlinkSync, rmdirSync, statSync } from "fs";
@@ -945,7 +1125,8 @@ var RenderEngine = class {
945
1125
  const source = mergeDefaults ? { ...def.sourceDefaults, ...spec.source } : spec.source;
946
1126
  const rows = resolveEntitySource(source, entityRow, entityPk, this._adapter);
947
1127
  if (spec.omitIfEmpty && rows.length === 0) continue;
948
- const content = truncateContent(spec.render(rows), spec.budget);
1128
+ const renderFn = compileEntityRender(spec.render);
1129
+ const content = truncateContent(renderFn(rows), spec.budget);
949
1130
  renderedFiles.set(filename, content);
950
1131
  const filePath = join5(entityDir, filename);
951
1132
  if (atomicWrite(filePath, content)) {