@xwxfox/foxdb 0.1.2 → 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
@@ -47,18 +47,30 @@ const found = orm.users.findById("usr-1");
47
47
  // update
48
48
  orm.users.update({ id: "usr-1", name: "alice smith" });
49
49
 
50
- // query
51
- const adults = orm.users.findMany({
52
- where: { age: { gte: 18 } },
53
- orderBy: { column: "name", direction: "ASC" },
54
- });
50
+ // query (chain api)
51
+ const adults = orm.users
52
+ .findMany()
53
+ .greaterThanOrEqual("age", 18)
54
+ .orderBy("name", "ASC")
55
+ .exec();
55
56
 
56
57
  // paginate
57
- const page = orm.users.findPage({
58
- where: { age: { gte: 18 } },
59
- limit: 10,
60
- offset: 0,
61
- });
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();
62
74
 
63
75
  orm._close();
64
76
  ```
@@ -67,11 +79,14 @@ orm._close();
67
79
 
68
80
  - **zero codegen** - your typebox schema *is* the source of truth. no `prisma generate`, no migration files to keep in sync :3
69
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
70
- - **tiny** - ~2kb overhead on top of `bun:sqlite`. no external query builder, no connection pool, no bloat
82
+ - **chain query api** - `findMany().where({...}).orderBy(...).limit(10).exec()` with full type autocomplete
71
83
  - **relations** - scalar relations (lazy) and sub-table relations (batch resolved) with a fluent builder
72
84
  - **events** - listen to table events (`insert`, `update`, `read`, `write`, etc.) typed to your schema. zero overhead unless you subscribe ~
73
85
  - **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
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
75
90
 
76
91
  ## core concepts
77
92
 
@@ -93,14 +108,14 @@ const OrderSchema = Object({
93
108
  customerId: String(),
94
109
  status: String(),
95
110
  total: Number(),
96
- tags: Array(String()), // becomes a JSON string column ~
111
+ tags: Array(String()), // becomes a sub-table with _value column ~
97
112
  lineItems: Array(LineItemSchema), // becomes a sub-table ~
98
113
  });
99
114
  ```
100
115
 
101
116
  ### tables
102
117
 
103
- the `table()` helper turns a schema into a table descriptor. you pick the primary key, add indexes, and optionally enable timestamps.
118
+ the `table()` helper turns a schema into a table descriptor. you pick the primary key, add indexes, and configure table options.
104
119
 
105
120
  ```typescript
106
121
  table(OrderSchema, (s) => ({
@@ -109,26 +124,90 @@ table(OrderSchema, (s) => ({
109
124
  { columns: [s.customerId] },
110
125
  { columns: [s.status] },
111
126
  ],
112
- timestamps: true, // adds createdAt / updatedAt
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
+ },
113
138
  }))
114
139
  ```
115
140
 
116
141
  ### repositories
117
142
 
118
- every table becomes a repository on the orm object. all crud methods are fully typed:
143
+ every table becomes a repository on the orm object. all crud methods are fully typed.
119
144
 
145
+ **Writing:**
120
146
  - `insert(data)` - insert a record
121
147
  - `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
148
  - `update(data)` - merge partial data (must include pk)
127
- - `upsert(opts)` - insert or update on conflict
128
- - `deleteById(id)` - delete by 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)
129
153
  - `deleteWhere(where)` - delete matching records
130
- - `count(where?)` - count matching records
131
- - `flush()` - truncate the table and sub-tables
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.
132
211
 
133
212
  ### relations
134
213
 
@@ -152,6 +231,9 @@ const order = orm.orders.findById("ord-1");
152
231
  for (const item of order.lineItems) {
153
232
  console.log(item.product.name); // lazy or batch resolved :3
154
233
  }
234
+
235
+ // batch resolve all relations eagerly
236
+ const orders = orm.orders.findManyMaterialized({ limit: 50 });
155
237
  ```
156
238
 
157
239
  ### events
@@ -209,6 +291,22 @@ const orm = createORM({
209
291
  });
210
292
  ```
211
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
+
212
310
  ### configuration
213
311
 
214
312
  ```typescript
@@ -217,6 +315,8 @@ createORM({
217
315
  cacheSize: -64000, // sqlite cache size in pages
218
316
  busyTimeout: 5000, // ms to wait for write locks
219
317
  synchronous: "NORMAL", // pragma synchronous level
318
+ mmapSize: 268435456, // 256 MB memory-mapped i/o
319
+ autoVacuum: "incremental", // reclaim free pages
220
320
  rebuildOnLaunch: false, // wipe and rebuild on start
221
321
  flushOnStart: ["logs"], // truncate tables before seeding
222
322
  dropOnExit: ["temp"], // drop tables before close
@@ -224,12 +324,80 @@ createORM({
224
324
  migrations: { dir: "./migrations" },
225
325
  errorPolicy: "throw", // "throw" | "emit" | "emit-swallow" | "crash"
226
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
+ },
227
333
  })
228
334
  ```
229
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
+
230
398
  ## error handling
231
399
 
232
- foxdb uses `ORMError` with invisible trace context. every throw includes the operation name, table, sql, and parameters that led to the error:
400
+ foxdb uses `ORMError` with trace context. every throw includes the operation name, table, sql, and parameters that led to the error:
233
401
 
234
402
  ```typescript
235
403
  try {
@@ -247,17 +415,16 @@ configure the error policy to crash, emit, swallow, or just throw.
247
415
 
248
416
  ## migrations
249
417
 
250
- foxdb can auto-run migrations on startup. create migration files and point `migrations.dir` at them:
418
+ auto-run timestamped migration files on startup. each migration runs in a transaction:
251
419
 
252
420
  ```typescript
253
- // migrations/001-init.ts
421
+ // ./migrations/001_add_notes.ts
254
422
  import type { Migration } from "@xwxfox/foxdb";
255
423
 
256
424
  export default {
257
- name: "init",
258
- date: "2024-01-15",
425
+ name: "add_notes",
259
426
  up(db) {
260
- // raw sql via db.exec() if needed
427
+ db.exec(`ALTER TABLE users ADD COLUMN notes TEXT`);
261
428
  },
262
429
  } satisfies Migration;
263
430
  ```
@@ -270,6 +437,40 @@ createORM({
270
437
  });
271
438
  ```
272
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
+
273
474
  ## license
274
475
 
275
476
  mit uwu