@xwxfox/foxdb 0.1.1 → 0.1.3

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
@@ -1,275 +1,476 @@
1
- # foxdb ~ a typed sqlite orm for bun :3
2
-
3
- > built on top of [typebox](https://github.com/sinclairzx81/typebox) schemas and `bun:sqlite`. fast, tiny, fully typed :3
4
-
5
- foxdb gives you a repository-style orm where your database schema *is* your typescript types. no codegen, no decorators, no magic. just plain typebox objects that compile to sqlite tables and give you autocomplete everywhere ~
6
-
7
-
8
- > Credit to [@deadlinecode](https://github.com/deadlinecode) for the original idea and base :3
9
-
10
- ## quick start
11
-
12
- ```bash
13
- bun add @xwxfox/foxdb
14
- ```
15
-
16
- ```typescript
17
- import { Object, String, Number, Integer } from "typebox";
18
- import { createORM, table } from "@xwxfox/foxdb";
19
-
20
- const UserSchema = Object({
21
- id: String(),
22
- name: String(),
23
- email: String(),
24
- age: Integer(),
25
- });
26
-
27
- const orm = createORM({
28
- tables: {
29
- users: table(UserSchema, (s) => ({
30
- primaryKey: s.id,
31
- indexes: [{ columns: [s.email] }],
32
- })),
33
- },
34
- });
35
-
36
- // insert
37
- const user = orm.users.insert({
38
- id: "usr-1",
39
- name: "alice",
40
- email: "alice@example.com",
41
- age: 30,
42
- });
43
-
44
- // find
45
- const found = orm.users.findById("usr-1");
46
-
47
- // update
48
- orm.users.update({ id: "usr-1", name: "alice smith" });
49
-
50
- // query
51
- const adults = orm.users.findMany({
52
- where: { age: { gte: 18 } },
53
- orderBy: { column: "name", direction: "ASC" },
54
- });
55
-
56
- // paginate
57
- const page = orm.users.findPage({
58
- where: { age: { gte: 18 } },
59
- limit: 10,
60
- offset: 0,
61
- });
62
-
63
- orm._close();
64
- ```
65
-
66
- ## why foxdb
67
-
68
- - **zero codegen** - your typebox schema *is* the source of truth. no `prisma generate`, no migration files to keep in sync :3
69
- - **fully typed** - every query, insert, update, and relation is typed end-to-end. try passing the wrong column name and typescript will bonk you
70
- - **tiny** - ~2kb overhead on top of `bun:sqlite`. no external query builder, no connection pool, no bloat
71
- - **relations** - scalar relations (lazy) and sub-table relations (batch resolved) with a fluent builder
72
- - **events** - listen to table events (`insert`, `update`, `read`, `write`, etc.) typed to your schema. zero overhead unless you subscribe ~
73
- - **lifecycle hooks** - `onStart`, `onReady`, `onShutdown`, `onExit` for seeding, migrating, cleaning up
74
- - **sub-tables** - arrays of objects are automatically split into separate sqlite tables with proper indexing
75
-
76
- ## core concepts
77
-
78
- ### schemas
79
-
80
- use [typebox](https://github.com/sinclairzx81/typebox) to define your data shape. foxdb supports all scalar types (`String`, `Number`, `Integer`, `Boolean`, `Literal`) plus arrays of objects (sub-tables) and arrays of scalars (JSON strings).
81
-
82
- ```typescript
83
- import { Object, String, Number, Integer, Array, Optional } from "typebox";
84
-
85
- const LineItemSchema = Object({
86
- sku: String(),
87
- qty: Integer(),
88
- price: Number(),
89
- });
90
-
91
- const OrderSchema = Object({
92
- id: String(),
93
- customerId: String(),
94
- status: String(),
95
- total: Number(),
96
- tags: Array(String()), // becomes a JSON string column ~
97
- lineItems: Array(LineItemSchema), // becomes a sub-table ~
98
- });
99
- ```
100
-
101
- ### tables
102
-
103
- the `table()` helper turns a schema into a table descriptor. you pick the primary key, add indexes, and optionally enable timestamps.
104
-
105
- ```typescript
106
- table(OrderSchema, (s) => ({
107
- primaryKey: s.id,
108
- indexes: [
109
- { columns: [s.customerId] },
110
- { columns: [s.status] },
111
- ],
112
- timestamps: true, // adds createdAt / updatedAt
113
- }))
114
- ```
115
-
116
- ### repositories
117
-
118
- every table becomes a repository on the orm object. all crud methods are fully typed:
119
-
120
- - `insert(data)` - insert a record
121
- - `insertMany(records)` - batch insert in a transaction
122
- - `findById(id)` - find by primary key
123
- - `findMany(opts)` - query with where, orderBy, limit, offset
124
- - `findPage(opts)` - findMany + total count for pagination
125
- - `findOne(opts)` - findMany with limit 1
126
- - `update(data)` - merge partial data (must include pk)
127
- - `upsert(opts)` - insert or update on conflict
128
- - `deleteById(id)` - delete by pk
129
- - `deleteWhere(where)` - delete matching records
130
- - `count(where?)` - count matching records
131
- - `flush()` - truncate the table and sub-tables
132
-
133
- ### relations
134
-
135
- define cross-table relations with a fluent builder:
136
-
137
- ```typescript
138
- const orm = createORM({
139
- tables: {
140
- orders: table(OrderSchema, (s) => ({ primaryKey: s.id })),
141
- products: table(ProductSchema, (s) => ({ primaryKey: s.sku })),
142
- },
143
- relations: (r) => [
144
- r.from("orders")
145
- .subTable("lineItems", "sku")
146
- .to("products", "sku", { as: "product" }),
147
- ],
148
- });
149
-
150
- // sub-table items now have a .product property
151
- const order = orm.orders.findById("ord-1");
152
- for (const item of order.lineItems) {
153
- console.log(item.product.name); // lazy or batch resolved :3
154
- }
155
- ```
156
-
157
- ### events
158
-
159
- listen to table or lifecycle events with full type safety:
160
-
161
- ```typescript
162
- // table-specific fine-grained event
163
- const off = orm._events.on("users", "insert", (e) => {
164
- console.log(`user ${e.data.id} inserted at ${e.timestamp}`);
165
- });
166
-
167
- // broad category - catches all writes (insert, update, upsert)
168
- orm._events.on("users", "write", (e) => {
169
- console.log(`write op: ${e.operation}`);
170
- });
171
-
172
- // lifecycle events
173
- orm._events.on("ready", (e) => {
174
- console.log("orm is ready ~");
175
- });
176
-
177
- // cleanup
178
- off();
179
- ```
180
-
181
- events have **zero overhead** unless you subscribe. the event bus only builds payloads when a listener exists.
182
-
183
- ### lifecycle hooks
184
-
185
- hook into startup and shutdown to seed, migrate, or clean up:
186
-
187
- ```typescript
188
- const orm = createORM({
189
- tables: { /* ... */ },
190
-
191
- seed: (o) => {
192
- o.products.insert({ sku: "WIDGET", name: "widget", price: 9.99 });
193
- },
194
-
195
- onReady: (ctx) => {
196
- console.log("tables:", ctx.tables.join(", "));
197
- },
198
-
199
- onShutdown: (ctx) => {
200
- ctx.orm.activity.insert({
201
- id: "shutdown",
202
- message: "shutting down",
203
- level: "info",
204
- });
205
- },
206
-
207
- rebuildOnLaunch: true, // wipe db on every start
208
- unlinkDbFilesOnExit: true, // delete .db files on close
209
- });
210
- ```
211
-
212
- ### configuration
213
-
214
- ```typescript
215
- createORM({
216
- path: "myapp.db", // sqlite file path (default: ":memory:")
217
- cacheSize: -64000, // sqlite cache size in pages
218
- busyTimeout: 5000, // ms to wait for write locks
219
- synchronous: "NORMAL", // pragma synchronous level
220
- rebuildOnLaunch: false, // wipe and rebuild on start
221
- flushOnStart: ["logs"], // truncate tables before seeding
222
- dropOnExit: ["temp"], // drop tables before close
223
- autoMigrate: true, // run migrations on startup
224
- migrations: { dir: "./migrations" },
225
- errorPolicy: "throw", // "throw" | "emit" | "emit-swallow" | "crash"
226
- unlinkDbFilesOnExit: false, // true | "onlyGraceful" | "any"
227
- })
228
- ```
229
-
230
- ## error handling
231
-
232
- foxdb uses `ORMError` with invisible trace context. every throw includes the operation name, table, sql, and parameters that led to the error:
233
-
234
- ```typescript
235
- try {
236
- orm.users.insert({ id: null, name: "oops" });
237
- } catch (e) {
238
- if (e instanceof ORMError) {
239
- console.log(e.code); // "VALIDATION_FAILED"
240
- console.log(e.trace); // [{ label: "repository.insert", time: ... }]
241
- console.log(e.context); // { table: "users" }
242
- }
243
- }
244
- ```
245
-
246
- configure the error policy to crash, emit, swallow, or just throw.
247
-
248
- ## migrations
249
-
250
- foxdb can auto-run migrations on startup. create migration files and point `migrations.dir` at them:
251
-
252
- ```typescript
253
- // migrations/001-init.ts
254
- import type { Migration } from "@xwxfox/foxdb";
255
-
256
- export default {
257
- name: "init",
258
- date: "2024-01-15",
259
- up(db) {
260
- // raw sql via db.exec() if needed
261
- },
262
- } satisfies Migration;
263
- ```
264
-
265
- ```typescript
266
- createORM({
267
- tables: { /* ... */ },
268
- migrations: { dir: "./migrations" },
269
- autoMigrate: true,
270
- });
271
- ```
272
-
273
- ## license
274
-
275
- mit uwu
1
+ # foxdb ~ a typed sqlite orm for bun :3
2
+
3
+ > built on top of [typebox](https://github.com/sinclairzx81/typebox) schemas and `bun:sqlite`. fast, tiny, fully typed :3
4
+
5
+ foxdb gives you a repository-style orm where your database schema *is* your typescript types. no codegen, no decorators, no magic. just plain typebox objects that compile to sqlite tables and give you autocomplete everywhere ~
6
+
7
+
8
+ > Credit to [@deadlinecode](https://github.com/deadlinecode) for the original idea and base :3
9
+
10
+ ## quick start
11
+
12
+ ```bash
13
+ bun add @xwxfox/foxdb
14
+ ```
15
+
16
+ ```typescript
17
+ import { Object, String, Number, Integer } from "typebox";
18
+ import { createORM, table } from "@xwxfox/foxdb";
19
+
20
+ const UserSchema = Object({
21
+ id: String(),
22
+ name: String(),
23
+ email: String(),
24
+ age: Integer(),
25
+ });
26
+
27
+ const orm = createORM({
28
+ tables: {
29
+ users: table(UserSchema, (s) => ({
30
+ primaryKey: s.id,
31
+ indexes: [{ columns: [s.email] }],
32
+ })),
33
+ },
34
+ });
35
+
36
+ // insert
37
+ const user = orm.users.insert({
38
+ id: "usr-1",
39
+ name: "alice",
40
+ email: "alice@example.com",
41
+ age: 30,
42
+ });
43
+
44
+ // find
45
+ const found = orm.users.findById("usr-1");
46
+
47
+ // update
48
+ orm.users.update({ id: "usr-1", name: "alice smith" });
49
+
50
+ // query (chain api)
51
+ const adults = orm.users
52
+ .findMany()
53
+ .greaterThanOrEqual("age", 18)
54
+ .orderBy("name", "ASC")
55
+ .exec();
56
+
57
+ // paginate
58
+ const page = orm.users
59
+ .findPage()
60
+ .greaterThanOrEqual("age", 18)
61
+ .limit(10)
62
+ .page(0)
63
+ .exec();
64
+
65
+ // count
66
+ const total = orm.users.count().greaterThanOrEqual("age", 18).exec();
67
+
68
+ // aggregate
69
+ const stats = orm.users
70
+ .aggregate()
71
+ .avg("age")
72
+ .groupBy("name")
73
+ .exec();
74
+
75
+ orm._close();
76
+ ```
77
+
78
+ ## why foxdb
79
+
80
+ - **zero codegen** - your typebox schema *is* the source of truth. no `prisma generate`, no migration files to keep in sync :3
81
+ - **fully typed** - every query, insert, update, and relation is typed end-to-end. try passing the wrong column name and typescript will bonk you
82
+ - **chain query api** - `findMany().where({...}).orderBy(...).limit(10).exec()` with full type autocomplete
83
+ - **relations** - scalar relations (lazy) and sub-table relations (batch resolved) with a fluent builder
84
+ - **events** - listen to table events (`insert`, `update`, `read`, `write`, etc.) typed to your schema. zero overhead unless you subscribe ~
85
+ - **lifecycle hooks** - `onStart`, `onReady`, `onShutdown`, `onExit` for seeding, migrating, cleaning up
86
+ - **sub-tables** - arrays of objects are automatically split into separate sqlite tables with proper FK cascade and indexing
87
+ - **soft deletes** - configurable per-table soft delete column, auto-filtered from all queries
88
+ - **compression** - per-column gzip compression for large text columns
89
+ - **eviction** - automatic row cleanup with TTL or max-row limits
90
+
91
+ ## core concepts
92
+
93
+ ### schemas
94
+
95
+ use [typebox](https://github.com/sinclairzx81/typebox) to define your data shape. foxdb supports all scalar types (`String`, `Number`, `Integer`, `Boolean`, `Literal`) plus arrays of objects (sub-tables) and arrays of scalars (JSON strings).
96
+
97
+ ```typescript
98
+ import { Object, String, Number, Integer, Array, Optional } from "typebox";
99
+
100
+ const LineItemSchema = Object({
101
+ sku: String(),
102
+ qty: Integer(),
103
+ price: Number(),
104
+ });
105
+
106
+ const OrderSchema = Object({
107
+ id: String(),
108
+ customerId: String(),
109
+ status: String(),
110
+ total: Number(),
111
+ tags: Array(String()), // becomes a sub-table with _value column ~
112
+ lineItems: Array(LineItemSchema), // becomes a sub-table ~
113
+ });
114
+ ```
115
+
116
+ ### tables
117
+
118
+ the `table()` helper turns a schema into a table descriptor. you pick the primary key, add indexes, and configure table options.
119
+
120
+ ```typescript
121
+ table(OrderSchema, (s) => ({
122
+ primaryKey: s.id,
123
+ indexes: [
124
+ { columns: [s.customerId] },
125
+ { columns: [s.status] },
126
+ ],
127
+ timestamps: true, // adds createdAt / updatedAt
128
+ softDelete: { column: "deletedAt" }, // soft delete support
129
+ compression: { // gzip specific columns
130
+ algorithm: "gzip",
131
+ columns: [s.notes],
132
+ },
133
+ eviction: { // auto-cleanup old rows
134
+ ttlColumn: "createdAt",
135
+ ttlMs: 24 * 60 * 60 * 1000, // 24 hours
136
+ maxRows: 100000,
137
+ },
138
+ }))
139
+ ```
140
+
141
+ ### repositories
142
+
143
+ every table becomes a repository on the orm object. all crud methods are fully typed.
144
+
145
+ **Writing:**
146
+ - `insert(data)` - insert a record
147
+ - `insertMany(records)` - batch insert in a transaction
148
+ - `update(data)` - merge partial data (must include pk)
149
+ - `updateWhere({ where, data })` - bulk update matching records
150
+ - `upsert({ data, conflictTarget })` - insert or update on conflict
151
+ - `upsertMany({ data, conflictTarget })` - bulk upsert
152
+ - `deleteById(id)` - delete by pk (fk cascade on sub-tables)
153
+ - `deleteWhere(where)` - delete matching records
154
+ - `flush()` - truncate table and sub-tables
155
+ - `drop()` - drop table entirely
156
+
157
+ **Reading (chain api):**
158
+ - `findMany().where({...}).orderBy(...).limit(10).exec()` - fluent query builder
159
+ - `findOne().equals("id", "x").exec()` - single record query
160
+ - `findPage().limit(10).page(0).exec()` - paginated query with total count
161
+ - `findCursorPage().orderBy(...).limit(25).exec()` - cursor-based pagination
162
+ - `count().greaterThan("age", 18).exec()` - count with optional filter
163
+ - `aggregate().avg("price").groupBy("category").exec()` - aggregate queries
164
+ - `O_iterate({ where, limit })` - lazy generator iteration
165
+
166
+ **Validation:**
167
+ - `parse(data)` - validate and return typed data without inserting
168
+ - `check(data)` - type guard, returns true if data matches schema
169
+
170
+ ### chain query api
171
+
172
+ the chain api gives you fluent, type-safe query building:
173
+
174
+ ```typescript
175
+ const results = orm.users
176
+ .findMany()
177
+ .equals("status", "active")
178
+ .greaterThanOrEqual("age", 18)
179
+ .orderBy("createdAt", "DESC")
180
+ .limit(10)
181
+ .select(["id", "name", "email"])
182
+ .exec();
183
+
184
+ // array filters on scalar sub-tables
185
+ const tagged = orm.users
186
+ .findMany()
187
+ .arraySome("tags", "admin")
188
+ .exec();
189
+
190
+ // nested sub-table filters
191
+ const withItem = orm.orders
192
+ .findMany()
193
+ .nested("lineItems", (li) => li.equals("sku", "WIDGET"))
194
+ .exec();
195
+ ```
196
+
197
+ ### sub-table filtering
198
+
199
+ filter parent rows by sub-table content using dotted paths:
200
+
201
+ ```typescript
202
+ // find orders containing a specific line item
203
+ orm.orders.O_findMany({
204
+ where: { "lineItems.sku": { eq: "WIDGET" } }
205
+ })
206
+
207
+ // internally generates: EXISTS (SELECT 1 FROM orders__lineItems WHERE _owner_id = orders.id AND "sku" = ?)
208
+ ```
209
+
210
+ sub-tables use `ON DELETE CASCADE` foreign keys - deleting a parent row auto-deletes all sub-rows.
211
+
212
+ ### relations
213
+
214
+ define cross-table relations with a fluent builder:
215
+
216
+ ```typescript
217
+ const orm = createORM({
218
+ tables: {
219
+ orders: table(OrderSchema, (s) => ({ primaryKey: s.id })),
220
+ products: table(ProductSchema, (s) => ({ primaryKey: s.sku })),
221
+ },
222
+ relations: (r) => [
223
+ r.from("orders")
224
+ .subTable("lineItems", "sku")
225
+ .to("products", "sku", { as: "product" }),
226
+ ],
227
+ });
228
+
229
+ // sub-table items now have a .product property
230
+ const order = orm.orders.findById("ord-1");
231
+ for (const item of order.lineItems) {
232
+ console.log(item.product.name); // lazy or batch resolved :3
233
+ }
234
+
235
+ // batch resolve all relations eagerly
236
+ const orders = orm.orders.findManyMaterialized({ limit: 50 });
237
+ ```
238
+
239
+ ### events
240
+
241
+ listen to table or lifecycle events with full type safety:
242
+
243
+ ```typescript
244
+ // table-specific fine-grained event
245
+ const off = orm._events.on("users", "insert", (e) => {
246
+ console.log(`user ${e.data.id} inserted at ${e.timestamp}`);
247
+ });
248
+
249
+ // broad category - catches all writes (insert, update, upsert)
250
+ orm._events.on("users", "write", (e) => {
251
+ console.log(`write op: ${e.operation}`);
252
+ });
253
+
254
+ // lifecycle events
255
+ orm._events.on("ready", (e) => {
256
+ console.log("orm is ready ~");
257
+ });
258
+
259
+ // cleanup
260
+ off();
261
+ ```
262
+
263
+ events have **zero overhead** unless you subscribe. the event bus only builds payloads when a listener exists.
264
+
265
+ ### lifecycle hooks
266
+
267
+ hook into startup and shutdown to seed, migrate, or clean up:
268
+
269
+ ```typescript
270
+ const orm = createORM({
271
+ tables: { /* ... */ },
272
+
273
+ seed: (o) => {
274
+ o.products.insert({ sku: "WIDGET", name: "widget", price: 9.99 });
275
+ },
276
+
277
+ onReady: (ctx) => {
278
+ console.log("tables:", ctx.tables.join(", "));
279
+ },
280
+
281
+ onShutdown: (ctx) => {
282
+ ctx.orm.activity.insert({
283
+ id: "shutdown",
284
+ message: "shutting down",
285
+ level: "info",
286
+ });
287
+ },
288
+
289
+ rebuildOnLaunch: true, // wipe db on every start
290
+ unlinkDbFilesOnExit: true, // delete .db files on close
291
+ });
292
+ ```
293
+
294
+ ### batch writing
295
+
296
+ for high-throughput insert streaming, use `createBatchWriter`:
297
+
298
+ ```typescript
299
+ const writer = orm.events.createBatchWriter({
300
+ maxBuffer: 1000, // flush when 1000 rows buffered
301
+ flushIntervalMs: 5000, // or every 5 seconds
302
+ });
303
+
304
+ for (const event of eventStream) {
305
+ writer.insert({ id: event.id, type: event.type, data: event.data });
306
+ }
307
+ writer.close(); // final flush
308
+ ```
309
+
310
+ ### configuration
311
+
312
+ ```typescript
313
+ createORM({
314
+ path: "myapp.db", // sqlite file path (default: ":memory:")
315
+ cacheSize: -64000, // sqlite cache size in pages
316
+ busyTimeout: 5000, // ms to wait for write locks
317
+ synchronous: "NORMAL", // pragma synchronous level
318
+ mmapSize: 268435456, // 256 MB memory-mapped i/o
319
+ autoVacuum: "incremental", // reclaim free pages
320
+ rebuildOnLaunch: false, // wipe and rebuild on start
321
+ flushOnStart: ["logs"], // truncate tables before seeding
322
+ dropOnExit: ["temp"], // drop tables before close
323
+ autoMigrate: true, // run migrations on startup
324
+ migrations: { dir: "./migrations" },
325
+ errorPolicy: "throw", // "throw" | "emit" | "emit-swallow" | "crash"
326
+ unlinkDbFilesOnExit: false, // true | "onlyGraceful" | "any"
327
+ sync: "auto", // schema drift policy: "ignore" | "warn" | "error" | "auto"
328
+ hooks: {
329
+ onQuery: (meta) => { // query metrics hook
330
+ if (meta.durationMs > 100) console.warn("slow query", meta);
331
+ },
332
+ },
333
+ })
334
+ ```
335
+
336
+ ### sub-tables
337
+
338
+ arrays of objects are automatically split into child tables with:
339
+ - `_id` autoincrement primary key
340
+ - `_owner_id` foreign key with `ON DELETE CASCADE` back to parent
341
+ - `_index` ordering column
342
+ - Automatic indexes on `_owner_id` and TEXT columns
343
+ - Batch-hydrated on `include` for N+1 safety
344
+
345
+ arrays of scalars get a `_value` column with indexes for fast `arraySome`/`arrayNot` filtering.
346
+
347
+ ### soft deletes
348
+
349
+ configure a soft delete column to keep deleted rows recoverable:
350
+
351
+ ```typescript
352
+ table(Schema, (s) => ({
353
+ primaryKey: s.id,
354
+ softDelete: { column: "deletedAt" },
355
+ }))
356
+ ```
357
+
358
+ all queries automatically exclude soft-deleted rows. pass `includeDeleted: true` to see them:
359
+
360
+ ```typescript
361
+ orm.users.O_findMany({ where: { status: { eq: "active" } }, includeDeleted: true })
362
+ ```
363
+
364
+ ### full-text search
365
+
366
+ enable FTS5 on any table by adding `fts: true` (indexes all TEXT columns) or `fts: { columns: [...] }` (explicit column list). Cues `.search()` appears **only** on tables with FTS enabled — zero overhead on other tables:
367
+
368
+ ```typescript
369
+ table(PostSchema, (s) => ({
370
+ primaryKey: s.id,
371
+ fts: { columns: [s.title, s.body] }, // or fts: true for all TEXT columns
372
+ }))
373
+ ```
374
+
375
+ the `.search()` chain API returns full entities augmented with `_score` (bm25 rank), plus optional `_snippet` and `_highlight`:
376
+
377
+ ```typescript
378
+ // basic search — returns [{ ...entity, _score, _snippet, _highlight }]
379
+ const matches = orm.posts.search("quick fox").limit(10).exec();
380
+
381
+ // with column weights (bm25), snippets, highlights, and extra filters
382
+ const rich = orm.posts
383
+ .search("database")
384
+ .weights({ title: 2, body: 0.5 })
385
+ .snippet("body", { tokens: 20 })
386
+ .highlight("title")
387
+ .where((q) => q.equals("published", true))
388
+ .limit(10)
389
+ .exec();
390
+
391
+ for (const m of rich) {
392
+ console.log(`#${m.id} ${m._highlight.title} — ${m._snippet.body} (score: ${m._score})`);
393
+ }
394
+ ```
395
+
396
+ the FTS5 index is an **external-content** table (no data duplication). foxdb keeps it in sync across `insert`, `insertMany`, `update`, `updateWhere`, `upsert`, `upsertMany`, `deleteById`, `deleteWhere`, `flush`, and `drop` — all within the same transaction. soft-deleted rows are excluded from search unless `.includeDeleted()` is called. the virtual table prefix (`_foxdb_fts_`) ensures it never triggers schema drift on reopen.
397
+
398
+ ## error handling
399
+
400
+ foxdb uses `ORMError` with trace context. every throw includes the operation name, table, sql, and parameters that led to the error:
401
+
402
+ ```typescript
403
+ try {
404
+ orm.users.insert({ id: null, name: "oops" });
405
+ } catch (e) {
406
+ if (e instanceof ORMError) {
407
+ console.log(e.code); // "VALIDATION_FAILED"
408
+ console.log(e.trace); // [{ label: "repository.insert", time: ... }]
409
+ console.log(e.context); // { table: "users" }
410
+ }
411
+ }
412
+ ```
413
+
414
+ configure the error policy to crash, emit, swallow, or just throw.
415
+
416
+ ## migrations
417
+
418
+ auto-run timestamped migration files on startup. each migration runs in a transaction:
419
+
420
+ ```typescript
421
+ // ./migrations/001_add_notes.ts
422
+ import type { Migration } from "@xwxfox/foxdb";
423
+
424
+ export default {
425
+ name: "add_notes",
426
+ up(db) {
427
+ db.exec(`ALTER TABLE users ADD COLUMN notes TEXT`);
428
+ },
429
+ } satisfies Migration;
430
+ ```
431
+
432
+ ```typescript
433
+ createORM({
434
+ tables: { /* ... */ },
435
+ migrations: { dir: "./migrations" },
436
+ autoMigrate: true,
437
+ });
438
+ ```
439
+
440
+ tracked in `_foxdb_migrations` so each migration runs exactly once.
441
+
442
+ ## generated columns
443
+
444
+ sqlite computed columns defined in your schema:
445
+
446
+ ```typescript
447
+ import { Generated } from "@xwxfox/foxdb";
448
+
449
+ table(OrderSchema, (s) => ({
450
+ primaryKey: s.id,
451
+ generated: {
452
+ totalWithTax: { expr: `"total" * 1.25`, type: Number() },
453
+ },
454
+ }))
455
+ ```
456
+
457
+ generated columns are read-only - they're computed automatically by sqlite and excluded from INSERT/UPDATE.
458
+
459
+ ## debugging
460
+
461
+ foxdb has three feature flags for debugging:
462
+
463
+ ```bash
464
+ # trace every operation with timing breakdowns
465
+ bun --feature DEBUG_TRACING run ./app.ts
466
+
467
+ # log how sql is built (query plans, where clause resolution, ddl decisions)
468
+ bun --feature DEBUG_SQL_BUILDING run ./app.ts
469
+
470
+ # emit all executed sql to ./foxdb-queries.sql with sections and params
471
+ bun --feature DEBUG_SQL_FILE run ./app.ts
472
+ ```
473
+
474
+ ## license
475
+
476
+ mit uwu