latticesql 1.3.1 → 1.4.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
@@ -861,6 +861,22 @@ Migrations are idempotent — each `version` number is applied exactly once, tra
861
861
 
862
862
  `close()` closes the SQLite connection. Call it when the process shuts down.
863
863
 
864
+ #### Migration validation (v1.4+)
865
+
866
+ Pass a `validateMigrationSQL` function in `InitOptions` to validate migration SQL before any migrations execute. If validation fails, no migrations run and an error is thrown.
867
+
868
+ ```typescript
869
+ await db.init({
870
+ migrations: [{ version: 1, sql: 'ALTER TABLE tasks ADD COLUMN due_date TEXT' }],
871
+ validateMigrationSQL: (sql) => {
872
+ if (sql.trim().length === 0) return { valid: false, errors: ['Empty SQL'] };
873
+ return { valid: true };
874
+ },
875
+ });
876
+ ```
877
+
878
+ Multi-statement migrations are fully supported — each migration's SQL can contain multiple semicolon-separated statements, all of which are executed.
879
+
864
880
  ### `migrate()` (v0.17+)
865
881
 
866
882
  ```typescript
@@ -1052,6 +1068,30 @@ await db.count(table: string, opts?: CountOptions): Promise<number>
1052
1068
  const n = await db.count('tasks', { where: { status: 'open' } });
1053
1069
  ```
1054
1070
 
1071
+ #### `isDirty()` / `markDirty()` (v1.4+)
1072
+
1073
+ ```typescript
1074
+ db.isDirty(): boolean
1075
+ db.markDirty(table?: string): this
1076
+ ```
1077
+
1078
+ Lattice tracks a per-table write version counter. `isDirty()` returns `true` when any table has been written to since the last `render()` call — useful for consumers implementing custom polling loops to skip redundant render cycles.
1079
+
1080
+ `markDirty()` forces one or all tables to be considered changed. Call it after writing directly via the `db` escape hatch.
1081
+
1082
+ ```typescript
1083
+ // Custom watch loop that skips redundant renders
1084
+ setInterval(async () => {
1085
+ if (db.isDirty()) {
1086
+ await db.render(outputDir);
1087
+ }
1088
+ }, 5000);
1089
+
1090
+ // After direct DB writes, mark dirty so next render picks up changes
1091
+ db.db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run('done', taskId);
1092
+ db.markDirty('tasks');
1093
+ ```
1094
+
1055
1095
  ---
1056
1096
 
1057
1097
  ### Query operators
@@ -1264,6 +1304,18 @@ const rows = db.db
1264
1304
  .all('open');
1265
1305
  ```
1266
1306
 
1307
+ After direct writes via the escape hatch, call `db.markDirty('table')` so `isDirty()` reflects the change.
1308
+
1309
+ ---
1310
+
1311
+ ### Performance (v1.4+)
1312
+
1313
+ Lattice v1.4 includes two internal performance improvements that require no API changes:
1314
+
1315
+ - **Prepared statement cache** — `SQLiteAdapter` caches compiled `better-sqlite3` statements. Repeated calls with the same SQL string reuse the compiled statement instead of recompiling. DDL statements bypass the cache. The cache clears automatically after schema changes and on `close()`.
1316
+
1317
+ - **Batch entity query resolution** — Entity context rendering pre-fetches related rows for all entities in a single `WHERE IN (...)` query per source, replacing the previous per-entity query pattern. `hasMany`, `manyToMany`, and `belongsTo` sources are batched automatically. `custom` and `enriched` sources fall back to per-entity resolution.
1318
+
1267
1319
  ---
1268
1320
 
1269
1321
  ### Context optimization (v1.3+)
@@ -1279,8 +1331,8 @@ db.define('tickets', {
1279
1331
  columns: { id: 'TEXT PRIMARY KEY', title: 'TEXT', updated_at: 'TEXT' },
1280
1332
  render: (rows) => rows.map((r) => `- ${r.title}`).join('\n'),
1281
1333
  outputFile: 'TICKETS.md',
1282
- tokenBudget: 4000, // max estimated tokens (~4 chars/token)
1283
- prioritizeBy: 'updated_at', // keep most recent rows when pruning
1334
+ tokenBudget: 4000, // max estimated tokens (~4 chars/token)
1335
+ prioritizeBy: 'updated_at', // keep most recent rows when pruning
1284
1336
  });
1285
1337
  ```
