@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 CHANGED
@@ -1,696 +1,7 @@
1
- import { Grant, DrizzleTable, PermissionDescriptor } from '@cfast/permissions';
2
-
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
- type InferRow<TTable> = TTable extends {
15
- $inferSelect: infer R;
16
- } ? 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
- */
39
- type Operation<TResult> = {
40
- /** Structural permission requirements. Available immediately without execution. */
41
- 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
- run: (params?: Record<string, unknown>) => Promise<TResult>;
50
- };
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
- 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
- type CacheConfig = {
81
- /** Which cache backend to use: edge-local Cache API or global KV. */
82
- backend: CacheBackend;
83
- /** KV namespace binding. Required when {@link backend} is `"kv"`. */
84
- kv?: KVNamespace;
85
- /** Default TTL for cached queries (e.g., `"30s"`, `"5m"`, `"1h"`). Defaults to `"60s"`. */
86
- ttl?: string;
87
- /** Stale-while-revalidate window (e.g., `"5m"`). Serves stale data while revalidating in the background. */
88
- staleWhileRevalidate?: string;
89
- /** Table names that should never be cached (e.g., `["sessions", "tokens"]`). */
90
- exclude?: string[];
91
- /** Observability hook called on cache hits. */
92
- onHit?: (key: string, table: string) => void;
93
- /** Observability hook called on cache misses. */
94
- onMiss?: (key: string, table: string) => void;
95
- /** Observability hook called when tables are invalidated by mutations. */
96
- onInvalidate?: (tables: string[]) => void;
97
- };
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
- type QueryCacheOptions = false | {
114
- /** Override the default TTL for this query (e.g., `"5m"`, `"1h"`). */
115
- ttl?: string;
116
- /** Override the default stale-while-revalidate window for this query. */
117
- staleWhileRevalidate?: string;
118
- /** Tags for targeted manual invalidation via `db.cache.invalidate({ tags })`. */
119
- tags?: string[];
120
- };
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`. */
140
- 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()`. */
152
- 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
- user: {
158
- id: string;
159
- } | null;
160
- /** Cache configuration, or `false` to disable caching entirely. Defaults to `{ backend: "cache-api" }`. */
161
- cache?: CacheConfig | false;
162
- };
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
- type FindManyOptions = {
185
- /** Column selection (e.g., `{ id: true, title: true }`). Omit to select all columns. */
186
- columns?: Record<string, boolean>;
187
- /** User-supplied filter condition (AND'd with permission filters at `.run()` time). */
188
- where?: unknown;
189
- /** Ordering expression (e.g., `desc(posts.createdAt)`). */
190
- orderBy?: unknown;
191
- /** Maximum number of rows to return. */
192
- limit?: number;
193
- /** Number of rows to skip (for offset-based pagination). */
194
- 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
- with?: Record<string, unknown>;
201
- /** Per-query cache control. Pass `false` to skip caching, or an object to customize. */
202
- cache?: QueryCacheOptions;
203
- };
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
- 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
- type CursorParams = {
230
- /** Discriminant for cursor-based pagination. Always `"cursor"`. */
231
- type: "cursor";
232
- /** The opaque cursor string from the previous page, or `null` for the first page. */
233
- cursor: string | null;
234
- /** Maximum items per page (clamped between 1 and `maxLimit`). */
235
- limit: number;
236
- };
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
- type OffsetParams = {
250
- /** Discriminant for offset-based pagination. Always `"offset"`. */
251
- type: "offset";
252
- /** The 1-based page number. */
253
- page: number;
254
- /** Maximum items per page (clamped between 1 and `maxLimit`). */
255
- limit: number;
256
- };
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
- 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
- type CursorPage<T> = {
283
- /** The items on this page. */
284
- items: T[];
285
- /** Opaque cursor for the next page, or `null` if this is the last page. */
286
- nextCursor: string | null;
287
- };
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
- type OffsetPage<T> = {
305
- /** The items on this page. */
306
- items: T[];
307
- /** Total number of matching rows across all pages. */
308
- total: number;
309
- /** The current 1-based page number. */
310
- page: number;
311
- /** Total number of pages (computed as `Math.ceil(total / limit)`). */
312
- totalPages: number;
313
- };
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
- type PaginateOptions = {
332
- /** Column selection (e.g., `{ id: true, title: true }`). Omit to select all columns. */
333
- columns?: Record<string, boolean>;
334
- /** User-supplied filter condition (AND'd with permission filters at `.run()` time). */
335
- where?: unknown;
336
- /** Ordering expression for offset pagination. Ignored for cursor pagination (uses `cursorColumns` instead). */
337
- orderBy?: unknown;
338
- /** Drizzle column references used for cursor-based ordering and comparison. */
339
- cursorColumns?: unknown[];
340
- /** Sort direction for cursor pagination. Defaults to `"desc"`. */
341
- 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
- with?: Record<string, unknown>;
348
- /** Per-query cache control. Pass `false` to skip caching, or an object to customize. */
349
- cache?: QueryCacheOptions;
350
- };
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
- */
377
- 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
- update: <TTable extends DrizzleTable>(table: TTable) => UpdateBuilder<TTable>;
385
- /** Same as {@link Db.delete}: records the delete into the transaction's pending queue. */
386
- 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>;
398
- };
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. */
427
- insert: <TTable extends DrizzleTable>(table: TTable) => InsertBuilder<TTable>;
428
- /** Creates an {@link UpdateBuilder} for updating rows in the given table. */
429
- update: <TTable extends DrizzleTable>(table: TTable) => UpdateBuilder<TTable>;
430
- /** Creates a {@link DeleteBuilder} for deleting rows from the given table. */
431
- 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
- */
456
- batch: (operations: Operation<unknown>[]) => Operation<unknown[]>;
457
- /**
458
- * Runs a callback inside a transaction with atomic commit-or-rollback semantics.
459
- *
460
- * All writes (`tx.insert`/`tx.update`/`tx.delete`) recorded inside the callback
461
- * are deferred and flushed together as a single atomic `db.batch([...])` when
462
- * the callback returns successfully. If the callback throws, pending writes are
463
- * discarded and the error is re-thrown — nothing reaches D1.
464
- *
465
- * Reads (`tx.query(...)`) execute eagerly so the caller can branch on their
466
- * results. **D1 does not provide snapshot isolation across async code**, so
467
- * reads inside a transaction can see concurrent writes. For concurrency-safe
468
- * read-modify-write (e.g. stock decrement), combine the transaction with a
469
- * relative SQL update and a WHERE guard:
470
- *
471
- * ```ts
472
- * await db.transaction(async (tx) => {
473
- * // The WHERE guard ensures the decrement only applies when stock is
474
- * // still >= qty at commit time. Two concurrent transactions cannot
475
- * // both oversell because D1's atomic batch re-evaluates the WHERE.
476
- * await tx.update(products)
477
- * .set({ stock: sql`stock - ${qty}` })
478
- * .where(and(eq(products.id, pid), gte(products.stock, qty)))
479
- * .run();
480
- * return tx.insert(orders).values({ productId: pid, qty }).returning().run();
481
- * });
482
- * ```
483
- *
484
- * Nested `db.transaction()` calls inside the callback are flattened into the
485
- * parent's pending queue so everything still commits atomically.
486
- *
487
- * @typeParam T - The return type of the callback.
488
- * @param callback - The transaction body. Receives a `tx` handle with
489
- * `query`/`insert`/`update`/`delete` methods (no `unsafe`, `batch`, or
490
- * `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).
493
- */
494
- transaction: <T>(callback: (tx: Tx) => Promise<T>) => Promise<T>;
495
- /** Cache control methods for manual invalidation. */
496
- cache: {
497
- /** Invalidate cached queries by tag names and/or table names. */
498
- invalidate: (options: {
499
- /** Tag names to invalidate (from {@link QueryCacheOptions} `tags`). */
500
- tags?: string[];
501
- /** Table names to invalidate (bumps their version counters). */
502
- tables?: string[];
503
- }) => Promise<void>;
504
- };
505
- /**
506
- * Clears the per-instance `with` lookup cache so that the next query
507
- * re-runs every grant lookup function.
508
- *
509
- * In production this is rarely needed because each request gets a fresh
510
- * `Db` via `createDb()`. In tests that reuse a single `Db` across grant
511
- * mutations (e.g. inserting a new friendship and then querying recipes),
512
- * call this after the mutation to avoid stale lookup results.
513
- *
514
- * For finer-grained control, wrap each logical request in
515
- * {@link runWithLookupCache} instead -- that scopes the cache via
516
- * `AsyncLocalStorage` so it is automatically discarded at scope exit.
517
- *
518
- * @example
519
- * ```ts
520
- * const db = createDb({ ... });
521
- * await db.query(recipes).findMany().run(); // populates lookup cache
522
- * await db.insert(friendGrants).values({ ... }).run(); // adds new grant
523
- * db.clearLookupCache(); // drop stale lookups
524
- * await db.query(recipes).findMany().run(); // sees new grant
525
- * ```
526
- */
527
- clearLookupCache: () => void;
528
- };
529
- /**
530
- * Builder for read queries on a single table.
531
- *
532
- * Returned by `db.query(table)`. Provides `findMany`, `findFirst`, and `paginate` methods
533
- * that each return an {@link Operation} with permission-aware execution.
534
- *
535
- * @example
536
- * ```ts
537
- * const builder = db.query(posts);
538
- *
539
- * // Fetch all visible posts
540
- * const all = await builder.findMany().run();
541
- *
542
- * // Fetch a single post
543
- * const post = await builder.findFirst({ where: eq(posts.id, id) }).run();
544
- *
545
- * // Paginate
546
- * const page = await builder.paginate(params, { orderBy: desc(posts.createdAt) }).run();
547
- * ```
548
- */
549
- type QueryBuilder<TTable extends DrizzleTable = DrizzleTable> = {
550
- /**
551
- * Returns an {@link Operation} that fetches multiple rows matching the given options.
552
- *
553
- * The result is typed via `InferRow<TTable>`, so callers get IntelliSense on
554
- * `(row) => row.title` without any cast.
555
- *
556
- * **Relations escape hatch (#158):** when you pass `with: { relation: true }`,
557
- * Drizzle's relational query builder embeds the joined rows into the result,
558
- * but `@cfast/db` cannot statically infer that shape from the
559
- * `Record<string, unknown>` schema we accept here. Override the row type via
560
- * the optional `TRow` generic to claim the shape you know the query will
561
- * produce, instead of having to `as any` the result downstream:
562
- *
563
- * ```ts
564
- * type RecipeWithIngredients = Recipe & { ingredients: Ingredient[] };
565
- * const recipes = await db
566
- * .query(recipesTable)
567
- * .findMany<RecipeWithIngredients>({ with: { ingredients: true } })
568
- * .run();
569
- * // recipes is RecipeWithIngredients[], no cast needed
570
- * ```
571
- */
572
- findMany: <TRow = InferRow<TTable>>(options?: FindManyOptions) => Operation<TRow[]>;
573
- /**
574
- * Returns an {@link Operation} that fetches the first matching row, or `undefined`
575
- * if none match. The row type is propagated from `TTable`.
576
- *
577
- * Same `TRow` generic escape hatch as {@link findMany} for `with`-relation
578
- * shapes that the framework can't infer from the runtime-typed schema.
579
- *
580
- * ```ts
581
- * type RecipeWithIngredients = Recipe & { ingredients: Ingredient[] };
582
- * const recipe = await db
583
- * .query(recipesTable)
584
- * .findFirst<RecipeWithIngredients>({ where: eq(recipes.id, id), with: { ingredients: true } })
585
- * .run();
586
- * // recipe is RecipeWithIngredients | undefined
587
- * ```
588
- */
589
- findFirst: <TRow = InferRow<TTable>>(options?: FindFirstOptions) => Operation<TRow | undefined>;
590
- /**
591
- * Returns a paginated {@link Operation} using either cursor-based or offset-based strategy.
592
- *
593
- * The return type depends on the `params.type` discriminant: {@link CursorPage} for `"cursor"`,
594
- * {@link OffsetPage} for `"offset"`. Each page's items are typed as `InferRow<TTable>` by
595
- * default; pass an explicit `TRow` to claim a wider shape (e.g. when using
596
- * `with: { ... }` to embed related rows).
597
- */
598
- paginate: <TRow = InferRow<TTable>>(params: CursorParams | OffsetParams, options?: PaginateOptions) => Operation<CursorPage<TRow>> | Operation<OffsetPage<TRow>>;
599
- };
600
- /**
601
- * Builder for insert operations on a single table.
602
- *
603
- * Returned by `db.insert(table)`. Chain `.values()` to set the row data,
604
- * then optionally `.returning()` to get the inserted row back.
605
- *
606
- * @example
607
- * ```ts
608
- * // Insert without returning
609
- * await db.insert(posts).values({ title: "Hello", authorId: user.id }).run();
610
- *
611
- * // Insert with returning
612
- * const row = await db.insert(posts)
613
- * .values({ title: "Hello", authorId: user.id })
614
- * .returning()
615
- * .run();
616
- * ```
617
- */
618
- type InsertBuilder<TTable extends DrizzleTable = DrizzleTable> = {
619
- /** Specifies the column values to insert, returning an {@link InsertReturningBuilder}. */
620
- values: (values: Record<string, unknown>) => InsertReturningBuilder<TTable>;
621
- };
622
- /**
623
- * An insert {@link Operation} that optionally returns the inserted row via `.returning()`.
624
- *
625
- * Without `.returning()`, the operation resolves to `void`. With `.returning()`,
626
- * it resolves to the full inserted row, typed as `InferRow<TTable>`.
627
- */
628
- type InsertReturningBuilder<TTable extends DrizzleTable = DrizzleTable> = Operation<void> & {
629
- /** Chains `.returning()` to get the inserted row back from D1. */
630
- returning: () => Operation<InferRow<TTable>>;
631
- };
632
- /**
633
- * Builder for update operations on a single table.
634
- *
635
- * Returned by `db.update(table)`. Chain `.set()` to specify values, then `.where()`
636
- * to add a condition, and optionally `.returning()` to get the updated row back.
637
- *
638
- * @example
639
- * ```ts
640
- * await db.update(posts)
641
- * .set({ published: true })
642
- * .where(eq(posts.id, "abc-123"))
643
- * .run();
644
- * ```
645
- */
646
- type UpdateBuilder<TTable extends DrizzleTable = DrizzleTable> = {
647
- /** Specifies the column values to update, returning an {@link UpdateWhereBuilder}. */
648
- set: (values: Record<string, unknown>) => UpdateWhereBuilder<TTable>;
649
- };
650
- /**
651
- * Intermediate builder requiring a WHERE condition before the update can execute.
652
- *
653
- * The WHERE condition is AND'd with any permission-based WHERE clauses from the user's grants.
654
- */
655
- type UpdateWhereBuilder<TTable extends DrizzleTable = DrizzleTable> = {
656
- /** Specifies the WHERE condition (AND'd with permission filters at `.run()` time). */
657
- where: (condition: unknown) => UpdateReturningBuilder<TTable>;
658
- };
659
- /**
660
- * An update {@link Operation} that optionally returns the updated row via `.returning()`.
661
- *
662
- * Without `.returning()`, the operation resolves to `void`. With `.returning()`,
663
- * it resolves to the full updated row, typed as `InferRow<TTable>`.
664
- */
665
- type UpdateReturningBuilder<TTable extends DrizzleTable = DrizzleTable> = Operation<void> & {
666
- /** Chains `.returning()` to get the updated row back from D1. */
667
- returning: () => Operation<InferRow<TTable>>;
668
- };
669
- /**
670
- * Builder for delete operations on a single table.
671
- *
672
- * Returned by `db.delete(table)`. Chain `.where()` to add a condition,
673
- * and optionally `.returning()` to get the deleted row back.
674
- *
675
- * @example
676
- * ```ts
677
- * await db.delete(posts).where(eq(posts.id, "abc-123")).run();
678
- * ```
679
- */
680
- type DeleteBuilder<TTable extends DrizzleTable = DrizzleTable> = {
681
- /** Specifies the WHERE condition (AND'd with permission filters at `.run()` time). */
682
- where: (condition: unknown) => DeleteReturningBuilder<TTable>;
683
- };
684
- /**
685
- * A delete {@link Operation} that optionally returns the deleted row via `.returning()`.
686
- *
687
- * Without `.returning()`, the operation resolves to `void`. With `.returning()`,
688
- * it resolves to the full deleted row, typed as `InferRow<TTable>`.
689
- */
690
- type DeleteReturningBuilder<TTable extends DrizzleTable = DrizzleTable> = Operation<void> & {
691
- /** Chains `.returning()` to get the deleted row back from D1. */
692
- returning: () => Operation<InferRow<TTable>>;
693
- };
1
+ import { Grant, PermissionDescriptor } from '@cfast/permissions';
2
+ import { D as DbConfig, a as Db, O as Operation, C as CursorParams, b as OffsetParams } from './types-FUFR36h1.js';
3
+ export { c as CacheBackend, d as CacheConfig, e as CursorPage, f as DeleteBuilder, g as DeleteReturningBuilder, F as FindFirstOptions, h as FindManyOptions, I as InferQueryResult, i as InferRow, j as InsertBuilder, k as InsertReturningBuilder, l as OffsetPage, P as PaginateOptions, m as PaginateParams, Q as QueryBuilder, n as QueryCacheOptions, T as TransactionResult, o as Tx, U as UpdateBuilder, p as UpdateReturningBuilder, q as UpdateWhereBuilder, W as WithCan } from './types-FUFR36h1.js';
4
+ import 'drizzle-orm';
694
5
 
