@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/LICENSE.md +20 -20
- package/README.md +476 -275
- package/dist/index.d.mts +802 -126
- package/dist/index.mjs +16 -16
- package/package.json +3 -2
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
})
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
###
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
###
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
})
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|