@cfast/db 0.4.1 → 0.6.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.
- package/dist/index.d.ts +47 -794
- package/dist/index.js +147 -65
- package/dist/seed.d.ts +258 -0
- package/dist/seed.js +378 -0
- package/dist/types-FUFR36h1.d.ts +221 -0
- package/llms.txt +212 -44
- package/package.json +11 -4
package/llms.txt
CHANGED
|
@@ -47,8 +47,8 @@ type Operation<TResult> = {
|
|
|
47
47
|
### Reads
|
|
48
48
|
|
|
49
49
|
```typescript
|
|
50
|
-
db.query(table).findMany(options?): Operation<Row[]>
|
|
51
|
-
db.query(table).findFirst(options?): Operation<Row | undefined>
|
|
50
|
+
db.query(table).findMany(options?): Operation<WithCan<Row>[]>
|
|
51
|
+
db.query(table).findFirst(options?): Operation<WithCan<Row> | undefined>
|
|
52
52
|
db.query(table).paginate(params, options?): Operation<CursorPage<Row> | OffsetPage<Row>>
|
|
53
53
|
```
|
|
54
54
|
|
|
@@ -58,34 +58,78 @@ IntelliSense on `(row) => row.title` without any cast.
|
|
|
58
58
|
FindManyOptions: `{ columns?, where?, orderBy?, limit?, offset?, with?, cache? }`
|
|
59
59
|
FindFirstOptions: same without `limit`/`offset`.
|
|
60
60
|
|
|
61
|
-
####
|
|
61
|
+
#### Auto-inferred `.with()` relations (#240)
|
|
62
62
|
|
|
63
63
|
When you pass `with: { relation: true }`, Drizzle's relational query builder
|
|
64
|
-
embeds the joined rows into the result. `@cfast/db
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
embeds the joined rows into the result. As of `@cfast/db@0.5`, the result
|
|
65
|
+
type is **automatically inferred** from the schema -- no type cast needed.
|
|
66
|
+
`createDb` captures the full schema type via a generic, and `findMany`/
|
|
67
|
+
`findFirst` thread the `with` config through Drizzle's `BuildQueryResult`
|
|
68
|
+
to compute the exact result shape including nested relations.
|
|
69
69
|
|
|
70
70
|
```typescript
|
|
71
|
-
|
|
71
|
+
import { createDb } from "@cfast/db";
|
|
72
|
+
import * as schema from "./schema"; // includes relations()
|
|
73
|
+
|
|
74
|
+
const db = createDb({ d1, schema, grants, user });
|
|
72
75
|
|
|
76
|
+
// Auto-inferred -- no cast, no manual generic
|
|
73
77
|
const recipes = await db
|
|
74
|
-
.query(recipesTable)
|
|
75
|
-
.findMany
|
|
78
|
+
.query(schema.recipesTable)
|
|
79
|
+
.findMany({ with: { ingredients: true } })
|
|
80
|
+
.run();
|
|
81
|
+
// recipes is { id: string; title: string; ingredients: Ingredient[] }[]
|
|
82
|
+
|
|
83
|
+
// Nested relations work too
|
|
84
|
+
const plan = await db
|
|
85
|
+
.query(schema.mealPlans)
|
|
86
|
+
.findFirst({
|
|
87
|
+
with: { entries: { with: { recipe: { with: { ingredients: true } } } } },
|
|
88
|
+
})
|
|
76
89
|
.run();
|
|
77
|
-
//
|
|
90
|
+
// plan.entries[0].recipe.ingredients is fully typed
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Manual override:** pass `<TConfig, TRow>` to override when needed:
|
|
78
94
|
|
|
79
|
-
|
|
95
|
+
```typescript
|
|
96
|
+
type Custom = Recipe & { ingredients: Ingredient[] };
|
|
97
|
+
const recipes = await db
|
|
80
98
|
.query(recipesTable)
|
|
81
|
-
.
|
|
82
|
-
where: eq(recipesTable.id, id),
|
|
99
|
+
.findMany<{ with: { ingredients: true } }, Custom>({
|
|
83
100
|
with: { ingredients: true },
|
|
84
101
|
})
|
|
85
102
|
.run();
|
|
86
|
-
// recipe is RecipeWithIngredients | undefined.
|
|
87
103
|
```
|
|
88
104
|
|
|
105
|
+
#### Row-level `_can` annotations (#270)
|
|
106
|
+
|
|
107
|
+
Every `findMany` and `findFirst` result includes a `_can` object with per-action
|
|
108
|
+
booleans, evaluated from the user's grants at query time. No opt-in required --
|
|
109
|
+
permissions are first-class on every row.
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
const comments = await db.query(commentsTable).findMany().run();
|
|
113
|
+
// comments[0]._can -> { read: true, create: true, update: true, delete: false }
|
|
114
|
+
|
|
115
|
+
// Type: WithCan<Comment>[] -- each row has _can: Record<string, boolean>
|
|
116
|
+
type WithCan<T> = T & { _can: Record<string, boolean> };
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
How `_can` is computed for each CRUD action:
|
|
120
|
+
- **Unrestricted grant** (no `where` clause): `_can.action = true` for every row (literal `1` in SQL, no CASE needed).
|
|
121
|
+
- **Restricted grant** (has `where` clause): `_can.action` varies per row via a SQL `CASE WHEN <condition> THEN 1 ELSE 0 END` computed column.
|
|
122
|
+
- **No grant**: `_can.action = false` for every row.
|
|
123
|
+
- **`manage` grant**: expands to all four CRUD actions (`read`, `create`, `update`, `delete`).
|
|
124
|
+
|
|
125
|
+
The `read` action is always `true` on returned rows because the permission WHERE
|
|
126
|
+
clause already filters out rows the user cannot read.
|
|
127
|
+
|
|
128
|
+
`_can` is **not** added in `unsafe()` mode or when `user` is `null`.
|
|
129
|
+
|
|
130
|
+
Performance: adds N computed columns per query (where N = distinct granted CRUD
|
|
131
|
+
actions, typically 3-5). Unrestricted grants are free (literal `1`).
|
|
132
|
+
|
|
89
133
|
### Writes
|
|
90
134
|
|
|
91
135
|
```typescript
|
|
@@ -223,7 +267,7 @@ sequential execution (and loses the atomicity guarantee). For pure compose
|
|
|
223
267
|
workflows that need atomicity, build the underlying ops with `db.insert/update/
|
|
224
268
|
delete` directly and pass them straight to `db.batch([...])`.
|
|
225
269
|
|
|
226
|
-
### db.transaction(async tx => ...): Promise<T
|
|
270
|
+
### db.transaction(async tx => ...): Promise<TransactionResult<T>>
|
|
227
271
|
|
|
228
272
|
Runs a callback inside a transaction. Writes (`tx.insert`, `tx.update`,
|
|
229
273
|
`tx.delete`) are **recorded** as the callback runs and flushed together as a
|
|
@@ -231,6 +275,14 @@ single atomic `db.batch([...])` when the callback returns successfully. If the
|
|
|
231
275
|
callback throws, the pending writes are discarded and the error is re-thrown —
|
|
232
276
|
nothing reaches D1.
|
|
233
277
|
|
|
278
|
+
Returns a `TransactionResult<T>` with:
|
|
279
|
+
- `result: T` — the callback's return value
|
|
280
|
+
- `meta.changes: number` — total rows affected across all writes
|
|
281
|
+
- `meta.writeResults: D1Result[]` — per-statement D1 results
|
|
282
|
+
|
|
283
|
+
Use `meta.changes` to detect whether a WHERE-guarded UPDATE actually matched
|
|
284
|
+
any rows (the "out of stock" signal) without falling back to raw SQL.
|
|
285
|
+
|
|
234
286
|
Use `db.transaction` whenever the set of writes depends on logic inside the
|
|
235
287
|
callback (read-modify-write, conditional inserts, state machines):
|
|
236
288
|
|
|
@@ -238,11 +290,7 @@ callback (read-modify-write, conditional inserts, state machines):
|
|
|
238
290
|
import { and, eq, gte, sql } from "drizzle-orm";
|
|
239
291
|
|
|
240
292
|
// Oversell-safe checkout: atomic + guarded against concurrent decrements.
|
|
241
|
-
const order = await db.transaction(async (tx) => {
|
|
242
|
-
// Reads execute eagerly against the underlying db. They see whatever is
|
|
243
|
-
// committed right now — D1 does NOT provide snapshot isolation across
|
|
244
|
-
// async code, so another request can modify the row between read and
|
|
245
|
-
// write. The WHERE guard on the update is what keeps us concurrency-safe.
|
|
293
|
+
const { result: order, meta } = await db.transaction(async (tx) => {
|
|
246
294
|
const product = await tx.query(products).findFirst({
|
|
247
295
|
where: eq(products.id, pid),
|
|
248
296
|
}).run();
|
|
@@ -250,24 +298,22 @@ const order = await db.transaction(async (tx) => {
|
|
|
250
298
|
throw new Error("out of stock"); // rolls back, nothing is written
|
|
251
299
|
}
|
|
252
300
|
|
|
253
|
-
// Guarded decrement: relative SQL + WHERE stock >= qty.
|
|
254
|
-
// re-evaluated by D1 at commit time, so two concurrent transactions
|
|
255
|
-
// cannot BOTH decrement past zero. Either one succeeds and the other
|
|
256
|
-
// is a no-op (0 rows matched), or the application-level check above
|
|
257
|
-
// rejects the second one first.
|
|
301
|
+
// Guarded decrement: relative SQL + WHERE stock >= qty.
|
|
258
302
|
await tx.update(products)
|
|
259
303
|
.set({ stock: sql`stock - ${qty}` })
|
|
260
304
|
.where(and(eq(products.id, pid), gte(products.stock, qty)))
|
|
261
305
|
.run();
|
|
262
306
|
|
|
263
|
-
// Generate the order id client-side so we don't need `.returning()`
|
|
264
|
-
// inside the transaction (see "Returning inside a transaction" below).
|
|
265
307
|
const orderId = crypto.randomUUID();
|
|
266
308
|
await tx.insert(orders).values({ id: orderId, productId: pid, qty }).run();
|
|
267
309
|
|
|
268
|
-
// Whatever the callback returns becomes the transaction's return value.
|
|
269
310
|
return { orderId, productId: pid, qty };
|
|
270
311
|
});
|
|
312
|
+
|
|
313
|
+
// Check if the guarded decrement actually matched any rows.
|
|
314
|
+
if (meta.changes === 0) {
|
|
315
|
+
throw new Error("out of stock — concurrent decrement won");
|
|
316
|
+
}
|
|
271
317
|
```
|
|
272
318
|
|
|
273
319
|
**`tx` is a `Pick<Db, "query" | "insert" | "update" | "delete">`** plus a
|
|
@@ -385,19 +431,36 @@ cache: {
|
|
|
385
431
|
Per-query: `db.query(t).findMany({ cache: false })` or `{ cache: { ttl: "5m", tags: ["posts"] } }`.
|
|
386
432
|
Manual invalidation: `await db.cache.invalidate({ tags: ["posts"], tables: ["posts"] })`.
|
|
387
433
|
|
|
388
|
-
###
|
|
434
|
+
### One-liner seed
|
|
389
435
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
`db.unsafe()` internally so seeds never need their own grants. Empty `rows`
|
|
394
|
-
arrays are skipped, so placeholder entries are safe.
|
|
436
|
+
All seed functionality lives under the `@cfast/db/seed` entrypoint, which
|
|
437
|
+
bundles `@faker-js/faker` so you never install or import it yourself. The
|
|
438
|
+
simplest API is a one-liner:
|
|
395
439
|
|
|
396
440
|
```typescript
|
|
397
|
-
import {
|
|
441
|
+
import { seed } from "@cfast/db/seed";
|
|
442
|
+
import { createDb } from "@cfast/db";
|
|
398
443
|
import * as schema from "~/db/schema";
|
|
399
444
|
|
|
400
|
-
const
|
|
445
|
+
const db = createDb({ d1, schema, grants: [], user: null });
|
|
446
|
+
await seed(db);
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
`seed(db)` introspects the schema from the Db instance, topologically sorts
|
|
450
|
+
tables, generates realistic data via faker, and inserts it through
|
|
451
|
+
`db.unsafe()`. Pass `{ transcript: "./seed.sql" }` as the second argument
|
|
452
|
+
to also write the INSERT statements to a file.
|
|
453
|
+
|
|
454
|
+
### defineSeed (static fixtures)
|
|
455
|
+
|
|
456
|
+
For hand-authored fixture data (not generated), use `defineSeed`:
|
|
457
|
+
|
|
458
|
+
```typescript
|
|
459
|
+
import { defineSeed } from "@cfast/db/seed";
|
|
460
|
+
import { createDb } from "@cfast/db";
|
|
461
|
+
import * as schema from "~/db/schema";
|
|
462
|
+
|
|
463
|
+
const mySeed = defineSeed({
|
|
401
464
|
entries: [
|
|
402
465
|
{ table: schema.users, rows: [{ id: "u-1", email: "ada@example.com", name: "Ada" }] },
|
|
403
466
|
{ table: schema.posts, rows: [{ id: "p-1", title: "Hello", authorId: "u-1" }] },
|
|
@@ -405,14 +468,95 @@ const seed = defineSeed({
|
|
|
405
468
|
});
|
|
406
469
|
|
|
407
470
|
const db = createDb({ d1, schema, grants: [], user: null });
|
|
408
|
-
await
|
|
471
|
+
await mySeed.run(db);
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
### Schema-driven seed generation (createSeedEngine)
|
|
475
|
+
|
|
476
|
+
`createSeedEngine(schema)` introspects Drizzle schema metadata to
|
|
477
|
+
auto-generate realistic test data using the bundled faker. Handles FK
|
|
478
|
+
resolution, topological ordering, `per` relational generation,
|
|
479
|
+
many-to-many deduplication, and auth table detection.
|
|
480
|
+
|
|
481
|
+
#### Column-level seed overrides
|
|
482
|
+
|
|
483
|
+
`seedConfig(column, fn)` attaches a custom generator to a column:
|
|
484
|
+
|
|
485
|
+
```typescript
|
|
486
|
+
import { seedConfig, tableSeed } from "@cfast/db/seed";
|
|
487
|
+
|
|
488
|
+
const posts = sqliteTable("posts", {
|
|
489
|
+
id: text("id").primaryKey(),
|
|
490
|
+
title: seedConfig(text("title").notNull(), f => f.lorem.sentence()),
|
|
491
|
+
content: seedConfig(text("content"), f => f.lorem.paragraphs(3)),
|
|
492
|
+
authorId: text("author_id").references(() => users.id),
|
|
493
|
+
});
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
Fields without `seedConfig()` are auto-inferred from column type:
|
|
497
|
+
- `text` -> `faker.lorem.words()`
|
|
498
|
+
- `integer`/`real` -> `faker.number.int()`/`faker.number.float()`
|
|
499
|
+
- `timestamp` -> `faker.date.recent()`
|
|
500
|
+
- `boolean` -> `faker.datatype.boolean()`
|
|
501
|
+
- FK (`.references()`) -> random existing row from referenced table
|
|
502
|
+
- Primary key -> auto-generated UUID
|
|
503
|
+
- Nullable -> occasionally `null` (~10%)
|
|
504
|
+
- Columns with `$defaultFn` or static defaults are skipped
|
|
505
|
+
|
|
506
|
+
#### Table-level seed config
|
|
507
|
+
|
|
508
|
+
`tableSeed(table, { count, per })` sets the row count and optional
|
|
509
|
+
per-parent relationship:
|
|
510
|
+
|
|
511
|
+
```typescript
|
|
512
|
+
const users = tableSeed(sqliteTable("users", { ... }), { count: 10 });
|
|
513
|
+
const posts = tableSeed(sqliteTable("posts", { ... }), { count: 5, per: users });
|
|
514
|
+
// -> 5 per user = 50 total. authorId auto-filled with parent user's id.
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
#### Seed context (ctx)
|
|
518
|
+
|
|
519
|
+
Column-level seed functions receive `(faker, ctx)` where `ctx` provides:
|
|
520
|
+
|
|
521
|
+
- `ctx.parent` -- the parent row (from `per`), `undefined` for root tables
|
|
522
|
+
- `ctx.ref(table)` -- pick a random existing row from any table
|
|
523
|
+
- `ctx.index` -- zero-based position within current batch
|
|
524
|
+
- `ctx.all(table)` -- all generated rows for a table
|
|
525
|
+
|
|
526
|
+
```typescript
|
|
527
|
+
authorId: seedConfig(text("author_id").references(() => users.id),
|
|
528
|
+
(faker, ctx) => ctx.parent?.id), // inherit from parent
|
|
529
|
+
role: seedConfig(text("role"),
|
|
530
|
+
(f, ctx) => ctx.index === 0 ? "admin" : f.helpers.arrayElement(["member", "viewer"])),
|
|
409
531
|
```
|
|
410
532
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
533
|
+
#### Runtime API
|
|
534
|
+
|
|
535
|
+
```typescript
|
|
536
|
+
import { createSeedEngine, createSingleTableSeed } from "@cfast/db/seed";
|
|
537
|
+
|
|
538
|
+
// Seed all tables from schema config
|
|
539
|
+
const engine = createSeedEngine(schema);
|
|
540
|
+
const generated = await engine.run(db);
|
|
541
|
+
|
|
542
|
+
// Also write SQL transcript
|
|
543
|
+
await engine.run(db, { transcript: "./seed.sql" });
|
|
544
|
+
|
|
545
|
+
// Single-table override
|
|
546
|
+
const singleTable = createSingleTableSeed(schema, posts, 5);
|
|
547
|
+
await singleTable.run(db);
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
#### Many-to-many deduplication
|
|
551
|
+
|
|
552
|
+
Tables with 2+ FK columns are auto-detected as join tables.
|
|
553
|
+
Duplicate composite key combinations are silently skipped.
|
|
554
|
+
|
|
555
|
+
#### Auth integration
|
|
556
|
+
|
|
557
|
+
Tables named "users" get special handling: the first 5 rows use
|
|
558
|
+
`admin@example.com`, `user@example.com`, etc., and names use
|
|
559
|
+
`faker.person.fullName()`.
|
|
416
560
|
|
|
417
561
|
## Usage Examples
|
|
418
562
|
|
|
@@ -631,6 +775,30 @@ export default defineConfig({
|
|
|
631
775
|
});
|
|
632
776
|
```
|
|
633
777
|
|
|
778
|
+
### toJSON(value): DateToString<T>
|
|
779
|
+
|
|
780
|
+
Recursively converts `Date` fields to ISO 8601 strings for JSON serialization.
|
|
781
|
+
React Router loaders must return JSON-serializable data; `toJSON()` makes the
|
|
782
|
+
Date-to-string conversion explicit so every loader doesn't need manual
|
|
783
|
+
`.toISOString()` calls.
|
|
784
|
+
|
|
785
|
+
```typescript
|
|
786
|
+
import { toJSON } from "@cfast/db";
|
|
787
|
+
|
|
788
|
+
export async function loader({ context }) {
|
|
789
|
+
const db = createDb({ ... });
|
|
790
|
+
const posts = await db.query(postsTable).findMany().run();
|
|
791
|
+
// Date fields (createdAt, updatedAt, etc.) become ISO strings automatically.
|
|
792
|
+
return { posts: toJSON(posts) };
|
|
793
|
+
}
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
The return type `DateToString<T>` maps every `Date` property to `string` at the
|
|
797
|
+
type level, so the client sees accurate types without manual casting.
|
|
798
|
+
|
|
799
|
+
Works on plain objects, arrays, nested structures, and single Date values.
|
|
800
|
+
Non-Date primitives pass through unchanged.
|
|
801
|
+
|
|
634
802
|
## Common Mistakes
|
|
635
803
|
|
|
636
804
|
- **Forgetting `.run()`** -- Operations are lazy. `db.query(t).findMany()` returns an Operation, not results. You must call `.run()`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cfast/db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Permission-aware Drizzle queries for Cloudflare D1",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cfast",
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
".": {
|
|
24
24
|
"import": "./dist/index.js",
|
|
25
25
|
"types": "./dist/index.d.ts"
|
|
26
|
+
},
|
|
27
|
+
"./seed": {
|
|
28
|
+
"import": "./dist/seed.js",
|
|
29
|
+
"types": "./dist/seed.d.ts"
|
|
26
30
|
}
|
|
27
31
|
},
|
|
28
32
|
"files": [
|
|
@@ -33,6 +37,9 @@
|
|
|
33
37
|
"publishConfig": {
|
|
34
38
|
"access": "public"
|
|
35
39
|
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@faker-js/faker": "^9.0.0"
|
|
42
|
+
},
|
|
36
43
|
"peerDependencies": {
|
|
37
44
|
"@cfast/permissions": ">=0.3.0 <0.6.0",
|
|
38
45
|
"drizzle-orm": ">=0.35"
|
|
@@ -51,11 +58,11 @@
|
|
|
51
58
|
"tsup": "^8",
|
|
52
59
|
"typescript": "^5.7",
|
|
53
60
|
"vitest": "^4.1.0",
|
|
54
|
-
"@cfast/permissions": "0.
|
|
61
|
+
"@cfast/permissions": "0.6.0"
|
|
55
62
|
},
|
|
56
63
|
"scripts": {
|
|
57
|
-
"build": "tsup src/index.ts --format esm --dts",
|
|
58
|
-
"dev": "tsup src/index.ts --format esm --dts --watch",
|
|
64
|
+
"build": "tsup src/index.ts src/seed.ts --format esm --dts",
|
|
65
|
+
"dev": "tsup src/index.ts src/seed.ts --format esm --dts --watch",
|
|
59
66
|
"typecheck": "tsc --noEmit",
|
|
60
67
|
"lint": "eslint src/",
|
|
61
68
|
"test": "vitest run"
|