@cfast/db 0.3.0 → 0.4.1

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,4 +1,4 @@
1
- import { DrizzleTable, PermissionDescriptor, Grant } from '@cfast/permissions';
1
+ import { Grant, DrizzleTable, PermissionDescriptor } from '@cfast/permissions';
2
2
 
3
3
  /**
4
4
  * Extracts the row type from a Drizzle table reference.
@@ -33,7 +33,7 @@ type InferRow<TTable> = TTable extends {
33
33
  * // => [{ action: "read", table: "posts" }]
34
34
  *
35
35
  * // Execute with permission checks
36
- * const rows = await op.run({});
36
+ * const rows = await op.run();
37
37
  * ```
38
38
  */
39
39
  type Operation<TResult> = {
@@ -43,7 +43,8 @@ type Operation<TResult> = {
43
43
  * Checks permissions, applies permission WHERE clauses, executes the query via Drizzle,
44
44
  * and returns the result. Throws `ForbiddenError` if the user's role lacks a required grant.
45
45
  *
46
- * @param params - Placeholder values for `sql.placeholder()` calls. Pass `{}` when no placeholders are used.
46
+ * @param params - Optional placeholder values for `sql.placeholder()` calls.
47
+ * Omit when your query does not use placeholders — the default is `{}`.
47
48
  */
48
49
  run: (params?: Record<string, unknown>) => Promise<TResult>;
49
50
  };
@@ -222,7 +223,7 @@ type FindFirstOptions = Omit<FindManyOptions, "limit" | "offset">;
222
223
  * @example
223
224
  * ```ts
224
225
  * const params = parseCursorParams(request, { defaultLimit: 20 });
225
- * const page = await db.query(posts).paginate(params).run({});
226
+ * const page = await db.query(posts).paginate(params).run();
226
227
  * ```
227
228
  */
228
229
  type CursorParams = {
@@ -242,7 +243,7 @@ type CursorParams = {
242
243
  * @example
243
244
  * ```ts
244
245
  * const params = parseOffsetParams(request, { defaultLimit: 20 });
245
- * const page = await db.query(posts).paginate(params).run({});
246
+ * const page = await db.query(posts).paginate(params).run();
246
247
  * ```
247
248
  */
248
249
  type OffsetParams = {
@@ -271,7 +272,7 @@ type PaginateParams = CursorParams | OffsetParams;
271
272
  * ```ts
272
273
  * const page: CursorPage<Post> = await db.query(posts)
273
274
  * .paginate({ type: "cursor", cursor: null, limit: 20 })
274
- * .run({});
275
+ * .run();
275
276
  *
276
277
  * if (page.nextCursor) {
277
278
  * // Fetch next page with page.nextCursor
@@ -295,7 +296,7 @@ type CursorPage<T> = {
295
296
  * ```ts
296
297
  * const page: OffsetPage<Post> = await db.query(posts)
297
298
  * .paginate({ type: "offset", page: 1, limit: 20 })
298
- * .run({});
299
+ * .run();
299
300
  *
300
301
  * console.log(`Page ${page.page} of ${page.totalPages} (${page.total} total)`);
301
302
  * ```
@@ -347,6 +348,54 @@ type PaginateOptions = {
347
348
  /** Per-query cache control. Pass `false` to skip caching, or an object to customize. */
348
349
  cache?: QueryCacheOptions;
349
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
+ };
350
399
  /**
351
400
  * A permission-aware database instance bound to a specific user.
352
401
  *
@@ -357,13 +406,13 @@ type PaginateOptions = {
357
406
  * @example
358
407
  * ```ts
359
408
  * // Read
360
- * const posts = await db.query(postsTable).findMany().run({});
409
+ * const posts = await db.query(postsTable).findMany().run();
361
410
  *
362
411
  * // Write
363
- * await db.insert(postsTable).values({ title: "Hello" }).run({});
412
+ * await db.insert(postsTable).values({ title: "Hello" }).run();
364
413
  *
365
414
  * // Bypass permissions for system tasks
366
- * await db.unsafe().delete(sessionsTable).where(expired).run({});
415
+ * await db.unsafe().delete(sessionsTable).where(expired).run();
367
416
  * ```
368
417
  */
369
418
  type Db = {
@@ -405,6 +454,44 @@ type Db = {
405
454
  * compositions but loses the atomicity guarantee.
406
455
  */
407
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>;
408
495
  /** Cache control methods for manual invalidation. */
409
496
  cache: {
410
497
  /** Invalidate cached queries by tag names and/or table names. */
@@ -415,6 +502,29 @@ type Db = {
415
502
  tables?: string[];
416
503
  }) => Promise<void>;
417
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;
418
528
  };
419
529
  /**
420
530
  * Builder for read queries on a single table.
@@ -427,13 +537,13 @@ type Db = {
427
537
  * const builder = db.query(posts);
428
538
  *
429
539
  * // Fetch all visible posts
430
- * const all = await builder.findMany().run({});
540
+ * const all = await builder.findMany().run();
431
541
  *
432
542
  * // Fetch a single post
433
- * const post = await builder.findFirst({ where: eq(posts.id, id) }).run({});
543
+ * const post = await builder.findFirst({ where: eq(posts.id, id) }).run();
434
544
  *
435
545
  * // Paginate
436
- * const page = await builder.paginate(params, { orderBy: desc(posts.createdAt) }).run({});
546
+ * const page = await builder.paginate(params, { orderBy: desc(posts.createdAt) }).run();
437
547
  * ```
438
548
  */
439
549
  type QueryBuilder<TTable extends DrizzleTable = DrizzleTable> = {
@@ -442,20 +552,50 @@ type QueryBuilder<TTable extends DrizzleTable = DrizzleTable> = {
442
552
  *
443
553
  * The result is typed via `InferRow<TTable>`, so callers get IntelliSense on
444
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
+ * ```
445
571
  */
446
- findMany: (options?: FindManyOptions) => Operation<InferRow<TTable>[]>;
572
+ findMany: <TRow = InferRow<TTable>>(options?: FindManyOptions) => Operation<TRow[]>;
447
573
  /**
448
574
  * Returns an {@link Operation} that fetches the first matching row, or `undefined`
449
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
+ * ```
450
588
  */
451
- findFirst: (options?: FindFirstOptions) => Operation<InferRow<TTable> | undefined>;
589
+ findFirst: <TRow = InferRow<TTable>>(options?: FindFirstOptions) => Operation<TRow | undefined>;
452
590
  /**
453
591
  * Returns a paginated {@link Operation} using either cursor-based or offset-based strategy.
454
592
  *
455
593
  * The return type depends on the `params.type` discriminant: {@link CursorPage} for `"cursor"`,
456
- * {@link OffsetPage} for `"offset"`. Each page's items are typed as `InferRow<TTable>`.
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).
457
597
  */
458
- paginate: (params: CursorParams | OffsetParams, options?: PaginateOptions) => Operation<CursorPage<InferRow<TTable>>> | Operation<OffsetPage<InferRow<TTable>>>;
598
+ paginate: <TRow = InferRow<TTable>>(params: CursorParams | OffsetParams, options?: PaginateOptions) => Operation<CursorPage<TRow>> | Operation<OffsetPage<TRow>>;
459
599
  };
460
600
  /**
461
601
  * Builder for insert operations on a single table.
@@ -466,13 +606,13 @@ type QueryBuilder<TTable extends DrizzleTable = DrizzleTable> = {
466
606
  * @example
467
607
  * ```ts
468
608
  * // Insert without returning
469
- * await db.insert(posts).values({ title: "Hello", authorId: user.id }).run({});
609
+ * await db.insert(posts).values({ title: "Hello", authorId: user.id }).run();
470
610
  *
471
611
  * // Insert with returning
472
612
  * const row = await db.insert(posts)
473
613
  * .values({ title: "Hello", authorId: user.id })
474
614
  * .returning()
475
- * .run({});
615
+ * .run();
476
616
  * ```
477
617
  */
478
618
  type InsertBuilder<TTable extends DrizzleTable = DrizzleTable> = {
@@ -500,7 +640,7 @@ type InsertReturningBuilder<TTable extends DrizzleTable = DrizzleTable> = Operat
500
640
  * await db.update(posts)
501
641
  * .set({ published: true })
502
642
  * .where(eq(posts.id, "abc-123"))
503
- * .run({});
643
+ * .run();
504
644
  * ```
505
645
  */
506
646
  type UpdateBuilder<TTable extends DrizzleTable = DrizzleTable> = {
@@ -534,7 +674,7 @@ type UpdateReturningBuilder<TTable extends DrizzleTable = DrizzleTable> = Operat
534
674
  *
535
675
  * @example
536
676
  * ```ts
537
- * await db.delete(posts).where(eq(posts.id, "abc-123")).run({});
677
+ * await db.delete(posts).where(eq(posts.id, "abc-123")).run();
538
678
  * ```
539
679
  */
540
680
  type DeleteBuilder<TTable extends DrizzleTable = DrizzleTable> = {
@@ -576,10 +716,102 @@ type DeleteReturningBuilder<TTable extends DrizzleTable = DrizzleTable> = Operat
576
716
  * });
577
717
  *
578
718
  * // All operations check permissions at .run() time
579
- * const posts = await db.query(postsTable).findMany().run({});
719
+ * const posts = await db.query(postsTable).findMany().run();
580
720
  * ```
581
721
  */
582
722
  declare function createDb(config: DbConfig): Db;
723
+ /**
724
+ * App-level db factory configuration. The "constants" — D1 binding,
725
+ * Drizzle schema, cache config — that don't change between requests.
726
+ *
727
+ * Returned by {@link createAppDb}, called per-request with the user's
728
+ * resolved grants and identity to produce a fresh permission-aware {@link Db}.
729
+ */
730
+ type AppDbConfig = {
731
+ /**
732
+ * The Cloudflare D1 database binding. Pass either the binding directly
733
+ * (for tests, where the mock D1 is in scope at module load) or a
734
+ * `() => D1Database` getter (for Workers, where `env.DB` is only
735
+ * available per-request -- typical pattern: `() => env.get().DB`).
736
+ *
737
+ * The lazy form lets `createAppDb()` be called once at module-load time
738
+ * even though the binding itself isn't materialized until the first
739
+ * request, so the entire app shares a single factory definition.
740
+ */
741
+ d1: D1Database | (() => D1Database);
742
+ /**
743
+ * Drizzle schema. Same shape as {@link DbConfig.schema} -- pass
744
+ * `import * as schema from "./schema"`.
745
+ */
746
+ schema: Record<string, unknown>;
747
+ /**
748
+ * Cache configuration shared across every per-request `Db`. Defaults to
749
+ * `{ backend: "cache-api" }` to match `createDb`. Pass `false` to opt out.
750
+ */
751
+ cache?: DbConfig["cache"];
752
+ };
753
+ /**
754
+ * The per-request factory returned by {@link createAppDb}. Closes over the
755
+ * app-level config and accepts the request-scoped grants + user, returning a
756
+ * fresh permission-aware {@link Db}.
757
+ *
758
+ * Stable type so callers (e.g. `@cfast/admin`'s `createDbForAdmin`,
759
+ * `@cfast/core`'s `dbPlugin`, route loaders) can pass the factory around
760
+ * without re-deriving its signature.
761
+ */
762
+ type AppDbFactory = (grants: Grant[], user: {
763
+ id: string;
764
+ } | null) => Db;
765
+ /**
766
+ * Consolidates the three near-identical `createDb` factories that every
767
+ * cfast app used to define (#149):
768
+ *
769
+ * - `app/cfast.server.ts` (`dbPlugin` setup)
770
+ * - `app/admin.server.ts` (`createDbForAdmin`)
771
+ * - any route handler that built `Db` ad-hoc from `env.DB`
772
+ *
773
+ * Splits the configuration into "app constants" (D1 binding, schema, cache)
774
+ * and "per-request inputs" (grants, user). The returned factory captures the
775
+ * constants once at module load and accepts the request-scoped inputs at
776
+ * call time.
777
+ *
778
+ * Use this whenever the same shape of `createDb({ d1, schema, grants, user, cache })`
779
+ * shows up in more than one file in your app. The factory is a stable
780
+ * `(grants, user) => Db` callable that plugs directly into:
781
+ *
782
+ * - `@cfast/core`'s db plugin: `setup(ctx) { return { client: appDb(ctx.auth.grants, ctx.auth.user) } }`
783
+ * - `@cfast/admin`'s `db: createDbForAdmin` config: `db: appDb`
784
+ * - any route handler: `const db = appDb(ctx.grants, ctx.user)`
785
+ *
786
+ * @example
787
+ * ```ts
788
+ * // app/db/factory.ts -- defined once
789
+ * import { createAppDb } from "@cfast/db";
790
+ * import * as schema from "./schema";
791
+ *
792
+ * export const appDb = createAppDb({
793
+ * d1: env.get().DB,
794
+ * schema,
795
+ * cache: false,
796
+ * });
797
+ *
798
+ * // app/cfast.server.ts -- consume from the plugin
799
+ * const dbPlugin = definePlugin({
800
+ * name: "db",
801
+ * requires: [authPlugin],
802
+ * setup(ctx) {
803
+ * return { client: appDb(ctx.auth.grants, ctx.auth.user) };
804
+ * },
805
+ * });
806
+ *
807
+ * // app/admin.server.ts -- consume from admin
808
+ * const adminConfig = {
809
+ * db: appDb, // (grants, user) => Db -- already the right shape
810
+ * ...
811
+ * };
812
+ * ```
813
+ */
814
+ declare function createAppDb(config: AppDbConfig): AppDbFactory;
583
815
 
584
816
  /**
585
817
  * A function that executes a single sub-operation within a {@link compose} executor.
@@ -596,10 +828,51 @@ type RunFn = (params?: Record<string, unknown>) => Promise<unknown>;
596
828
  * `.run()` still performs its own permission check when the executor calls it. This enables
597
829
  * data dependencies between operations (e.g., using an insert result's ID in an audit log).
598
830
  *
831
+ * ### ⚠️ Positional callback footgun
832
+ *
833
+ * The `executor` callback receives one `run` function per entry in `operations`,
834
+ * **in array order**. TypeScript cannot enforce that the parameter names line
835
+ * up with the operations they correspond to, so a callback that swaps
836
+ * `(runUpdate, runVersion)` for `(runVersion, runUpdate)` -- or that simply
837
+ * forgets a parameter -- silently invokes the wrong sub-operation. The wiki
838
+ * tracking issue (#182) was a real bug of this shape: a `runVersion` parameter
839
+ * shadowed an outer-scope variable and was never invoked.
840
+ *
841
+ * **Prefer {@link composeSequentialCallback} for any workflow with data
842
+ * dependencies.** It accepts an `async (db) => ...` callback that uses the
843
+ * normal db builders by reference, so the binding is by name (not by
844
+ * position) and there is no way to wire the wrong op to the wrong handler:
845
+ *
846
+ * ```ts
847
+ * // ✗ Footgun: parameter order must match array order, not type-checked.
848
+ * const op = compose(
849
+ * [updatePost, bumpVersion],
850
+ * async (runUpdate, runVersion) => {
851
+ * await runUpdate(); // What if `runVersion` was renamed and never called?
852
+ * await runVersion();
853
+ * },
854
+ * );
855
+ *
856
+ * // ✓ Preferred: by-name binding via composeSequentialCallback.
857
+ * const op = composeSequentialCallback(db, async (tx) => {
858
+ * await tx.update(posts).set({ ... }).where(...).run();
859
+ * await tx.update(postVersions).set({ ... }).where(...).run();
860
+ * });
861
+ * ```
862
+ *
863
+ * Use `compose()` only when you genuinely need to interleave non-db logic
864
+ * between sub-operations and you have a small, fixed number of ops where the
865
+ * footgun is easy to read around. For everything else, reach for
866
+ * {@link composeSequential} (no callback at all) or
867
+ * {@link composeSequentialCallback} (by-name binding).
868
+ *
599
869
  * @typeParam TResult - The return type of the executor function.
600
870
  * @param operations - The operations to compose. Their permissions are merged and deduplicated.
601
871
  * @param executor - A function that receives a `run` function for each operation (in order).
602
872
  * You control execution order, data flow between operations, and the return value.
873
+ * **Must declare exactly one parameter per operation** -- TypeScript cannot enforce this,
874
+ * but a regression test in this package asserts that swapping the count surfaces undefined
875
+ * `run` calls at runtime.
603
876
  * @returns A single {@link Operation} with combined permissions.
604
877
  *
605
878
  * @example
@@ -609,8 +882,8 @@ type RunFn = (params?: Record<string, unknown>) => Promise<unknown>;
609
882
  * const publishWorkflow = compose(
610
883
  * [updatePost, insertAuditLog],
611
884
  * async (doUpdate, doAudit) => {
612
- * const updated = await doUpdate({});
613
- * await doAudit({});
885
+ * const updated = await doUpdate();
886
+ * await doAudit();
614
887
  * return { published: true };
615
888
  * },
616
889
  * );
@@ -620,7 +893,7 @@ type RunFn = (params?: Record<string, unknown>) => Promise<unknown>;
620
893
  * // => [{ action: "update", table: "posts" }, { action: "create", table: "audit_logs" }]
621
894
  *
622
895
  * // Execute all sub-operations
623
- * await publishWorkflow.run({});
896
+ * await publishWorkflow.run();
624
897
  * ```
625
898
  */
626
899
  declare function compose<TResult>(operations: Operation<unknown>[], executor: (...runs: RunFn[]) => TResult | Promise<TResult>): Operation<TResult>;
@@ -749,4 +1022,162 @@ declare function parseCursorParams(request: Request, options?: PaginationOptions
749
1022
  */
750
1023
  declare function parseOffsetParams(request: Request, options?: PaginationOptions): OffsetParams;
751
1024
 
752
- export { 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 OffsetPage, type OffsetParams, type Operation, type PaginateOptions, type PaginateParams, type QueryBuilder, type QueryCacheOptions, type UpdateBuilder, type UpdateReturningBuilder, type UpdateWhereBuilder, compose, composeSequential, composeSequentialCallback, createDb, parseCursorParams, parseOffsetParams };
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
+ declare class TransactionError extends Error {
1030
+ constructor(message: string);
1031
+ }
1032
+
1033
+ /**
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.
1038
+ *
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}.
1072
+ *
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.
1095
+ *
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.
1100
+ *
1101
+ * @example
1102
+ * ```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
+ * });
1124
+ *
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);
1128
+ * ```
1129
+ *
1130
+ * @param config - The {@link SeedConfig} with the ordered list of entries.
1131
+ * @returns A {@link Seed} with a `.run(db)` executor.
1132
+ */
1133
+ declare function defineSeed(config: SeedConfig): Seed;
1134
+
1135
+ /**
1136
+ * Per-request cache that holds the resolved `with` lookup map for each grant.
1137
+ *
1138
+ * Keyed by grant object identity so two queries that consult the same grant
1139
+ * within a single request reuse the same lookup promise (each underlying
1140
+ * `LookupFn` runs at most once).
1141
+ *
1142
+ * The cache's lifetime is tied to the surrounding async context rather than
1143
+ * to any specific `Db` instance: `createDb()` calls transparently run inside
1144
+ * an {@link AsyncLocalStorage} scope, and nested operations resolve their
1145
+ * lookups through the active scope's cache. Tests (or anything sharing a
1146
+ * single `Db` across logical requests) can scope an explicit cache via
1147
+ * {@link runWithLookupCache}.
1148
+ */
1149
+ type LookupCache = Map<Grant, Promise<Record<string, unknown>>>;
1150
+ /**
1151
+ * Creates a fresh per-request lookup cache. Exposed for tests and for the
1152
+ * `Db` wrapper that establishes the ALS scope; normal framework consumers
1153
+ * should rely on {@link runWithLookupCache} or the automatic scope that
1154
+ * `createDb()` sets up on every invocation.
1155
+ */
1156
+ declare function createLookupCache(): LookupCache;
1157
+ /**
1158
+ * Runs `fn` inside a fresh per-request lookup cache scope.
1159
+ *
1160
+ * Use this when you need to reuse a single `Db` across multiple logical
1161
+ * requests (e.g. in tests that insert new grants mid-run) and want each
1162
+ * logical request to see a clean cache. All nested `Db` operations inside
1163
+ * `fn` (and any async work they start) share the same cache.
1164
+ *
1165
+ * @param fn - The callback to run inside the scope. Its return value is
1166
+ * forwarded back to the caller.
1167
+ * @param cache - Optional pre-existing cache to install. Defaults to a fresh
1168
+ * empty cache.
1169
+ *
1170
+ * @example
1171
+ * ```ts
1172
+ * // Test setup reusing a single Db:
1173
+ * const db = createDb({ ... });
1174
+ *
1175
+ * await runWithLookupCache(async () => {
1176
+ * await db.insert(friendGrants).values(...).run();
1177
+ * await db.query(posts).findMany().run(); // sees the new grant
1178
+ * });
1179
+ * ```
1180
+ */
1181
+ declare function runWithLookupCache<T>(fn: () => T, cache?: LookupCache): T;
1182
+
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 };