@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/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
- #### Relations escape hatch (#158)
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` cannot statically infer
65
- that shape from the `Record<string, unknown>` schema we accept on `createDb`,
66
- so the default row type does not include the relation. Override the row type
67
- via the `findMany`/`findFirst`/`paginate` generic to claim the shape you know
68
- the query will produce, instead of `as any`-casting the result downstream:
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
- type RecipeWithIngredients = Recipe & { ingredients: Ingredient[] };
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<RecipeWithIngredients>({ with: { ingredients: true } })
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
- // recipes is RecipeWithIngredients[], no cast needed.
90
+ // plan.entries[0].recipe.ingredients is fully typed
91
+ ```
92
+
93
+ **Manual override:** pass `<TConfig, TRow>` to override when needed:
78
94
 
79
- const recipe = await db
95
+ ```typescript
96
+ type Custom = Recipe & { ingredients: Ingredient[] };
97
+ const recipes = await db
80
98
  .query(recipesTable)
81
- .findFirst<RecipeWithIngredients>({
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. The guard is
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
- ### defineSeed
434
+ ### One-liner seed
389
435
 
390
- `defineSeed({ entries })` is the canonical way to seed a local D1 database.
391
- Accepts an ordered list of `{ table, rows }` entries — put parent tables
392
- before child tables to respect FK ordering. `seed.run(db)` hops through
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 { createDb, defineSeed } from "@cfast/db";
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 seed = defineSeed({
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 seed.run(db);
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
- Multi-row entries are flushed via `db.batch([...])` (atomic per table);
412
- single-row entries skip the batch path so Drizzle's non-empty tuple
413
- invariant holds. Use this from `scripts/seed.ts` and run it via
414
- `pnpm db:seed:local` (the scaffolded `create-cfast` package ships this
415
- script and command out of the box).
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.4.1",
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.5.1"
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"