latticesql 3.0.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,313 @@
1
+ # Example: Ticket Tracker
2
+
3
+ A complete example showing how to build a simple ticket tracker with `latticesql` — users, tickets, comments, and automatic Markdown context generation.
4
+
5
+ ---
6
+
7
+ ## Scenario
8
+
9
+ A small engineering team wants:
10
+
11
+ - A SQLite-backed ticket system
12
+ - Automatic Markdown snapshots of the ticket board for LLM context
13
+ - TypeScript types generated from the schema
14
+ - Query support for filtering by status and assignee
15
+
16
+ ---
17
+
18
+ ## Project structure
19
+
20
+ ```
21
+ ticket-tracker/
22
+ ├── lattice.config.yml
23
+ ├── src/
24
+ │ ├── db.ts
25
+ │ └── tickets.ts
26
+ ├── context/
27
+ │ ├── USERS.md
28
+ │ ├── TICKETS.md
29
+ │ └── COMMENTS.md
30
+ ├── data/
31
+ │ └── tickets.db
32
+ └── generated/
33
+ ├── types.ts
34
+ └── migration.sql
35
+ ```
36
+
37
+ ---
38
+
39
+ ## 1. Schema
40
+
41
+ ```yaml
42
+ # lattice.config.yml
43
+ db: ./data/tickets.db
44
+
45
+ entities:
46
+ user:
47
+ fields:
48
+ id: { type: uuid, primaryKey: true }
49
+ name: { type: text, required: true }
50
+ email: { type: text, required: true }
51
+ team: { type: text }
52
+ render: default-table
53
+ outputFile: context/USERS.md
54
+
55
+ ticket:
56
+ fields:
57
+ id: { type: uuid, primaryKey: true }
58
+ title: { type: text, required: true }
59
+ description: { type: text }
60
+ status: { type: text, default: open }
61
+ priority: { type: integer, default: 2 }
62
+ reporter_id: { type: uuid, ref: user }
63
+ assignee_id: { type: uuid, ref: user }
64
+ created_at: { type: datetime }
65
+ closed_at: { type: datetime }
66
+ render:
67
+ template: default-list
68
+ formatRow: '[{{status}}] P{{priority}} {{title}} — {{assignee.name}}'
69
+ outputFile: context/TICKETS.md
70
+
71
+ comment:
72
+ fields:
73
+ id: { type: uuid, primaryKey: true }
74
+ body: { type: text, required: true }
75
+ ticket_id: { type: uuid, ref: ticket }
76
+ author_id: { type: uuid, ref: user }
77
+ created_at: { type: datetime }
78
+ render:
79
+ template: default-list
80
+ formatRow: '{{author.name}} ({{created_at}}): {{body}}'
81
+ outputFile: context/COMMENTS.md
82
+ ```
83
+
84
+ ---
85
+
86
+ ## 2. Generate types and migration
87
+
88
+ ```sh
89
+ npx lattice generate --out src/generated
90
+ ```
91
+
92
+ `src/generated/types.ts`:
93
+
94
+ ```ts
95
+ export interface User {
96
+ id: string;
97
+ name: string;
98
+ email: string;
99
+ team?: string;
100
+ }
101
+
102
+ export interface Ticket {
103
+ id: string;
104
+ title: string;
105
+ description?: string;
106
+ status?: string;
107
+ priority?: number;
108
+ reporter_id?: string; // → user
109
+ assignee_id?: string; // → user
110
+ created_at?: string;
111
+ closed_at?: string;
112
+ }
113
+
114
+ export interface Comment {
115
+ id: string;
116
+ body: string;
117
+ ticket_id?: string; // → ticket
118
+ author_id?: string; // → user
119
+ created_at?: string;
120
+ }
121
+ ```
122
+
123
+ ---
124
+
125
+ ## 3. Database setup
126
+
127
+ ```ts
128
+ // src/db.ts
129
+ import { Lattice } from 'latticesql';
130
+
131
+ export const db = new Lattice({
132
+ config: './lattice.config.yml',
133
+ options: { wal: true },
134
+ });
135
+
136
+ await db.init({
137
+ migrations: [
138
+ // v1: add tags column after initial release
139
+ {
140
+ version: 1,
141
+ sql: "ALTER TABLE ticket ADD COLUMN tags TEXT DEFAULT '[]'",
142
+ },
143
+ ],
144
+ });
145
+ ```
146
+
147
+ ---
148
+
149
+ ## 4. Core ticket operations
150
+
151
+ ```ts
152
+ // src/tickets.ts
153
+ import { db } from './db.js';
154
+
155
+ // --- Users ---
156
+
157
+ export async function createUser(name: string, email: string, team?: string) {
158
+ return db.insert('user', {
159
+ name,
160
+ email,
161
+ team: team ?? null,
162
+ id: undefined, // auto-generated
163
+ });
164
+ }
165
+
166
+ // --- Tickets ---
167
+
168
+ export async function openTicket(opts: {
169
+ title: string;
170
+ description?: string;
171
+ priority?: number;
172
+ reporterId: string;
173
+ assigneeId?: string;
174
+ }) {
175
+ return db.insert('ticket', {
176
+ title: opts.title,
177
+ description: opts.description ?? null,
178
+ status: 'open',
179
+ priority: opts.priority ?? 2,
180
+ reporter_id: opts.reporterId,
181
+ assignee_id: opts.assigneeId ?? null,
182
+ created_at: new Date().toISOString(),
183
+ });
184
+ }
185
+
186
+ export async function closeTicket(id: string) {
187
+ await db.update('ticket', id, {
188
+ status: 'closed',
189
+ closed_at: new Date().toISOString(),
190
+ });
191
+ }
192
+
193
+ export async function assignTicket(id: string, assigneeId: string) {
194
+ await db.update('ticket', id, { assignee_id: assigneeId });
195
+ }
196
+
197
+ export async function getOpenTickets(assigneeId?: string) {
198
+ if (assigneeId) {
199
+ return db.query('ticket', {
200
+ where: { status: 'open', assignee_id: assigneeId },
201
+ orderBy: 'priority',
202
+ orderDir: 'desc',
203
+ });
204
+ }
205
+ return db.query('ticket', {
206
+ where: { status: 'open' },
207
+ orderBy: 'priority',
208
+ orderDir: 'desc',
209
+ });
210
+ }
211
+
212
+ export async function searchTickets(titleFragment: string) {
213
+ return db.query('ticket', {
214
+ filters: [
215
+ { col: 'title', op: 'like', val: `%${titleFragment}%` },
216
+ { col: 'status', op: 'ne', val: 'archived' },
217
+ ],
218
+ });
219
+ }
220
+
221
+ export async function getHighPriorityOpen() {
222
+ return db.query('ticket', {
223
+ filters: [
224
+ { col: 'status', op: 'eq', val: 'open' },
225
+ { col: 'priority', op: 'gte', val: 3 },
226
+ ],
227
+ orderBy: 'priority',
228
+ orderDir: 'desc',
229
+ });
230
+ }
231
+
232
+ // --- Comments ---
233
+
234
+ export async function addComment(ticketId: string, authorId: string, body: string) {
235
+ return db.insert('comment', {
236
+ body,
237
+ ticket_id: ticketId,
238
+ author_id: authorId,
239
+ created_at: new Date().toISOString(),
240
+ });
241
+ }
242
+
243
+ export async function getComments(ticketId: string) {
244
+ return db.query('comment', {
245
+ where: { ticket_id: ticketId },
246
+ orderBy: 'created_at',
247
+ });
248
+ }
249
+ ```
250
+
251
+ ---
252
+
253
+ ## 5. Reports and counts
254
+
255
+ ```ts
256
+ // Dashboard stats
257
+ const openCount = await db.count('ticket', { where: { status: 'open' } });
258
+ const closedCount = await db.count('ticket', { where: { status: 'closed' } });
259
+
260
+ // Tickets assigned to no-one
261
+ const unassigned = await db.query('ticket', {
262
+ filters: [
263
+ { col: 'assignee_id', op: 'isNull' },
264
+ { col: 'status', op: 'eq', val: 'open' },
265
+ ],
266
+ });
267
+
268
+ console.log(`Open: ${openCount}, Closed: ${closedCount}, Unassigned: ${unassigned.length}`);
269
+ ```
270
+
271
+ ---
272
+
273
+ ## 6. Context sync
274
+
275
+ ```ts
276
+ // Keep context files fresh — sync every 30 seconds
277
+ const stop = await db.watch('./context', {
278
+ interval: 30_000,
279
+ onRender: (r) => console.log(`[sync] ${r.filesWritten.length} files updated`),
280
+ });
281
+
282
+ process.on('SIGTERM', () => {
283
+ stop();
284
+ db.close();
285
+ });
286
+ ```
287
+
288
+ ---
289
+
290
+ ## 7. Sample context output
291
+
292
+ **`context/TICKETS.md`:**
293
+
294
+ ```markdown
295
+ # ticket
296
+
297
+ - [open] P3 Auth tokens expire too early — Alice
298
+ - [open] P2 Slow search on large datasets — (unassigned)
299
+ - [closed] P2 Fix CSV export encoding — Bob
300
+ ```
301
+
302
+ **`context/USERS.md`:**
303
+
304
+ ```markdown
305
+ # user
306
+
307
+ | id | name | email | team |
308
+ | --- | ----- | ----------------- | -------- |
309
+ | u-1 | Alice | alice@example.com | Backend |
310
+ | u-2 | Bob | bob@example.com | Frontend |
311
+ ```
312
+
313
+ An LLM reading the ticket context immediately sees what's open, who owns what, and at what priority — without needing to query the database directly.
@@ -0,0 +1,272 @@
1
+ # Migration Guide
2
+
3
+ How to evolve your database schema over time with Lattice.
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+
9
+ - [Overview](#overview)
10
+ - [How migrations work](#how-migrations-work)
11
+ - [Writing your first migration](#writing-your-first-migration)
12
+ - [Workflow: YAML config + codegen](#workflow-yaml-config--codegen)
13
+ - [Migration best practices](#migration-best-practices)
14
+ - [Common migration patterns](#common-migration-patterns)
15
+ - [Rollback strategy](#rollback-strategy)
16
+
17
+ ---
18
+
19
+ ## Overview
20
+
21
+ Lattice uses a version-tracked migration system. You provide an array of `Migration` objects when calling `init()`. Each migration runs exactly once and is tracked in a `_lattice_migrations` table inside your database.
22
+
23
+ ```ts
24
+ await db.init({
25
+ migrations: [
26
+ { version: 1, sql: 'ALTER TABLE tasks ADD COLUMN priority INTEGER DEFAULT 1' },
27
+ { version: 2, sql: 'CREATE INDEX idx_tasks_status ON tasks (status)' },
28
+ ],
29
+ });
30
+ ```
31
+
32
+ ---
33
+
34
+ ## How migrations work
35
+
36
+ 1. On `init()`, Lattice creates `_lattice_migrations (version INTEGER PRIMARY KEY)` if it doesn't exist.
37
+ 2. For each migration in the array, Lattice checks whether its `version` is already in `_lattice_migrations`.
38
+ 3. If not present, the migration's SQL is executed and the version is recorded.
39
+ 4. Migrations are run in the order they appear in the array, not necessarily by version number — but using ascending version numbers is strongly recommended.
40
+
41
+ **Key properties:**
42
+
43
+ - Each migration runs at most once per database (idempotent across restarts)
44
+ - Migrations run inside implicit transactions (SQLite auto-commit per statement)
45
+ - If a migration fails, the error is thrown and the version is not recorded — the migration will be retried on the next startup
46
+ - `CREATE TABLE IF NOT EXISTS` in `init()` always runs (not a migration) — `columns` specs are for initial schema creation
47
+
48
+ ---
49
+
50
+ ## Writing your first migration
51
+
52
+ **Before (initial schema — `define()`):**
53
+
54
+ ```ts
55
+ db.define('tasks', {
56
+ columns: {
57
+ id: 'TEXT PRIMARY KEY',
58
+ title: 'TEXT NOT NULL',
59
+ status: "TEXT DEFAULT 'open'",
60
+ },
61
+ render: 'default-list',
62
+ outputFile: 'context/TASKS.md',
63
+ });
64
+ await db.init();
65
+ ```
66
+
67
+ **After (add a column in v0.2 of your app):**
68
+
69
+ ```ts
70
+ db.define('tasks', {
71
+ columns: {
72
+ id: 'TEXT PRIMARY KEY',
73
+ title: 'TEXT NOT NULL',
74
+ status: "TEXT DEFAULT 'open'",
75
+ priority: 'INTEGER DEFAULT 1', // New column
76
+ },
77
+ render: 'default-list',
78
+ outputFile: 'context/TASKS.md',
79
+ });
80
+
81
+ await db.init({
82
+ migrations: [
83
+ {
84
+ version: 1,
85
+ sql: 'ALTER TABLE tasks ADD COLUMN priority INTEGER DEFAULT 1',
86
+ },
87
+ ],
88
+ });
89
+ ```
90
+
91
+ The `columns` spec reflects the _current_ desired schema (used for new databases). The migration handles the _delta_ for existing databases. Both code paths result in the same final schema.
92
+
93
+ ---
94
+
95
+ ## Workflow: YAML config + codegen
96
+
97
+ When using `lattice.config.yml`, the recommended workflow is:
98
+
99
+ ### Step 1: Update the YAML config
100
+
101
+ Add the new field to your entity:
102
+
103
+ ```yaml
104
+ # lattice.config.yml
105
+ entities:
106
+ task:
107
+ fields:
108
+ id: { type: uuid, primaryKey: true }
109
+ title: { type: text, required: true }
110
+ status: { type: text, default: open }
111
+ priority: { type: integer, default: 1 } # ← added
112
+ render: default-list
113
+ outputFile: context/TASKS.md
114
+ ```
115
+
116
+ ### Step 2: Regenerate
117
+
118
+ ```sh
119
+ lattice generate
120
+ ```
121
+
122
+ This regenerates `generated/types.ts` (with `priority?: number` on the `Task` interface) and `generated/migration.sql` (with `"priority" INTEGER DEFAULT 1` in the `CREATE TABLE` statement).
123
+
124
+ ### Step 3: Write the migration
125
+
126
+ The generated `migration.sql` is for fresh databases. For existing databases, write a numbered migration in your application startup code:
127
+
128
+ ```ts
129
+ await db.init({
130
+ migrations: [{ version: 1, sql: 'ALTER TABLE tasks ADD COLUMN priority INTEGER DEFAULT 1' }],
131
+ });
132
+ ```
133
+
134
+ Keep migrations in a dedicated file so they accumulate over time:
135
+
136
+ ```ts
137
+ // src/migrations.ts
138
+ import type { Migration } from 'latticesql';
139
+
140
+ export const migrations: Migration[] = [
141
+ {
142
+ version: 1,
143
+ sql: 'ALTER TABLE tasks ADD COLUMN priority INTEGER DEFAULT 1',
144
+ },
145
+ {
146
+ version: 2,
147
+ sql: "ALTER TABLE tasks ADD COLUMN tags TEXT DEFAULT '[]'",
148
+ },
149
+ {
150
+ version: 3,
151
+ sql: 'CREATE INDEX idx_tasks_priority ON tasks (priority DESC)',
152
+ },
153
+ ];
154
+ ```
155
+
156
+ ```ts
157
+ import { migrations } from './migrations.js';
158
+
159
+ await db.init({ migrations });
160
+ ```
161
+
162
+ ---
163
+
164
+ ## Migration best practices
165
+
166
+ **Number migrations sequentially.** Use `1, 2, 3...` — never skip numbers. If two developers add migrations simultaneously, coordinate version numbers before merging.
167
+
168
+ **Never change a committed migration.** Once a migration is deployed and has run on any database, treat it as immutable. If you need to fix it, write a new migration that corrects the previous one.
169
+
170
+ **Keep migrations small.** One change per migration. This makes failures easy to diagnose and rollbacks straightforward.
171
+
172
+ **Test migrations.** Run your full migration sequence against a copy of production data in CI. Lattice's test utilities (`':memory:'` db) are useful for unit tests, but test against real data for confidence.
173
+
174
+ **Document each migration.** Use a comment in the SQL or the `migrations.ts` file explaining why the change was made:
175
+
176
+ ```ts
177
+ {
178
+ version: 4,
179
+ // Add soft-delete support — marketing wants to recover deleted tasks
180
+ sql: 'ALTER TABLE tasks ADD COLUMN deleted_at TEXT',
181
+ },
182
+ ```
183
+
184
+ ---
185
+
186
+ ## Common migration patterns
187
+
188
+ ### Add a nullable column
189
+
190
+ ```ts
191
+ { version: 5, sql: 'ALTER TABLE tasks ADD COLUMN notes TEXT' }
192
+ ```
193
+
194
+ SQLite `ALTER TABLE ADD COLUMN` supports nullable columns with no default. All existing rows get `NULL`.
195
+
196
+ ### Add a column with a default
197
+
198
+ ```ts
199
+ { version: 6, sql: "ALTER TABLE tasks ADD COLUMN status TEXT DEFAULT 'open'" }
200
+ ```
201
+
202
+ ### Add an index
203
+
204
+ ```ts
205
+ { version: 7, sql: 'CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks (status)' }
206
+ ```
207
+
208
+ Use `IF NOT EXISTS` to make the migration idempotent even if manually applied.
209
+
210
+ ### Rename a column (SQLite limitation)
211
+
212
+ SQLite does not support `ALTER TABLE RENAME COLUMN` in older versions. The standard approach is a table copy:
213
+
214
+ ```ts
215
+ {
216
+ version: 8,
217
+ sql: `
218
+ CREATE TABLE tasks_new AS SELECT
219
+ id, title, status, priority,
220
+ due_at AS deadline,
221
+ created_at
222
+ FROM tasks;
223
+ DROP TABLE tasks;
224
+ ALTER TABLE tasks_new RENAME TO tasks;
225
+ `.trim(),
226
+ }
227
+ ```
228
+
229
+ > **Warning:** This drops and recreates the table. All indexes are lost and must be recreated in a subsequent migration.
230
+
231
+ ### Add a NOT NULL column to an existing table
232
+
233
+ SQLite requires a `DEFAULT` on `ALTER TABLE ADD COLUMN` when `NOT NULL` is specified:
234
+
235
+ ```ts
236
+ {
237
+ version: 9,
238
+ sql: "ALTER TABLE tasks ADD COLUMN category TEXT NOT NULL DEFAULT 'general'",
239
+ }
240
+ ```
241
+
242
+ ### Backfill data after adding a column
243
+
244
+ ```ts
245
+ {
246
+ version: 10,
247
+ sql: `
248
+ ALTER TABLE tasks ADD COLUMN slug TEXT;
249
+ UPDATE tasks SET slug = LOWER(REPLACE(title, ' ', '-')) WHERE slug IS NULL;
250
+ `.trim(),
251
+ }
252
+ ```
253
+
254
+ ---
255
+
256
+ ## Rollback strategy
257
+
258
+ SQLite does not support transactional DDL (Data Definition Language) across multiple statements in the same way Postgres does. In practice, this means:
259
+
260
+ - **Test first.** Run migrations against a staging database before production.
261
+ - **Back up before migrating.** Lattice does not snapshot your database — do it yourself:
262
+ ```sh
263
+ cp data/app.db data/app.db.bak-$(date +%Y%m%d%H%M%S)
264
+ ```
265
+ - **Simple column additions are safe.** `ALTER TABLE ADD COLUMN` is non-destructive. Rollback = add nothing.
266
+ - **Table rebuilds are risky.** If a rename migration fails halfway, the database may be in an inconsistent state. Restore from backup.
267
+
268
+ For production deployments, always:
269
+
270
+ 1. Back up the database
271
+ 2. Run migrations in a pre-start hook before the app opens the connection
272
+ 3. Have a tested restore procedure