@cfast/db 0.4.0 → 0.5.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 CHANGED
@@ -1,458 +1,105 @@
1
1
  import { Grant, DrizzleTable, PermissionDescriptor } from '@cfast/permissions';
2
+ import { Table, TablesRelationalConfig, ExtractTablesWithRelations, TableRelationalConfig, BuildQueryResult } from 'drizzle-orm';
2
3
 
3
- /**
4
- * Extracts the row type from a Drizzle table reference.
5
- *
6
- * Drizzle tables expose `$inferSelect` (the row shape returned by `SELECT *`).
7
- * `InferRow<typeof posts>` resolves to `{ id: string; title: string; ... }`.
8
- *
9
- * Falls back to `Record<string, unknown>` for opaque `DrizzleTable` references
10
- * (e.g. when callers do not specify a concrete table type generic).
11
- *
12
- * @typeParam TTable - The Drizzle table type (e.g. `typeof posts`).
13
- */
14
4
  type InferRow<TTable> = TTable extends {
15
5
  $inferSelect: infer R;
16
6
  } ? R : Record<string, unknown>;
17
- /**
18
- * A lazy, permission-aware database operation.
19
- *
20
- * Every method on {@link Db} returns an `Operation` instead of a promise. The operation
21
- * exposes its permission requirements via `.permissions` for inspection and executes with
22
- * full permission checking via `.run()`. This two-phase design enables UI adaptation,
23
- * upfront composition via {@link compose}, and introspection before any SQL is executed.
24
- *
25
- * @typeParam TResult - The type of the result returned by `.run()`.
26
- *
27
- * @example
28
- * ```ts
29
- * const op = db.query(posts).findMany();
30
- *
31
- * // Inspect permissions without executing
32
- * console.log(op.permissions);
33
- * // => [{ action: "read", table: "posts" }]
34
- *
35
- * // Execute with permission checks
36
- * const rows = await op.run();
37
- * ```
38
- */
7
+ /** @internal */
8
+ type FindTableKeyByName<TSchema extends TablesRelationalConfig, TTableName extends string> = {
9
+ [K in keyof TSchema]: TSchema[K]["dbName"] extends TTableName ? K : never;
10
+ }[keyof TSchema];
11
+ /** @internal */
12
+ type LookupTableConfig<TFullSchema extends Record<string, unknown>, TTable> = TTable extends Table<infer TTableConfig> ? FindTableKeyByName<ExtractTablesWithRelations<TFullSchema>, TTableConfig["name"]> extends infer TKey extends keyof ExtractTablesWithRelations<TFullSchema> ? ExtractTablesWithRelations<TFullSchema>[TKey] : never : never;
13
+ type InferQueryResult<TFullSchema extends Record<string, unknown>, TTable, TConfig> = [TFullSchema] extends [Record<string, never>] ? InferRow<TTable> : LookupTableConfig<TFullSchema, TTable> extends infer TTableConfig extends TableRelationalConfig ? BuildQueryResult<ExtractTablesWithRelations<TFullSchema>, TTableConfig, TConfig extends Record<string, unknown> ? TConfig : true> : InferRow<TTable>;
39
14
  type Operation<TResult> = {
40
- /** Structural permission requirements. Available immediately without execution. */
41
15
  permissions: PermissionDescriptor[];
42
- /**
43
- * Checks permissions, applies permission WHERE clauses, executes the query via Drizzle,
44
- * and returns the result. Throws `ForbiddenError` if the user's role lacks a required grant.
45
- *
46
- * @param params - Optional placeholder values for `sql.placeholder()` calls.
47
- * Omit when your query does not use placeholders — the default is `{}`.
48
- */
49
16
  run: (params?: Record<string, unknown>) => Promise<TResult>;
50
17
  };
51
- /**
52
- * Supported cache backend types for {@link CacheConfig}.
53
- *
54
- * - `"cache-api"` — Edge-local Cloudflare Cache API (~0ms latency, per-edge-node).
55
- * - `"kv"` — Global Cloudflare KV (10-50ms latency, eventually consistent).
56
- */
57
18
  type CacheBackend = "cache-api" | "kv";
58
- /**
59
- * Configuration for the database cache layer.
60
- *
61
- * Controls how query results are cached and invalidated. Mutations automatically
62
- * bump table version counters, causing subsequent reads to miss the cache.
63
- *
64
- * @example
65
- * ```ts
66
- * const db = createDb({
67
- * d1: env.DB,
68
- * schema,
69
- * grants: resolvedGrants,
70
- * user: currentUser,
71
- * cache: {
72
- * backend: "cache-api",
73
- * ttl: "30s",
74
- * staleWhileRevalidate: "5m",
75
- * exclude: ["sessions"],
76
- * },
77
- * });
78
- * ```
79
- */
80
19
  type CacheConfig = {
81
- /** Which cache backend to use: edge-local Cache API or global KV. */
82
20
  backend: CacheBackend;
83
- /** KV namespace binding. Required when {@link backend} is `"kv"`. */
84
21
  kv?: KVNamespace;
85
- /** Default TTL for cached queries (e.g., `"30s"`, `"5m"`, `"1h"`). Defaults to `"60s"`. */
86
22
  ttl?: string;
87
- /** Stale-while-revalidate window (e.g., `"5m"`). Serves stale data while revalidating in the background. */
88
23
  staleWhileRevalidate?: string;
89
- /** Table names that should never be cached (e.g., `["sessions", "tokens"]`). */
90
24
  exclude?: string[];
91
- /** Observability hook called on cache hits. */
92
25
  onHit?: (key: string, table: string) => void;
93
- /** Observability hook called on cache misses. */
94
26
  onMiss?: (key: string, table: string) => void;
95
- /** Observability hook called when tables are invalidated by mutations. */
96
27
  onInvalidate?: (tables: string[]) => void;
97
28
  };
98
- /**
99
- * Per-query cache control options.
100
- *
101
- * Pass `false` to skip caching for a specific query, or an options object to
102
- * override the default {@link CacheConfig} for that query.
103
- *
104
- * @example
105
- * ```ts
106
- * // Skip cache entirely
107
- * db.query(posts).findMany({ cache: false });
108
- *
109
- * // Custom TTL and tags
110
- * db.query(posts).findMany({ cache: { ttl: "5m", tags: ["user-posts"] } });
111
- * ```
112
- */
113
29
  type QueryCacheOptions = false | {
114
- /** Override the default TTL for this query (e.g., `"5m"`, `"1h"`). */
115
30
  ttl?: string;
116
- /** Override the default stale-while-revalidate window for this query. */
117
31
  staleWhileRevalidate?: string;
118
- /** Tags for targeted manual invalidation via `db.cache.invalidate({ tags })`. */
119
32
  tags?: string[];
120
33
  };