1286
1338
 
@@ -1314,11 +1366,12 @@ db.define('incidents', {
1314
1366
  render: (rows) => JSON.stringify(rows, null, 2),
1315
1367
  outputFile: 'incidents.json',
1316
1368
  enrich: [
1317
- (rows) => rows.map((r) => ({
1318
- ...r,
1319
- _age_hours: Math.round((Date.now() - new Date(r.created_at as string).getTime()) / 3600000),
1320
- })),
1321
- (rows) => rows.length > 100 ? [{ _summary: `${rows.length} incidents` }] : rows,
1369
+ (rows) =>
1370
+ rows.map((r) => ({
1371
+ ...r,
1372
+ _age_hours: Math.round((Date.now() - new Date(r.created_at as string).getTime()) / 3600000),
1373
+ })),
1374
+ (rows) => (rows.length > 100 ? [{ _summary: `${rows.length} incidents` }] : rows),
1322
1375
  ],
1323
1376
  });
1324
1377
  ```
@@ -1332,8 +1385,8 @@ db.define('tips', {
1332
1385
  columns: { id: 'TEXT PRIMARY KEY', tip: 'TEXT', deleted_at: 'TEXT' },
1333
1386
  render: (rows) => rows.map((r) => `- ${r.tip}`).join('\n'),
1334
1387
  outputFile: 'TIPS.md',
1335
- rewardTracking: true, // auto-adds _reward_total, _reward_count columns
1336
- pruneBelow: 0.3, // soft-delete rows with reward < 0.3 (requires deleted_at column)
1388
+ rewardTracking: true, // auto-adds _reward_total, _reward_count columns
1389
+ pruneBelow: 0.3, // soft-delete rows with reward < 0.3 (requires deleted_at column)
1337
1390
  });
1338
1391
 
1339
1392
  await db.init();
@@ -1381,7 +1434,9 @@ Validate agent-written data before persisting. Reject low-quality or hallucinate
1381
1434
  db.defineWriteback({
1382
1435
  file: './agent-output/*.md',
1383
1436
  parse: (content, offset) => ({ entries: [content.slice(offset)], nextOffset: content.length }),
1384
- persist: async (entry) => { /* save to DB */ },
1437
+ persist: async (entry) => {
1438
+ /* save to DB */
1439
+ },
1385
1440
  validate: async (entry) => {
1386
1441
  const text = entry as string;
1387
1442
  const hasRequiredFields = text.includes('## Title') && text.includes('## Body');
@@ -2117,7 +2172,7 @@ const db = new Lattice('./app.db', {
2117
2172
  │ │ entity contexts │ │
2118
2173
  ├──────────────────┴──────────────────┴───────────────────────────────┤
2119
2174
  │ SQLiteAdapter │
2120
- (better-sqlite3 — synchronous I/O)
2175
+ (better-sqlite3 — synchronous I/O, statement cache)
2121
2176
  └──────────────────────────────────────────────────────────────────────┘
2122
2177
  │ │
2123
2178
  │ compileRender() │ lifecycle/
@@ -2135,7 +2190,7 @@ const db = new Lattice('./app.db', {
2135
2190
 
2136
2191
  **Key design decisions:**
2137
2192
 
2138
- - **Synchronous SQLite** — `better-sqlite3` gives synchronous reads; all Lattice CRUD methods return Promises for API consistency but resolve synchronously under the hood.
2193
+ - **Synchronous SQLite** — `better-sqlite3` gives synchronous reads; all Lattice CRUD methods return Promises for API consistency but resolve synchronously under the hood. Prepared statements are cached and reused automatically (v1.4+).
2139
2194
  - **Compile-time render** — `RenderSpec` is compiled to a plain `(rows: Row[]) => string` function at `define()`-time, not at render-time. `RenderEngine` stays unchanged.
2140
2195
  - **Atomic writes** — files are written to a `.tmp` sibling then renamed. No partial writes, no reader sees incomplete content.
2141
2196
  - **Schema-additive only** — Lattice never drops tables or columns automatically; it only adds missing ones.