695
6
  /**
696
7
  * Creates a permission-aware database instance bound to the given user.
@@ -719,7 +30,7 @@ type DeleteReturningBuilder<TTable extends DrizzleTable = DrizzleTable> = Operat
719
30
  * const posts = await db.query(postsTable).findMany().run();
720
31
  * ```
721
32
  */
722
- declare function createDb(config: DbConfig): Db;
33
+ declare function createDb<TSchema extends Record<string, unknown>>(config: DbConfig<TSchema>): Db<TSchema>;
723
34
  /**
724
35
  * App-level db factory configuration. The "constants" — D1 binding,
725
36
  * Drizzle schema, cache config — that don't change between requests.
@@ -727,7 +38,7 @@ declare function createDb(config: DbConfig): Db;
727
38
  * Returned by {@link createAppDb}, called per-request with the user's
728
39
  * resolved grants and identity to produce a fresh permission-aware {@link Db}.
729
40
  */
730
- type AppDbConfig = {
41
+ type AppDbConfig<TSchema extends Record<string, unknown> = Record<string, unknown>> = {
731
42
  /**
732
43
  * The Cloudflare D1 database binding. Pass either the binding directly
733
44
  * (for tests, where the mock D1 is in scope at module load) or a
@@ -743,7 +54,7 @@ type AppDbConfig = {
743
54
  * Drizzle schema. Same shape as {@link DbConfig.schema} -- pass
744
55
  * `import * as schema from "./schema"`.
745
56
  */
746
- schema: Record<string, unknown>;
57
+ schema: TSchema;
747
58
  /**
748
59
  * Cache configuration shared across every per-request `Db`. Defaults to
749
60
  * `{ backend: "cache-api" }` to match `createDb`. Pass `false` to opt out.
@@ -759,9 +70,9 @@ type AppDbConfig = {
759
70
  * `@cfast/core`'s `dbPlugin`, route loaders) can pass the factory around
760
71
  * without re-deriving its signature.
761
72
  */
762
- type AppDbFactory = (grants: Grant[], user: {
73
+ type AppDbFactory<TSchema extends Record<string, unknown> = Record<string, never>> = (grants: Grant[], user: {
763
74
  id: string;
764
- } | null) => Db;
75
+ } | null) => Db<TSchema>;
765
76
  /**
766
77
  * Consolidates the three near-identical `createDb` factories that every
767
78
  * cfast app used to define (#149):
@@ -811,7 +122,7 @@ type AppDbFactory = (grants: Grant[], user: {
811
122
  * };
812
123
  * ```
813
124
  */
814
- declare function createAppDb(config: AppDbConfig): AppDbFactory;
125
+ declare function createAppDb<TSchema extends Record<string, unknown>>(config: AppDbConfig<TSchema>): AppDbFactory<TSchema>;
815
126
 
816
127
  /**
817
128
  * A function that executes a single sub-operation within a {@link compose} executor.
@@ -1022,115 +333,57 @@ declare function parseCursorParams(request: Request, options?: PaginationOptions
1022
333
  */
1023
334
  declare function parseOffsetParams(request: Request, options?: PaginationOptions): OffsetParams;
1024
335
 
1025
- /**
1026
- * Error thrown when a transaction is misused (e.g. an op is recorded after the
1027
- * transaction has already committed or aborted).
1028
- */
1029
336
  declare class TransactionError extends Error {
1030
337
  constructor(message: string);
1031
338
  }
1032
339
 
1033
340
  /**
1034
- * A single seed entry — every row in `rows` is inserted into `table` at seed
1035
- * time. Row shape is inferred from the Drizzle table so typos in column names
1036
- * are caught by `tsc` instead of failing at runtime when `INSERT` rejects
1037
- * the statement.
341
+ * Recursively converts Date fields in a value to ISO 8601 strings for
342
+ * JSON serialization.
1038
343
  *
1039
- * @typeParam TTable - The Drizzle table reference (e.g. `typeof usersTable`).
1040
- */
1041
- type SeedEntry<TTable extends DrizzleTable = DrizzleTable> = {
1042
- /**
1043
- * The Drizzle table to insert into. Must be imported from your schema
1044
- * (`import { users } from "~/db/schema"`) rather than passed as a string
1045
- * so the helper can infer row types and forward the reference to
1046
- * `db.insert()`.
1047
- */
1048
- table: TTable;
1049
- /**
1050
- * Rows to insert. The row shape is inferred from the table's
1051
- * `$inferSelect` — making a typo in a column name is a compile-time error.
1052
- *
1053
- * Entries are inserted in the order they appear, which lets you control
1054
- * foreign-key ordering just by ordering your `entries` array
1055
- * (`{ users }` before `{ posts }`, etc.).
1056
- */
1057
- rows: readonly InferRow<TTable>[];
1058
- };
1059
- /**
1060
- * Configuration passed to {@link defineSeed}.
1061
- */
1062
- type SeedConfig = {
1063
- /**
1064
- * Ordered list of seed entries. Each entry is flushed as a batched insert
1065
- * in list order, so place parent tables (users, orgs) before child tables
1066
- * (posts, memberships) that reference them via foreign keys.
1067
- */
1068
- entries: readonly SeedEntry[];
1069
- };
1070
- /**
1071
- * The compiled seed returned by {@link defineSeed}.
344
+ * React Router loaders must return JSON-serializable data. Date objects
345
+ * are not JSON-serializable by default -- `JSON.stringify(new Date())`
346
+ * calls `Date.prototype.toJSON()` which produces an ISO string, but the
347
+ * resulting value on the client is a string, not a Date. This helper
348
+ * makes the conversion explicit and type-safe so every loader doesn't
349
+ * need to manually call `.toISOString()` on every date field.
1072
350
  *
1073
- * Holds a frozen copy of the entry list so runner callers can introspect
1074
- * what would be seeded, plus a `run(db)` method that actually executes the
1075
- * inserts against a real {@link Db} instance.
1076
- */
1077
- type Seed = {
1078
- /** The frozen list of entries this seed will insert, in order. */
1079
- readonly entries: readonly SeedEntry[];
1080
- /**
1081
- * Executes every entry against the given {@link Db} in the order they were
1082
- * declared. Uses `db.unsafe()` internally so seed scripts don't need
1083
- * their own grants plumbing — seeding is a system task by definition.
1084
- *
1085
- * Entries with an empty `rows` array are skipped so callers can leave
1086
- * placeholder entries in the config without crashing the seed.
1087
- *
1088
- * @param db - A {@link Db} instance, typically created once at the top
1089
- * of a `scripts/seed.ts` file via `createDb({...})`.
1090
- */
1091
- run: (db: Db) => Promise<void>;
1092
- };
1093
- /**
1094
- * Declares a database seed in a portable, type-safe way.
351
+ * Works on:
352
+ * - Plain objects (shallow and nested)
353
+ * - Arrays of objects
354
+ * - Single Date values
355
+ * - Nested arrays and objects of arbitrary depth
1095
356
  *
1096
- * The canonical replacement for hand-rolled `scripts/seed.ts` files that
1097
- * called `db.mutate("tablename")` (a method that never existed) or reached
1098
- * straight into raw Drizzle. Use the scaffolded `scripts/seed.ts` in a
1099
- * `create-cfast` project for a ready-made example.
357
+ * Non-Date primitives (string, number, boolean, null, undefined) pass
358
+ * through unchanged. The function is a no-op for values that don't
359
+ * contain Date instances.
360
+ *
361
+ * @param value - The value to convert. Typically a query result row or
362
+ * array of rows from `db.query(...).findMany().run()`.
363
+ * @returns A new value with every Date replaced by its ISO string.
1100
364
  *
1101
365
  * @example
1102
366
  * ```ts
1103
- * // scripts/seed.ts
1104
- * import { defineSeed, createDb } from "@cfast/db";
1105
- * import * as schema from "~/db/schema";
1106
- *
1107
- * const seed = defineSeed({
1108
- * entries: [
1109
- * {
1110
- * table: schema.users,
1111
- * rows: [
1112
- * { id: "u-1", email: "ada@example.com", name: "Ada" },
1113
- * { id: "u-2", email: "grace@example.com", name: "Grace" },
1114
- * ],
1115
- * },
1116
- * {
1117
- * table: schema.posts,
1118
- * rows: [
1119
- * { id: "p-1", authorId: "u-1", title: "Hello" },
1120
- * ],
1121
- * },
1122
- * ],
1123
- * });
367
+ * import { toJSON } from "@cfast/db";
1124
368
  *
1125
- * // In a worker/runner that already has a real D1 binding:
1126
- * const db = createDb({ d1, schema, grants: [], user: null });
1127
- * await seed.run(db);
369
+ * export async function loader({ context }) {
370
+ * const db = createDb({ ... });
371
+ * const posts = await db.query(postsTable).findMany().run();
372
+ * return { posts: toJSON(posts) };
373
+ * }
1128
374
  * ```
375
+ */
376
+ declare function toJSON<T>(value: T): DateToString<T>;
377
+ /**
378
+ * Type-level utility that converts Date fields to string in a type.
1129
379
  *
1130
- * @param config - The {@link SeedConfig} with the ordered list of entries.
1131
- * @returns A {@link Seed} with a `.run(db)` executor.
380
+ * Maps `{ createdAt: Date; title: string }` to
381
+ * `{ createdAt: string; title: string }` so the return type of
382
+ * `toJSON(row)` accurately reflects the runtime shape.
1132
383
  */
1133
- declare function defineSeed(config: SeedConfig): Seed;
384
+ type DateToString<T> = T extends Date ? string : T extends Array<infer U> ? DateToString<U>[] : T extends Record<string, unknown> ? {
385
+ [K in keyof T]: DateToString<T[K]>;
386
+ } : T;
1134
387
 
1135
388
  /**
1136
389
  * Per-request cache that holds the resolved `with` lookup map for each grant.
@@ -1180,4 +433,4 @@ declare function createLookupCache(): LookupCache;
1180
433
  */
1181
434
  declare function runWithLookupCache<T>(fn: () => T, cache?: LookupCache): T;
1182
435
 
1183
- 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 };
436
+ export { type AppDbConfig, type AppDbFactory, CursorParams, type DateToString, Db, DbConfig, type LookupCache, OffsetParams, Operation, TransactionError, compose, composeSequential, composeSequentialCallback, createAppDb, createDb, createLookupCache, parseCursorParams, parseOffsetParams, runWithLookupCache, toJSON };