@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 +231 -30
- package/dist/index.d.mts +768 -120
- package/dist/index.mjs +16 -16
- package/package.json +2 -1
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
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
- **
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
- `
|
|
128
|
-
- `
|
|
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
|
-
- `
|
|
131
|
-
- `
|
|
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
|
|
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
|
-
|
|
418
|
+
auto-run timestamped migration files on startup. each migration runs in a transaction:
|
|
251
419
|
|
|
252
420
|
```typescript
|
|
253
|
-
// migrations/
|
|
421
|
+
// ./migrations/001_add_notes.ts
|
|
254
422
|
import type { Migration } from "@xwxfox/foxdb";
|
|
255
423
|
|
|
256
424
|
export default {
|
|
257
|
-
name: "
|
|
258
|
-
date: "2024-01-15",
|
|
425
|
+
name: "add_notes",
|
|
259
426
|
up(db) {
|
|
260
|
-
|
|
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
|