121
- /**
122
- * Configuration for {@link createDb}.
123
- *
124
- * @example
125
- * ```ts
126
- * import { createDb } from "@cfast/db";
127
- * import * as schema from "./schema";
128
- *
129
- * const db = createDb({
130
- * d1: env.DB,
131
- * schema,
132
- * grants: resolvedGrants,
133
- * user: { id: "user-123" },
134
- * cache: { backend: "cache-api" },
135
- * });
136
- * ```
137
- */
138
- type DbConfig = {
139
- /** The Cloudflare D1 database binding from `env.DB`. */
34
+ type DbConfig<TSchema extends Record<string, unknown> = Record<string, unknown>> = {
140
35
  d1: D1Database;
141
- /**
142
- * Drizzle schema object. Must be `import * as schema` so that keys match
143
- * table variable names (required by Drizzle's relational query API).
144
- *
145
- * Typed as `Record<string, unknown>` so callers can pass `import * as schema`
146
- * directly without casting -- Drizzle schemas typically include `Relations`
147
- * exports alongside tables, and the `@cfast/db` runtime ignores any non-table
148
- * entries when looking up tables by key.
149
- */
150
- schema: Record<string, unknown>;
151
- /** Resolved permission grants for the current user's role, from `resolveGrants()`. */
36
+ schema: TSchema;
152
37
  grants: Grant[];
153
- /**
154
- * The current user, or `null` for anonymous access.
155
- * When `null`, the `"anonymous"` role is used for permission checks.
156
- */
157
38
  user: {
158
39
  id: string;
159
40
  } | null;
160
- /** Cache configuration, or `false` to disable caching entirely. Defaults to `{ backend: "cache-api" }`. */
161
41
  cache?: CacheConfig | false;
162
42
  };
163
- /**
164
- * Options for `db.query(table).findMany()`.
165
- *
166
- * The `where` condition is AND'd with any permission-based WHERE clauses
167
- * resolved from the user's grants.
168
- *
169
- * @example
170
- * ```ts
171
- * import { eq, desc } from "drizzle-orm";
172
- *
173
- * db.query(posts).findMany({
174
- * columns: { id: true, title: true },
175
- * where: eq(posts.category, "tech"),
176
- * orderBy: desc(posts.createdAt),
177
- * limit: 10,
178
- * offset: 20,
179
- * with: { comments: true },
180
- * cache: { ttl: "5m", tags: ["posts"] },
181
- * });
182
- * ```
183
- */
184
43
  type FindManyOptions = {
185
- /** Column selection (e.g., `{ id: true, title: true }`). Omit to select all columns. */
186
44
  columns?: Record<string, boolean>;
187
- /** User-supplied filter condition (AND'd with permission filters at `.run()` time). */
188
45
  where?: unknown;
189
- /** Ordering expression (e.g., `desc(posts.createdAt)`). */
190
46
  orderBy?: unknown;
191
- /** Maximum number of rows to return. */
192
47
  limit?: number;
193
- /** Number of rows to skip (for offset-based pagination). */
194
48
  offset?: number;
195
- /**
196
- * Drizzle relational query includes (e.g., `{ comments: true }`).
197
- *
198
- * Note: Permission filters are only applied to the root table, not to joined relations.
199
- */
200
49
  with?: Record<string, unknown>;
201
- /** Per-query cache control. Pass `false` to skip caching, or an object to customize. */
202
50
  cache?: QueryCacheOptions;
203
51
  };
204
- /**
205
- * Options for `db.query(table).findFirst()`.
206
- *
207
- * Same as {@link FindManyOptions} without `limit`/`offset` (returns the first match or `undefined`).
208
- *
209
- * @example
210
- * ```ts
211
- * db.query(posts).findFirst({
212
- * where: eq(posts.id, "abc-123"),
213
- * });
214
- * ```
215
- */
216
52
  type FindFirstOptions = Omit<FindManyOptions, "limit" | "offset">;
217
- /**
218
- * Parsed cursor-based pagination parameters from a request URL.
219
- *
220
- * Produced by {@link parseCursorParams}. Pass to `db.query(table).paginate()` for
221
- * keyset pagination that avoids the offset performance cliff on large datasets.
222
- *
223
- * @example
224
- * ```ts
225
- * const params = parseCursorParams(request, { defaultLimit: 20 });
226
- * const page = await db.query(posts).paginate(params).run();
227
- * ```
228
- */
229
53
  type CursorParams = {
230
- /** Discriminant for cursor-based pagination. Always `"cursor"`. */
231
54
  type: "cursor";
232
- /** The opaque cursor string from the previous page, or `null` for the first page. */
233
55
  cursor: string | null;
234
- /** Maximum items per page (clamped between 1 and `maxLimit`). */
235
56
  limit: number;
236
57
  };
237
- /**
238
- * Parsed offset-based pagination parameters from a request URL.
239
- *
240
- * Produced by {@link parseOffsetParams}. Pass to `db.query(table).paginate()` for
241
- * traditional page-number-based pagination with total counts.
242
- *
243
- * @example
244
- * ```ts
245
- * const params = parseOffsetParams(request, { defaultLimit: 20 });
246
- * const page = await db.query(posts).paginate(params).run();
247
- * ```
248
- */
249
58
  type OffsetParams = {
250
- /** Discriminant for offset-based pagination. Always `"offset"`. */
251
59
  type: "offset";
252
- /** The 1-based page number. */
253
60
  page: number;
254
- /** Maximum items per page (clamped between 1 and `maxLimit`). */
255
61
  limit: number;
256
62
  };
257
- /**
258
- * Union of cursor and offset pagination parameters.
259
- *
260
- * Use the `type` discriminant to determine which pagination strategy is in use.
261
- * Accepted by `db.query(table).paginate()`.
262
- */
263
63
  type PaginateParams = CursorParams | OffsetParams;
264
- /**
265
- * A page of results from cursor-based pagination.
266
- *
267
- * Use `nextCursor` to fetch the next page. When `nextCursor` is `null`, there are no more pages.
268
- *
269
- * @typeParam T - The row type.
270
- *
271
- * @example
272
- * ```ts
273
- * const page: CursorPage<Post> = await db.query(posts)
274
- * .paginate({ type: "cursor", cursor: null, limit: 20 })
275
- * .run();
276
- *
277
- * if (page.nextCursor) {
278
- * // Fetch next page with page.nextCursor
279
- * }
280
- * ```
281
- */
282
64
  type CursorPage<T> = {
283
- /** The items on this page. */
284
65
  items: T[];
285
- /** Opaque cursor for the next page, or `null` if this is the last page. */
286
66
  nextCursor: string | null;
287
67
  };
288
- /**
289
- * A page of results from offset-based pagination.
290
- *
291
- * Includes total counts for rendering page navigation controls.
292
- *
293
- * @typeParam T - The row type.
294
- *
295
- * @example
296
- * ```ts
297
- * const page: OffsetPage<Post> = await db.query(posts)
298
- * .paginate({ type: "offset", page: 1, limit: 20 })
299
- * .run();
300
- *
301
- * console.log(`Page ${page.page} of ${page.totalPages} (${page.total} total)`);
302
- * ```
303
- */
304
68
  type OffsetPage<T> = {
305
- /** The items on this page. */
306
69
  items: T[];
307
- /** Total number of matching rows across all pages. */
308
70
  total: number;
309
- /** The current 1-based page number. */
310
71
  page: number;
311
- /** Total number of pages (computed as `Math.ceil(total / limit)`). */
312
72
  totalPages: number;
313
73
  };
314
- /**
315
- * Options for `db.query(table).paginate()`.
316
- *
317
- * Combines query filtering with pagination-specific settings. The actual pagination
318
- * strategy (cursor vs. offset) is determined by the {@link PaginateParams} passed
319
- * alongside these options.
320
- *
321
- * @example
322
- * ```ts
323
- * db.query(posts).paginate(params, {
324
- * where: eq(posts.published, true),
325
- * orderBy: desc(posts.createdAt),
326
- * cursorColumns: [posts.createdAt, posts.id],
327
- * orderDirection: "desc",
328
- * });
329
- * ```
330
- */
331
74
  type PaginateOptions = {
332
- /** Column selection (e.g., `{ id: true, title: true }`). Omit to select all columns. */
333
75
  columns?: Record<string, boolean>;
334
- /** User-supplied filter condition (AND'd with permission filters at `.run()` time). */
335
76
  where?: unknown;
336
- /** Ordering expression for offset pagination. Ignored for cursor pagination (uses `cursorColumns` instead). */
337
77
  orderBy?: unknown;
338
- /** Drizzle column references used for cursor-based ordering and comparison. */
339
78
  cursorColumns?: unknown[];
340
- /** Sort direction for cursor pagination. Defaults to `"desc"`. */
341
79
  orderDirection?: "asc" | "desc";
342
- /**
343
- * Drizzle relational query includes (e.g., `{ comments: true }`).
344
- *
345
- * Note: Permission filters are only applied to the root table, not to joined relations.
346
- */
347
80
  with?: Record<string, unknown>;
348
- /** Per-query cache control. Pass `false` to skip caching, or an object to customize. */
349
81
  cache?: QueryCacheOptions;
350
82
  };
351
- /**
352
- * The transaction handle passed to the `db.transaction()` callback.
353
- *
354
- * Exposes the read/write builder surface of {@link Db} but intentionally omits
355
- * `unsafe`, `batch`, `transaction`, and `cache`. Those are off-limits inside a
356
- * transaction:
357
- *
358
- * - `unsafe()` would let you bypass permissions mid-tx — if you need system
359
- * access, use `db.unsafe().transaction(...)` on the outer handle instead.
360
- * - `batch()` / nested `transaction()` calls are flattened into the parent's
361
- * pending queue automatically, so the plain mutate methods are all you need.
362
- * - `cache` invalidation is driven by the single commit at the end of the
363
- * transaction, not per-sub-op.
364
- *
365
- * Writes (`tx.insert` / `tx.update` / `tx.delete`) are recorded and flushed as
366
- * an atomic batch when the callback returns. Reads (`tx.query`) execute
367
- * eagerly so the caller can branch on their results.
368
- */
369
- type Tx = {
370
- /** Same as {@link Db.query}: reads execute eagerly against the underlying db. */
371
- query: <TTable extends DrizzleTable>(table: TTable) => QueryBuilder<TTable>;
372
- /**
373
- * Same as {@link Db.insert}: the returned Operation's `.run()` records the
374
- * insert into the transaction's pending queue. `.returning().run()` records
375
- * the insert and returns a promise that resolves AFTER the batch flushes.
376
- */
83
+ type TransactionResult<T> = {
84
+ result: T;
85
+ meta: {
86
+ changes: number;
87
+ writeResults: D1Result[];
88
+ };
89
+ };
90
+ type Tx<TSchema extends Record<string, unknown> = Record<string, never>> = {
91
+ query: <TTable extends DrizzleTable>(table: TTable) => QueryBuilder<TTable, TSchema>;
377
92
  insert: <TTable extends DrizzleTable>(table: TTable) => InsertBuilder<TTable>;
378
- /**
379
- * Same as {@link Db.update}: the returned Operation's `.run()` records the
380
- * update into the transaction's pending queue. Use relative SQL with a
381
- * WHERE guard (e.g. `set({ stock: sql\`stock - ${qty}\` }).where(gte(...))`)
382
- * for concurrency-safe read-modify-write.
383
- */
384
93
  update: <TTable extends DrizzleTable>(table: TTable) => UpdateBuilder<TTable>;
385
- /** Same as {@link Db.delete}: records the delete into the transaction's pending queue. */
386
94
  delete: <TTable extends DrizzleTable>(table: TTable) => DeleteBuilder<TTable>;
387
- /**
388
- * Nested transaction. Flattens into the parent's pending queue so every
389
- * write still commits in a single atomic batch. The nested callback's
390
- * return value is propagated as usual; any error thrown aborts the
391
- * entire outer transaction.
392
- *
393
- * Use this when a helper function wants to "own" its own tx scope but
394
- * the caller already started a transaction — the helper can call
395
- * `tx.transaction(...)` unconditionally and Just Work.
396
- */
397
- transaction: <T>(callback: (tx: Tx) => Promise<T>) => Promise<T>;
95
+ transaction: <T>(callback: (tx: Tx<TSchema>) => Promise<T>) => Promise<T>;
398
96
  };
399
- /**
400
- * A permission-aware database instance bound to a specific user.
401
- *
402
- * Created by {@link createDb}. All query and mutation methods return lazy {@link Operation}
403
- * objects that check permissions at `.run()` time. Create a new instance per request --
404
- * sharing across requests would apply one user's permissions to another's queries.
405
- *
406
- * @example
407
- * ```ts
408
- * // Read
409
- * const posts = await db.query(postsTable).findMany().run();
410
- *
411
- * // Write
412
- * await db.insert(postsTable).values({ title: "Hello" }).run();
413
- *
414
- * // Bypass permissions for system tasks
415
- * await db.unsafe().delete(sessionsTable).where(expired).run();
416
- * ```
417
- */
418
- type Db = {
419
- /**
420
- * Creates a {@link QueryBuilder} for reading rows from the given table.
421
- *
422
- * The builder is generic over `TTable`, so `findMany`/`findFirst` return rows
423
- * typed via `InferRow<TTable>` -- callers don't need to cast to `as any`.
424
- */
425
- query: <TTable extends DrizzleTable>(table: TTable) => QueryBuilder<TTable>;
426
- /** Creates an {@link InsertBuilder} for inserting rows into the given table. */
97
+ type Db<TSchema extends Record<string, unknown> = Record<string, never>> = {
98
+ query: <TTable extends DrizzleTable>(table: TTable) => QueryBuilder<TTable, TSchema>;
427
99
  insert: <TTable extends DrizzleTable>(table: TTable) => InsertBuilder<TTable>;
428
- /** Creates an {@link UpdateBuilder} for updating rows in the given table. */
429
100
  update: <TTable extends DrizzleTable>(table: TTable) => UpdateBuilder<TTable>;
430
- /** Creates a {@link DeleteBuilder} for deleting rows from the given table. */
431
101
  delete: <TTable extends DrizzleTable>(table: TTable) => DeleteBuilder<TTable>;
432
- /**
433
- * Returns a new `Db` instance that skips all permission checks.
434
- *
435
- * Use for cron jobs, migrations, and system operations without an authenticated user.
436
- * Every call site is greppable via `git grep '.unsafe()'`.
437
- */
438
- unsafe: () => Db;
439
- /**
440
- * Groups multiple operations into a single {@link Operation} with merged, deduplicated permissions.
441
- *
442
- * When every operation was produced by `db.insert/update/delete`, the batch is
443
- * executed via D1's native `batch()` API, which is **atomic** -- if any
444
- * statement fails, the entire batch is rolled back. This is the recommended
445
- * way to perform multi-step mutations that need transactional safety, such as
446
- * decrementing stock across multiple products during checkout.
447
- *
448
- * Permissions for every sub-operation are checked **upfront**: if the user
449
- * lacks any required grant, the batch throws before any SQL is issued.
450
- *
451
- * Operations that don't carry the internal batchable hook (for example, ops
452
- * produced by `compose()` executors) cause the batch to fall back to
453
- * sequential execution. This preserves backward compatibility for non-trivial
454
- * compositions but loses the atomicity guarantee.
455
- */
102
+ unsafe: () => Db<TSchema>;
456
103
  batch: (operations: Operation<unknown>[]) => Operation<unknown[]>;
457
104
  /**
458
105
  * Runs a callback inside a transaction with atomic commit-or-rollback semantics.
@@ -488,10 +135,11 @@ type Db = {
488
135
  * @param callback - The transaction body. Receives a `tx` handle with
489
136
  * `query`/`insert`/`update`/`delete` methods (no `unsafe`, `batch`, or
490
137
  * `cache` — those are intentionally off-limits inside a transaction).
491
- * @returns The value returned by the callback, or rejects with whatever the
492
- * callback threw (after rolling back pending writes).
138
+ * @returns A {@link TransactionResult} containing the callback's return value
139
+ * and transaction metadata (`meta.changes`, `meta.writeResults`), or rejects
140
+ * with whatever the callback threw (after rolling back pending writes).
493
141
  */
494
- transaction: <T>(callback: (tx: Tx) => Promise<T>) => Promise<T>;
142
+ transaction: <T>(callback: (tx: Tx<TSchema>) => Promise<T>) => Promise<TransactionResult<T>>;
495
143
  /** Cache control methods for manual invalidation. */
496
144
  cache: {
497
145
  /** Invalidate cached queries by tag names and/or table names. */
@@ -502,170 +150,54 @@ type Db = {
502
150
  tables?: string[];
503
151
  }) => Promise<void>;
504
152
  };
505
- };
506
- /**
507
- * Builder for read queries on a single table.
508
- *
509
- * Returned by `db.query(table)`. Provides `findMany`, `findFirst`, and `paginate` methods
510
- * that each return an {@link Operation} with permission-aware execution.
511
- *
512
- * @example
513
- * ```ts
514
- * const builder = db.query(posts);
515
- *
516
- * // Fetch all visible posts
517
- * const all = await builder.findMany().run();
518
- *
519
- * // Fetch a single post
520
- * const post = await builder.findFirst({ where: eq(posts.id, id) }).run();
521
- *
522
- * // Paginate
523
- * const page = await builder.paginate(params, { orderBy: desc(posts.createdAt) }).run();
524
- * ```
525
- */
526
- type QueryBuilder<TTable extends DrizzleTable = DrizzleTable> = {
527
153
  /**
528
- * Returns an {@link Operation} that fetches multiple rows matching the given options.
154
+ * Clears the per-instance `with` lookup cache so that the next query
155
+ * re-runs every grant lookup function.
529
156
  *
530
- * The result is typed via `InferRow<TTable>`, so callers get IntelliSense on
531
- * `(row) => row.title` without any cast.
157
+ * In production this is rarely needed because each request gets a fresh
158
+ * `Db` via `createDb()`. In tests that reuse a single `Db` across grant
159
+ * mutations (e.g. inserting a new friendship and then querying recipes),
160
+ * call this after the mutation to avoid stale lookup results.
532
161
  *
533
- * **Relations escape hatch (#158):** when you pass `with: { relation: true }`,
534
- * Drizzle's relational query builder embeds the joined rows into the result,
535
- * but `@cfast/db` cannot statically infer that shape from the
536
- * `Record<string, unknown>` schema we accept here. Override the row type via
537
- * the optional `TRow` generic to claim the shape you know the query will
538
- * produce, instead of having to `as any` the result downstream:
162
+ * For finer-grained control, wrap each logical request in
163
+ * {@link runWithLookupCache} instead -- that scopes the cache via
164
+ * `AsyncLocalStorage` so it is automatically discarded at scope exit.
539
165
  *
166
+ * @example
540
167
  * ```ts
541
- * type RecipeWithIngredients = Recipe & { ingredients: Ingredient[] };
542
- * const recipes = await db
543
- * .query(recipesTable)
544
- * .findMany<RecipeWithIngredients>({ with: { ingredients: true } })
545
- * .run();
546
- * // recipes is RecipeWithIngredients[], no cast needed
168
+ * const db = createDb({ ... });
169
+ * await db.query(recipes).findMany().run(); // populates lookup cache
170
+ * await db.insert(friendGrants).values({ ... }).run(); // adds new grant
171
+ * db.clearLookupCache(); // drop stale lookups
172
+ * await db.query(recipes).findMany().run(); // sees new grant
547
173
  * ```
548
174
  */
549
- findMany: <TRow = InferRow<TTable>>(options?: FindManyOptions) => Operation<TRow[]>;
550
- /**
551
- * Returns an {@link Operation} that fetches the first matching row, or `undefined`
552
- * if none match. The row type is propagated from `TTable`.
553
- *
554
- * Same `TRow` generic escape hatch as {@link findMany} for `with`-relation
555
- * shapes that the framework can't infer from the runtime-typed schema.
556
- *
557
- * ```ts
558
- * type RecipeWithIngredients = Recipe & { ingredients: Ingredient[] };
559
- * const recipe = await db
560
- * .query(recipesTable)
561
- * .findFirst<RecipeWithIngredients>({ where: eq(recipes.id, id), with: { ingredients: true } })
562
- * .run();
563
- * // recipe is RecipeWithIngredients | undefined
564
- * ```
565
- */
566
- findFirst: <TRow = InferRow<TTable>>(options?: FindFirstOptions) => Operation<TRow | undefined>;
567
- /**
568
- * Returns a paginated {@link Operation} using either cursor-based or offset-based strategy.
569
- *
570
- * The return type depends on the `params.type` discriminant: {@link CursorPage} for `"cursor"`,
571
- * {@link OffsetPage} for `"offset"`. Each page's items are typed as `InferRow<TTable>` by
572
- * default; pass an explicit `TRow` to claim a wider shape (e.g. when using
573
- * `with: { ... }` to embed related rows).
574
- */
175
+ clearLookupCache: () => void;
176
+ };
177
+ type QueryBuilder<TTable extends DrizzleTable = DrizzleTable, TSchema extends Record<string, unknown> = Record<string, never>> = {
178
+ findMany: <TConfig extends FindManyOptions = Record<string, never>, TRow = InferQueryResult<TSchema, TTable, TConfig>>(options?: TConfig) => Operation<TRow[]>;
179
+ findFirst: <TConfig extends FindFirstOptions = Record<string, never>, TRow = InferQueryResult<TSchema, TTable, TConfig>>(options?: TConfig) => Operation<TRow | undefined>;
575
180
  paginate: <TRow = InferRow<TTable>>(params: CursorParams | OffsetParams, options?: PaginateOptions) => Operation<CursorPage<TRow>> | Operation<OffsetPage<TRow>>;
576
181
  };
577
- /**
578
- * Builder for insert operations on a single table.
579
- *
580
- * Returned by `db.insert(table)`. Chain `.values()` to set the row data,
581
- * then optionally `.returning()` to get the inserted row back.
582
- *
583
- * @example
584
- * ```ts
585
- * // Insert without returning
586
- * await db.insert(posts).values({ title: "Hello", authorId: user.id }).run();
587
- *
588
- * // Insert with returning
589
- * const row = await db.insert(posts)
590
- * .values({ title: "Hello", authorId: user.id })
591
- * .returning()
592
- * .run();
593
- * ```
594
- */
595
182
  type InsertBuilder<TTable extends DrizzleTable = DrizzleTable> = {
596
- /** Specifies the column values to insert, returning an {@link InsertReturningBuilder}. */
597
183
  values: (values: Record<string, unknown>) => InsertReturningBuilder<TTable>;
598
184
  };
599
- /**
600
- * An insert {@link Operation} that optionally returns the inserted row via `.returning()`.
601
- *
602
- * Without `.returning()`, the operation resolves to `void`. With `.returning()`,
603
- * it resolves to the full inserted row, typed as `InferRow<TTable>`.
604
- */
605
185
  type InsertReturningBuilder<TTable extends DrizzleTable = DrizzleTable> = Operation<void> & {
606
- /** Chains `.returning()` to get the inserted row back from D1. */
607
186
  returning: () => Operation<InferRow<TTable>>;
608
187
  };
609
- /**
610
- * Builder for update operations on a single table.
611
- *
612
- * Returned by `db.update(table)`. Chain `.set()` to specify values, then `.where()`
613
- * to add a condition, and optionally `.returning()` to get the updated row back.
614
- *
615
- * @example
616
- * ```ts
617
- * await db.update(posts)
618
- * .set({ published: true })
619
- * .where(eq(posts.id, "abc-123"))
620
- * .run();
621
- * ```
622
- */
623
188
  type UpdateBuilder<TTable extends DrizzleTable = DrizzleTable> = {
624
- /** Specifies the column values to update, returning an {@link UpdateWhereBuilder}. */
625
189
  set: (values: Record<string, unknown>) => UpdateWhereBuilder<TTable>;
626
190
  };
627
- /**
628
- * Intermediate builder requiring a WHERE condition before the update can execute.
629
- *
630
- * The WHERE condition is AND'd with any permission-based WHERE clauses from the user's grants.
631
- */
632
191
  type UpdateWhereBuilder<TTable extends DrizzleTable = DrizzleTable> = {
633
- /** Specifies the WHERE condition (AND'd with permission filters at `.run()` time). */
634
192
  where: (condition: unknown) => UpdateReturningBuilder<TTable>;
635
193
  };
636
- /**
637
- * An update {@link Operation} that optionally returns the updated row via `.returning()`.
638
- *
639
- * Without `.returning()`, the operation resolves to `void`. With `.returning()`,
640
- * it resolves to the full updated row, typed as `InferRow<TTable>`.
641
- */
642
194
  type UpdateReturningBuilder<TTable extends DrizzleTable = DrizzleTable> = Operation<void> & {
643
- /** Chains `.returning()` to get the updated row back from D1. */
644
195
  returning: () => Operation<InferRow<TTable>>;
645
196
  };
646
- /**
647
- * Builder for delete operations on a single table.
648
- *
649
- * Returned by `db.delete(table)`. Chain `.where()` to add a condition,
650
- * and optionally `.returning()` to get the deleted row back.
651
- *
652
- * @example
653
- * ```ts
654
- * await db.delete(posts).where(eq(posts.id, "abc-123")).run();
655
- * ```
656
- */
657
197
  type DeleteBuilder<TTable extends DrizzleTable = DrizzleTable> = {
658
- /** Specifies the WHERE condition (AND'd with permission filters at `.run()` time). */
659
198
  where: (condition: unknown) => DeleteReturningBuilder<TTable>;
660
199
  };
661
- /**
662
- * A delete {@link Operation} that optionally returns the deleted row via `.returning()`.
663
- *
664
- * Without `.returning()`, the operation resolves to `void`. With `.returning()`,
665
- * it resolves to the full deleted row, typed as `InferRow<TTable>`.
666
- */
667
200
  type DeleteReturningBuilder<TTable extends DrizzleTable = DrizzleTable> = Operation<void> & {
668
- /** Chains `.returning()` to get the deleted row back from D1. */
669
201
  returning: () => Operation<InferRow<TTable>>;
670
202
  };
671
203
 
@@ -696,7 +228,7 @@ type DeleteReturningBuilder<TTable extends DrizzleTable = DrizzleTable> = Operat
696
228
  * const posts = await db.query(postsTable).findMany().run();
697
229
  * ```
698
230
  */
699
- declare function createDb(config: DbConfig): Db;
231
+ declare function createDb<TSchema extends Record<string, unknown>>(config: DbConfig<TSchema>): Db<TSchema>;
700
232
  /**
701
233
  * App-level db factory configuration. The "constants" — D1 binding,
702
234
  * Drizzle schema, cache config — that don't change between requests.
@@ -704,7 +236,7 @@ declare function createDb(config: DbConfig): Db;
704
236
  * Returned by {@link createAppDb}, called per-request with the user's
705
237
  * resolved grants and identity to produce a fresh permission-aware {@link Db}.
706
238
  */
707
- type AppDbConfig = {
239
+ type AppDbConfig<TSchema extends Record<string, unknown> = Record<string, unknown>> = {
708
240
  /**
709
241
  * The Cloudflare D1 database binding. Pass either the binding directly
710
242
  * (for tests, where the mock D1 is in scope at module load) or a
@@ -720,7 +252,7 @@ type AppDbConfig = {
720
252
  * Drizzle schema. Same shape as {@link DbConfig.schema} -- pass
721
253
  * `import * as schema from "./schema"`.
722
254
  */
723
- schema: Record<string, unknown>;
255
+ schema: TSchema;
724
256
  /**
725
257
  * Cache configuration shared across every per-request `Db`. Defaults to
726
258
  * `{ backend: "cache-api" }` to match `createDb`. Pass `false` to opt out.
@@ -736,9 +268,9 @@ type AppDbConfig = {
736
268
  * `@cfast/core`'s `dbPlugin`, route loaders) can pass the factory around
737
269
  * without re-deriving its signature.
738
270
  */
739
- type AppDbFactory = (grants: Grant[], user: {
271
+ type AppDbFactory<TSchema extends Record<string, unknown> = Record<string, never>> = (grants: Grant[], user: {
740
272
  id: string;
741
- } | null) => Db;
273
+ } | null) => Db<TSchema>;
742
274
  /**
743
275
  * Consolidates the three near-identical `createDb` factories that every
744
276
  * cfast app used to define (#149):
@@ -788,7 +320,7 @@ type AppDbFactory = (grants: Grant[], user: {
788
320
  * };
789
321
  * ```
790
322
  */
791
- declare function createAppDb(config: AppDbConfig): AppDbFactory;
323
+ declare function createAppDb<TSchema extends Record<string, unknown>>(config: AppDbConfig<TSchema>): AppDbFactory<TSchema>;
792
324
 
793
325
  /**
794
326
  * A function that executes a single sub-operation within a {@link compose} executor.
@@ -999,10 +531,6 @@ declare function parseCursorParams(request: Request, options?: PaginationOptions
999
531
  */
1000
532
  declare function parseOffsetParams(request: Request, options?: PaginationOptions): OffsetParams;
1001
533
 
1002
- /**
1003
- * Error thrown when a transaction is misused (e.g. an op is recorded after the
1004
- * transaction has already committed or aborted).
1005
- */
1006
534
  declare class TransactionError extends Error {
1007
535
  constructor(message: string);
1008
536
  }
@@ -1109,6 +637,54 @@ type Seed = {
1109
637
  */
1110
638
  declare function defineSeed(config: SeedConfig): Seed;
1111
639
 
640
+ /**
641
+ * Recursively converts Date fields in a value to ISO 8601 strings for
642
+ * JSON serialization.
643
+ *
644
+ * React Router loaders must return JSON-serializable data. Date objects
645
+ * are not JSON-serializable by default -- `JSON.stringify(new Date())`
646
+ * calls `Date.prototype.toJSON()` which produces an ISO string, but the
647
+ * resulting value on the client is a string, not a Date. This helper
648
+ * makes the conversion explicit and type-safe so every loader doesn't
649
+ * need to manually call `.toISOString()` on every date field.
650
+ *
651
+ * Works on:
652
+ * - Plain objects (shallow and nested)
653
+ * - Arrays of objects
654
+ * - Single Date values
655
+ * - Nested arrays and objects of arbitrary depth
656
+ *
657
+ * Non-Date primitives (string, number, boolean, null, undefined) pass
658
+ * through unchanged. The function is a no-op for values that don't
659
+ * contain Date instances.
660
+ *
661
+ * @param value - The value to convert. Typically a query result row or
662
+ * array of rows from `db.query(...).findMany().run()`.
663
+ * @returns A new value with every Date replaced by its ISO string.
664
+ *
665
+ * @example
666
+ * ```ts
667
+ * import { toJSON } from "@cfast/db";
668
+ *
669
+ * export async function loader({ context }) {
670
+ * const db = createDb({ ... });
671
+ * const posts = await db.query(postsTable).findMany().run();
672
+ * return { posts: toJSON(posts) };
673
+ * }
674
+ * ```
675
+ */
676
+ declare function toJSON<T>(value: T): DateToString<T>;
677
+ /**
678
+ * Type-level utility that converts Date fields to string in a type.
679
+ *
680
+ * Maps `{ createdAt: Date; title: string }` to
681
+ * `{ createdAt: string; title: string }` so the return type of
682
+ * `toJSON(row)` accurately reflects the runtime shape.
683
+ */
684
+ type DateToString<T> = T extends Date ? string : T extends Array<infer U> ? DateToString<U>[] : T extends Record<string, unknown> ? {
685
+ [K in keyof T]: DateToString<T[K]>;
686
+ } : T;
687
+
1112
688
  /**
1113
689
  * Per-request cache that holds the resolved `with` lookup map for each grant.
1114
690
  *
@@ -1157,4 +733,4 @@ declare function createLookupCache(): LookupCache;
1157
733
  */
1158
734
  declare function runWithLookupCache<T>(fn: () => T, cache?: LookupCache): T;
1159
735
 
1160
- export { type AppDbConfig, type AppDbFactory, type CacheBackend, type CacheConfig, type CursorPage, type CursorParams, type Db, type DbConfig, type DeleteBuilder, type DeleteReturningBuilder, type FindFirstOptions, type FindManyOptions, type InferRow, type InsertBuilder, type InsertReturningBuilder, type LookupCache, type OffsetPage, type OffsetParams, type Operation, type PaginateOptions, type PaginateParams, type QueryBuilder, type QueryCacheOptions, type Seed, type SeedConfig, type SeedEntry, TransactionError, type Tx, type UpdateBuilder, type UpdateReturningBuilder, type UpdateWhereBuilder, compose, composeSequential, composeSequentialCallback, createAppDb, createDb, createLookupCache, defineSeed, parseCursorParams, parseOffsetParams, runWithLookupCache };
736
+ export { type AppDbConfig, type AppDbFactory, type CacheBackend, type CacheConfig, type CursorPage, type CursorParams, type DateToString, type Db, type DbConfig, type DeleteBuilder, type DeleteReturningBuilder, type FindFirstOptions, type FindManyOptions, type InferQueryResult, type InferRow, type InsertBuilder, type InsertReturningBuilder, type LookupCache, type OffsetPage, type OffsetParams, type Operation, type PaginateOptions, type PaginateParams, type QueryBuilder, type QueryCacheOptions, type Seed, type SeedConfig, type SeedEntry, TransactionError, type TransactionResult, type Tx, type UpdateBuilder, type UpdateReturningBuilder, type UpdateWhereBuilder, compose, composeSequential, composeSequentialCallback, createAppDb, createDb, createLookupCache, defineSeed, parseCursorParams, parseOffsetParams, runWithLookupCache, toJSON };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/create-db.ts
2
- import { drizzle as drizzle3 } from "drizzle-orm/d1";
2
+ import { drizzle as drizzle4 } from "drizzle-orm/d1";
3
3
 
4
4
  // src/query-builder.ts
5
5
  import { count } from "drizzle-orm";
@@ -675,6 +675,7 @@ function createCacheManager(config) {
675
675
  }
676
676
 
677
677
  // src/transaction.ts
678
+ import { drizzle as drizzle3 } from "drizzle-orm/d1";
678
679
  var TransactionError = class extends Error {
679
680
  constructor(message) {
680
681
  super(message);
@@ -684,14 +685,10 @@ var TransactionError = class extends Error {
684
685
  function wrapWriteOperation(ctx, op) {
685
686
  const record = (toQueue) => {
686
687
  if (ctx.closed) {
687
- throw new TransactionError(
688
- "Cannot run an operation after the transaction has committed or aborted."
689
- );
688
+ throw new TransactionError("Cannot run an operation after the transaction has committed or aborted.");
690
689
  }
691
690
  if (ctx.aborted) {
692
- throw new TransactionError(
693
- "Transaction has been aborted; no further operations may run."
694
- );
691
+ throw new TransactionError("Transaction has been aborted; no further operations may run.");
695
692
  }
696
693
  ctx.pending.push({ op: toQueue });
697
694
  };
@@ -718,9 +715,7 @@ function wrapWriteOperation(ctx, op) {
718
715
  }
719
716
  function createTxHandle(db, ctx) {
720
717
  return {
721
- query: (table) => {
722
- return db.query(table);
723
- },
718
+ query: (table) => db.query(table),
724
719
  insert: (table) => {
725
720
  const realBuilder = db.insert(table);
726
721
  return {
@@ -753,13 +748,11 @@ function createTxHandle(db, ctx) {
753
748
  }
754
749
  };
755
750
  },
756
- transaction: (callback) => {
757
- return runTransaction(db, callback, ctx);
758
- }
751
+ transaction: (callback) => runTransaction(db, callback, ctx)
759
752
  };
760
753
  }
761
- async function flushWrites(db, pending) {
762
- if (pending.length === 0) return;
754
+ async function flushWritesDirect(config, isUnsafe, pending) {
755
+ if (pending.length === 0) return { changes: 0, writeResults: [] };
763
756
  const ops = pending.map((p) => p.op);
764
757
  for (const op of ops) {
765
758
  if (getBatchable(op) === void 0) {
@@ -768,24 +761,39 @@ async function flushWrites(db, pending) {
768
761
  );
769
762
  }
770
763
  }
771
- await db.batch(ops).run({});
764
+ const allPermissions = deduplicateDescriptors(ops.flatMap((op) => op.permissions));
765
+ if (!isUnsafe) {
766
+ checkOperationPermissions(config.grants, allPermissions);
767
+ }
768
+ const batchables = ops.map((op) => getBatchable(op));
769
+ await Promise.all(batchables.map((b) => b.prepare?.() ?? Promise.resolve()));
770
+ const sharedDb = drizzle3(config.d1, { schema: config.schema });
771
+ const items = batchables.map((b) => b.build(sharedDb));
772
+ const batchResults = await sharedDb.batch(
773
+ items
774
+ );
775
+ const writeResults = batchResults.map((r) => {
776
+ if (r && typeof r === "object" && "meta" in r) {
777
+ return r;
778
+ }
779
+ return { results: [], success: true, meta: { changes: 0 } };
780
+ });
781
+ const changes = writeResults.reduce(
782
+ (sum, r) => sum + (r?.meta?.changes ?? 0),
783
+ 0
784
+ );
785
+ return { changes, writeResults };
772
786
  }
773
- async function runTransaction(db, callback, parentCtx) {
774
- if (parentCtx) {
787
+ async function runTransaction(db, callback, parentCtxOrConfig, isUnsafe) {
788
+ if (parentCtxOrConfig && "pending" in parentCtxOrConfig) {
789
+ const parentCtx = parentCtxOrConfig;
775
790
  if (parentCtx.closed || parentCtx.aborted) {
776
- throw new TransactionError(
777
- "Cannot start a nested transaction: parent has already committed or aborted."
778
- );
791
+ throw new TransactionError("Cannot start a nested transaction: parent has already committed or aborted.");
779
792
  }
780
- const nestedTx = createTxHandle(db, parentCtx);
781
- return callback(nestedTx);
793
+ return callback(createTxHandle(db, parentCtx));
782
794
  }
783
- const ctx = {
784
- pending: [],
785
- closed: false,
786
- aborted: false,
787
- nested: false
788
- };
795
+ const config = parentCtxOrConfig;
796
+ const ctx = { pending: [], closed: false, aborted: false, nested: false };
789
797
  const tx = createTxHandle(db, ctx);
790
798
  let result;
791
799
  try {
@@ -796,15 +804,21 @@ async function runTransaction(db, callback, parentCtx) {
796
804
  ctx.pending.length = 0;
797
805
  throw err;
798
806
  }
807
+ let flushResult;
799
808
  try {
800
- await flushWrites(db, ctx.pending);
809
+ if (config) {
810
+ flushResult = await flushWritesDirect(config, isUnsafe ?? false, ctx.pending);
811
+ } else {
812
+ if (ctx.pending.length > 0) await db.batch(ctx.pending.map((p) => p.op)).run({});
813
+ flushResult = { changes: 0, writeResults: [] };
814
+ }
801
815
  ctx.closed = true;
802
816
  } catch (err) {
803
817
  ctx.aborted = true;
804
818
  ctx.closed = true;
805
819
  throw err;
806
820
  }
807
- return result;
821
+ return { result, meta: { changes: flushResult.changes, writeResults: flushResult.writeResults } };
808
822
  }
809
823
 
810
824
  // src/create-db.ts
@@ -895,7 +909,7 @@ function buildDb(config, isUnsafe, lookupCache) {
895
909
  const batchables = operations.map((op) => getBatchable(op));
896
910
  const everyOpBatchable = batchables.every((b) => b !== void 0);
897
911
  if (everyOpBatchable) {
898
- const sharedDb = drizzle3(config.d1, { schema: config.schema });
912
+ const sharedDb = drizzle4(config.d1, { schema: config.schema });
899
913
  await Promise.all(
900
914
  batchables.map((b) => b.prepare?.() ?? Promise.resolve())
901
915
  );
@@ -921,7 +935,7 @@ function buildDb(config, isUnsafe, lookupCache) {
921
935
  };
922
936
  },
923
937
  transaction(callback) {
924
- return runTransaction(db, callback);
938
+ return runTransaction(db, callback, { d1: config.d1, schema: config.schema, grants: config.grants }, isUnsafe);
925
939
  },
926
940
  cache: {
927
941
  async invalidate(options) {
@@ -935,6 +949,9 @@ function buildDb(config, isUnsafe, lookupCache) {
935
949
  }
936
950
  }
937
951
  }
952
+ },
953
+ clearLookupCache() {
954
+ lookupCache.clear();
938
955
  }
939
956
  };
940
957
  return db;
@@ -1051,7 +1068,7 @@ function createTrackingDb(real, perms) {
1051
1068
  insert: trackingDb.insert,
1052
1069
  update: trackingDb.update,
1053
1070
  delete: trackingDb.delete,
1054
- transaction: (cb) => trackingDb.transaction(cb)
1071
+ transaction: (cb) => trackingDb.transaction(cb).then((txr) => txr.result)
1055
1072
  };
1056
1073
  try {
1057
1074
  await callback(trackingTx);
@@ -1059,7 +1076,8 @@ function createTrackingDb(real, perms) {
1059
1076
  }
1060
1077
  return createSentinel();
1061
1078
  },
1062
- cache: real.cache
1079
+ cache: real.cache,
1080
+ clearLookupCache: () => real.clearLookupCache()
1063
1081
  };
1064
1082
  return trackingDb;
1065
1083
  }
@@ -1117,6 +1135,27 @@ function defineSeed(config) {
1117
1135
  }
1118
1136
  };
1119
1137
  }
1138
+
1139
+ // src/json.ts
1140
+ function toJSON(value) {
1141
+ return convertDates(value);
1142
+ }
1143
+ function convertDates(value) {
1144
+ if (value instanceof Date) {
1145
+ return value.toISOString();
1146
+ }
1147
+ if (Array.isArray(value)) {
1148
+ return value.map(convertDates);
1149
+ }
1150
+ if (value !== null && typeof value === "object") {
1151
+ const result = {};
1152
+ for (const [key, val] of Object.entries(value)) {
1153
+ result[key] = convertDates(val);
1154
+ }
1155
+ return result;
1156
+ }
1157
+ return value;
1158
+ }
1120
1159
  export {
1121
1160
  TransactionError,
1122
1161
  compose,
@@ -1128,5 +1167,6 @@ export {
1128
1167
  defineSeed,
1129
1168
  parseCursorParams,
1130
1169
  parseOffsetParams,
1131
- runWithLookupCache
1170
+ runWithLookupCache,
1171
+ toJSON
1132
1172
  };
package/llms.txt CHANGED
@@ -58,32 +58,48 @@ 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 } })
76
80
  .run();
77
- // recipes is RecipeWithIngredients[], no cast needed.
81
+ // recipes is { id: string; title: string; ingredients: Ingredient[] }[]
78
82
 
79
- const recipe = await db
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
+ })
89
+ .run();
90
+ // plan.entries[0].recipe.ingredients is fully typed
91
+ ```
92
+
93
+ **Manual override:** pass `<TConfig, TRow>` to override when needed:
94
+
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
 
89
105
  ### Writes
@@ -223,7 +239,7 @@ sequential execution (and loses the atomicity guarantee). For pure compose
223
239
  workflows that need atomicity, build the underlying ops with `db.insert/update/
224
240
  delete` directly and pass them straight to `db.batch([...])`.
225
241
 
226
- ### db.transaction(async tx => ...): Promise<T>
242
+ ### db.transaction(async tx => ...): Promise<TransactionResult<T>>
227
243
 
228
244
  Runs a callback inside a transaction. Writes (`tx.insert`, `tx.update`,
229
245
  `tx.delete`) are **recorded** as the callback runs and flushed together as a
@@ -231,6 +247,14 @@ single atomic `db.batch([...])` when the callback returns successfully. If the
231
247
  callback throws, the pending writes are discarded and the error is re-thrown —
232
248
  nothing reaches D1.
233
249
 
250
+ Returns a `TransactionResult<T>` with:
251
+ - `result: T` — the callback's return value
252
+ - `meta.changes: number` — total rows affected across all writes
253
+ - `meta.writeResults: D1Result[]` — per-statement D1 results
254
+
255
+ Use `meta.changes` to detect whether a WHERE-guarded UPDATE actually matched
256
+ any rows (the "out of stock" signal) without falling back to raw SQL.
257
+
234
258
  Use `db.transaction` whenever the set of writes depends on logic inside the
235
259
  callback (read-modify-write, conditional inserts, state machines):
236
260
 
@@ -238,11 +262,7 @@ callback (read-modify-write, conditional inserts, state machines):
238
262
  import { and, eq, gte, sql } from "drizzle-orm";
239
263
 
240
264
  // 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.
265
+ const { result: order, meta } = await db.transaction(async (tx) => {
246
266
  const product = await tx.query(products).findFirst({
247
267
  where: eq(products.id, pid),
248
268
  }).run();
@@ -250,24 +270,22 @@ const order = await db.transaction(async (tx) => {
250
270
  throw new Error("out of stock"); // rolls back, nothing is written
251
271
  }
252
272
 
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.
273
+ // Guarded decrement: relative SQL + WHERE stock >= qty.
258
274
  await tx.update(products)
259
275
  .set({ stock: sql`stock - ${qty}` })
260
276
  .where(and(eq(products.id, pid), gte(products.stock, qty)))
261
277
  .run();
262
278
 
263
- // Generate the order id client-side so we don't need `.returning()`
264
- // inside the transaction (see "Returning inside a transaction" below).
265
279
  const orderId = crypto.randomUUID();
266
280
  await tx.insert(orders).values({ id: orderId, productId: pid, qty }).run();
267
281
 
268
- // Whatever the callback returns becomes the transaction's return value.
269
282
  return { orderId, productId: pid, qty };
270
283
  });
284
+
285
+ // Check if the guarded decrement actually matched any rows.
286
+ if (meta.changes === 0) {
287
+ throw new Error("out of stock — concurrent decrement won");
288
+ }
271
289
  ```
272
290
 
273
291
  **`tx` is a `Pick<Db, "query" | "insert" | "update" | "delete">`** plus a
@@ -532,6 +550,27 @@ prefer the scoped cache over the `Db`-owned fallback. Pass an explicit
532
550
  `LookupCache` instance as the second argument if you want to share a cache
533
551
  across multiple sibling `runWithLookupCache` calls.
534
552
 
553
+ ### db.clearLookupCache(): void
554
+
555
+ Clears the per-instance cross-table `with` lookup cache on the `Db` instance. This is the imperative counterpart to `runWithLookupCache()` -- instead of scoping a fresh cache via ALS, it resets the instance-owned cache in place.
556
+
557
+ The primary use case is tests that reuse a single `Db` across grant inserts. After modifying grants, call `db.clearLookupCache()` so subsequent queries re-run `with` lookups against the updated data:
558
+
559
+ ```typescript
560
+ const db = createDb({ d1, schema, grants, user, cache: false });
561
+
562
+ // Insert a new friend-grant.
563
+ await db.unsafe().insert(friendGrants).values({ grantee: "u1", target: "u2" }).run();
564
+
565
+ // Without this, the next query would use the stale cached lookup.
566
+ db.clearLookupCache();
567
+
568
+ const visible = await db.query(recipes).findMany().run();
569
+ expect(visible).toContainEqual(expect.objectContaining({ authorId: "u2" }));
570
+ ```
571
+
572
+ Prefer `runWithLookupCache()` in production code (ALS-scoped, automatic cleanup). Use `clearLookupCache()` when you need a quick manual reset in tests or long-lived workers that don't use the ALS pattern.
573
+
535
574
  ## Integration
536
575
 
537
576
  - **@cfast/permissions** -- `grants` come from `resolveGrants(permissions, user.roles)`. Permission WHERE clauses are defined via `grant()` in your permissions config.
@@ -610,6 +649,30 @@ export default defineConfig({
610
649
  });
611
650
  ```
612
651
 
652
+ ### toJSON(value): DateToString<T>
653
+
654
+ Recursively converts `Date` fields to ISO 8601 strings for JSON serialization.
655
+ React Router loaders must return JSON-serializable data; `toJSON()` makes the
656
+ Date-to-string conversion explicit so every loader doesn't need manual
657
+ `.toISOString()` calls.
658
+
659
+ ```typescript
660
+ import { toJSON } from "@cfast/db";
661
+
662
+ export async function loader({ context }) {
663
+ const db = createDb({ ... });
664
+ const posts = await db.query(postsTable).findMany().run();
665
+ // Date fields (createdAt, updatedAt, etc.) become ISO strings automatically.
666
+ return { posts: toJSON(posts) };
667
+ }
668
+ ```
669
+
670
+ The return type `DateToString<T>` maps every `Date` property to `string` at the
671
+ type level, so the client sees accurate types without manual casting.
672
+
673
+ Works on plain objects, arrays, nested structures, and single Date values.
674
+ Non-Date primitives pass through unchanged.
675
+
613
676
  ## Common Mistakes
614
677
 
615
678
  - **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.0",
3
+ "version": "0.5.0",
4
4
  "description": "Permission-aware Drizzle queries for Cloudflare D1",
5
5
  "keywords": [
6
6
  "cfast",
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "peerDependencies": {
37
- "@cfast/permissions": ">=0.3.0 <0.5.0",
37
+ "@cfast/permissions": ">=0.3.0 <0.6.0",
38
38
  "drizzle-orm": ">=0.35"
39
39
  },
40
40
  "peerDependenciesMeta": {
@@ -51,7 +51,7 @@
51
51
  "tsup": "^8",
52
52
  "typescript": "^5.7",
53
53
  "vitest": "^4.1.0",
54
- "@cfast/permissions": "0.4.0"
54
+ "@cfast/permissions": "0.5.1"
55
55
  },
56
56
  "scripts": {
57
57
  "build": "tsup src/index.ts --format esm --dts",