@cfast/db 0.1.1 → 0.2.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,5 +1,19 @@
1
1
  import { DrizzleTable, PermissionDescriptor, Grant } from '@cfast/permissions';
2
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>;
3
17
  /**
4
18
  * A lazy, permission-aware database operation.
5
19
  *
@@ -126,8 +140,13 @@ type DbConfig = {
126
140
  /**
127
141
  * Drizzle schema object. Must be `import * as schema` so that keys match
128
142
  * table variable names (required by Drizzle's relational query API).
143
+ *
144
+ * Typed as `Record<string, unknown>` so callers can pass `import * as schema`
145
+ * directly without casting -- Drizzle schemas typically include `Relations`
146
+ * exports alongside tables, and the `@cfast/db` runtime ignores any non-table
147
+ * entries when looking up tables by key.
129
148
  */
130
- schema: Record<string, DrizzleTable>;
149
+ schema: Record<string, unknown>;
131
150
  /** Resolved permission grants for the current user's role, from `resolveGrants()`. */
132
151
  grants: Grant[];
133
152
  /**
@@ -348,14 +367,19 @@ type PaginateOptions = {
348
367
  * ```
349
368
  */
350
369
  type Db = {
351
- /** Creates a {@link QueryBuilder} for reading rows from the given table. */
352
- query: (table: DrizzleTable) => QueryBuilder;
370
+ /**
371
+ * Creates a {@link QueryBuilder} for reading rows from the given table.
372
+ *
373
+ * The builder is generic over `TTable`, so `findMany`/`findFirst` return rows
374
+ * typed via `InferRow<TTable>` -- callers don't need to cast to `as any`.
375
+ */
376
+ query: <TTable extends DrizzleTable>(table: TTable) => QueryBuilder<TTable>;
353
377
  /** Creates an {@link InsertBuilder} for inserting rows into the given table. */
354
- insert: (table: DrizzleTable) => InsertBuilder;
378
+ insert: <TTable extends DrizzleTable>(table: TTable) => InsertBuilder<TTable>;
355
379
  /** Creates an {@link UpdateBuilder} for updating rows in the given table. */
356
- update: (table: DrizzleTable) => UpdateBuilder;
380
+ update: <TTable extends DrizzleTable>(table: TTable) => UpdateBuilder<TTable>;
357
381
  /** Creates a {@link DeleteBuilder} for deleting rows from the given table. */
358
- delete: (table: DrizzleTable) => DeleteBuilder;
382
+ delete: <TTable extends DrizzleTable>(table: TTable) => DeleteBuilder<TTable>;
359
383
  /**
360
384
  * Returns a new `Db` instance that skips all permission checks.
361
385
  *
@@ -365,7 +389,20 @@ type Db = {
365
389
  unsafe: () => Db;
366
390
  /**
367
391
  * Groups multiple operations into a single {@link Operation} with merged, deduplicated permissions.
368
- * Operations are executed sequentially (not via D1 native batch).
392
+ *
393
+ * When every operation was produced by `db.insert/update/delete`, the batch is
394
+ * executed via D1's native `batch()` API, which is **atomic** -- if any
395
+ * statement fails, the entire batch is rolled back. This is the recommended
396
+ * way to perform multi-step mutations that need transactional safety, such as
397
+ * decrementing stock across multiple products during checkout.
398
+ *
399
+ * Permissions for every sub-operation are checked **upfront**: if the user
400
+ * lacks any required grant, the batch throws before any SQL is issued.
401
+ *
402
+ * Operations that don't carry the internal batchable hook (for example, ops
403
+ * produced by `compose()` executors) cause the batch to fall back to
404
+ * sequential execution. This preserves backward compatibility for non-trivial
405
+ * compositions but loses the atomicity guarantee.
369
406
  */
370
407
  batch: (operations: Operation<unknown>[]) => Operation<unknown[]>;
371
408
  /** Cache control methods for manual invalidation. */
@@ -399,18 +436,26 @@ type Db = {
399
436
  * const page = await builder.paginate(params, { orderBy: desc(posts.createdAt) }).run({});
400
437
  * ```
401
438
  */
402
- type QueryBuilder = {
403
- /** Returns an {@link Operation} that fetches multiple rows matching the given options. */
404
- findMany: (options?: FindManyOptions) => Operation<unknown[]>;
405
- /** Returns an {@link Operation} that fetches the first matching row, or `undefined` if none match. */
406
- findFirst: (options?: FindFirstOptions) => Operation<unknown | undefined>;
439
+ type QueryBuilder<TTable extends DrizzleTable = DrizzleTable> = {
440
+ /**
441
+ * Returns an {@link Operation} that fetches multiple rows matching the given options.
442
+ *
443
+ * The result is typed via `InferRow<TTable>`, so callers get IntelliSense on
444
+ * `(row) => row.title` without any cast.
445
+ */
446
+ findMany: (options?: FindManyOptions) => Operation<InferRow<TTable>[]>;
447
+ /**
448
+ * Returns an {@link Operation} that fetches the first matching row, or `undefined`
449
+ * if none match. The row type is propagated from `TTable`.
450
+ */
451
+ findFirst: (options?: FindFirstOptions) => Operation<InferRow<TTable> | undefined>;
407
452
  /**
408
453
  * Returns a paginated {@link Operation} using either cursor-based or offset-based strategy.
409
454
  *
410
455
  * The return type depends on the `params.type` discriminant: {@link CursorPage} for `"cursor"`,
411
- * {@link OffsetPage} for `"offset"`.
456
+ * {@link OffsetPage} for `"offset"`. Each page's items are typed as `InferRow<TTable>`.
412
457
  */
413
- paginate: (params: CursorParams | OffsetParams, options?: PaginateOptions) => Operation<CursorPage<unknown>> | Operation<OffsetPage<unknown>>;
458
+ paginate: (params: CursorParams | OffsetParams, options?: PaginateOptions) => Operation<CursorPage<InferRow<TTable>>> | Operation<OffsetPage<InferRow<TTable>>>;
414
459
  };
415
460
  /**
416
461
  * Builder for insert operations on a single table.
@@ -430,19 +475,19 @@ type QueryBuilder = {
430
475
  * .run({});
431
476
  * ```
432
477
  */
433
- type InsertBuilder = {
478
+ type InsertBuilder<TTable extends DrizzleTable = DrizzleTable> = {
434
479
  /** Specifies the column values to insert, returning an {@link InsertReturningBuilder}. */
435
- values: (values: Record<string, unknown>) => InsertReturningBuilder;
480
+ values: (values: Record<string, unknown>) => InsertReturningBuilder<TTable>;
436
481
  };
437
482
  /**
438
483
  * An insert {@link Operation} that optionally returns the inserted row via `.returning()`.
439
484
  *
440
485
  * Without `.returning()`, the operation resolves to `void`. With `.returning()`,
441
- * it resolves to the full inserted row.
486
+ * it resolves to the full inserted row, typed as `InferRow<TTable>`.
442
487
  */
443
- type InsertReturningBuilder = Operation<void> & {
488
+ type InsertReturningBuilder<TTable extends DrizzleTable = DrizzleTable> = Operation<void> & {
444
489
  /** Chains `.returning()` to get the inserted row back from D1. */
445
- returning: () => Operation<unknown>;
490
+ returning: () => Operation<InferRow<TTable>>;
446
491
  };
447
492
  /**
448
493
  * Builder for update operations on a single table.
@@ -458,28 +503,28 @@ type InsertReturningBuilder = Operation<void> & {
458
503
  * .run({});
459
504
  * ```
460
505
  */
461
- type UpdateBuilder = {
506
+ type UpdateBuilder<TTable extends DrizzleTable = DrizzleTable> = {
462
507
  /** Specifies the column values to update, returning an {@link UpdateWhereBuilder}. */
463
- set: (values: Record<string, unknown>) => UpdateWhereBuilder;
508
+ set: (values: Record<string, unknown>) => UpdateWhereBuilder<TTable>;
464
509
  };
465
510
  /**
466
511
  * Intermediate builder requiring a WHERE condition before the update can execute.
467
512
  *
468
513
  * The WHERE condition is AND'd with any permission-based WHERE clauses from the user's grants.
469
514
  */
470
- type UpdateWhereBuilder = {
515
+ type UpdateWhereBuilder<TTable extends DrizzleTable = DrizzleTable> = {
471
516
  /** Specifies the WHERE condition (AND'd with permission filters at `.run()` time). */
472
- where: (condition: unknown) => UpdateReturningBuilder;
517
+ where: (condition: unknown) => UpdateReturningBuilder<TTable>;
473
518
  };
474
519
  /**
475
520
  * An update {@link Operation} that optionally returns the updated row via `.returning()`.
476
521
  *
477
522
  * Without `.returning()`, the operation resolves to `void`. With `.returning()`,
478
- * it resolves to the full updated row.
523
+ * it resolves to the full updated row, typed as `InferRow<TTable>`.
479
524
  */
480
- type UpdateReturningBuilder = Operation<void> & {
525
+ type UpdateReturningBuilder<TTable extends DrizzleTable = DrizzleTable> = Operation<void> & {
481
526
  /** Chains `.returning()` to get the updated row back from D1. */
482
- returning: () => Operation<unknown>;
527
+ returning: () => Operation<InferRow<TTable>>;
483
528
  };
484
529
  /**
485
530
  * Builder for delete operations on a single table.
@@ -492,19 +537,19 @@ type UpdateReturningBuilder = Operation<void> & {
492
537
  * await db.delete(posts).where(eq(posts.id, "abc-123")).run({});
493
538
  * ```
494
539
  */
495
- type DeleteBuilder = {
540
+ type DeleteBuilder<TTable extends DrizzleTable = DrizzleTable> = {
496
541
  /** Specifies the WHERE condition (AND'd with permission filters at `.run()` time). */
497
- where: (condition: unknown) => DeleteReturningBuilder;
542
+ where: (condition: unknown) => DeleteReturningBuilder<TTable>;
498
543
  };
499
544
  /**
500
545
  * A delete {@link Operation} that optionally returns the deleted row via `.returning()`.
501
546
  *
502
547
  * Without `.returning()`, the operation resolves to `void`. With `.returning()`,
503
- * it resolves to the full deleted row.
548
+ * it resolves to the full deleted row, typed as `InferRow<TTable>`.
504
549
  */
505
- type DeleteReturningBuilder = Operation<void> & {
550
+ type DeleteReturningBuilder<TTable extends DrizzleTable = DrizzleTable> = Operation<void> & {
506
551
  /** Chains `.returning()` to get the deleted row back from D1. */
507
- returning: () => Operation<unknown>;
552
+ returning: () => Operation<InferRow<TTable>>;
508
553
  };
509
554
 
510
555
  /**
@@ -600,6 +645,56 @@ declare function compose<TResult>(operations: Operation<unknown>[], executor: (.
600
645
  * @returns A single {@link Operation} that runs all operations sequentially and returns their results.
601
646
  */
602
647
  declare function composeSequential(operations: Operation<unknown>[]): Operation<unknown[]>;
648
+ /**
649
+ * Sequential `compose` variant that accepts a callback for ad-hoc workflows where
650
+ * later operations depend on the results of earlier ones (e.g. inserting a child
651
+ * row that references the inserted parent's id).
652
+ *
653
+ * Permissions are still collected ahead-of-time: the callback is run twice. The
654
+ * first pass uses a tracking proxy that records every operation's permission
655
+ * descriptors and returns sentinel values from `.run()` (so `card.id` and similar
656
+ * accesses don't crash). The second pass runs the callback against the real Db
657
+ * to actually execute the queries.
658
+ *
659
+ * Because the callback runs twice, **it must be free of side effects outside of
660
+ * `db.*` calls** (no `fetch`, no `console.log` you care about, no global state
661
+ * mutation). All control flow that depends on db results should use the sentinel-
662
+ * tolerant property accesses.
663
+ *
664
+ * The dry-run pass is asynchronous, so `op.permissions` is empty until the dry-run
665
+ * resolves. Consumers that need to inspect permissions before calling `.run()` (e.g.
666
+ * the `@cfast/actions` permission UI) should `await op.permissionsAsync()` instead.
667
+ * Calling `op.run()` always awaits the dry-run internally, so permissions are
668
+ * guaranteed to be populated by the time the real callback runs.
669
+ *
670
+ * @typeParam TResult - The return type of the callback.
671
+ * @param db - The real Db that will execute the operations on the second pass.
672
+ * @param callback - A function that receives a Db and returns a value.
673
+ * @returns A single {@link Operation} whose `.permissions` is the union of every
674
+ * operation discovered during the dry-run pass and whose `.run()` re-executes
675
+ * the callback against the real Db. The returned object also exposes
676
+ * `permissionsAsync()` for guaranteed permission retrieval.
677
+ *
678
+ * @example
679
+ * ```ts
680
+ * import { composeSequentialCallback } from "@cfast/db";
681
+ *
682
+ * const op = composeSequentialCallback(db, async (tx) => {
683
+ * const card = await tx.insert(cards).values({ title: "New" }).returning().run();
684
+ * await tx.insert(activityLog).values({
685
+ * cardId: (card as { id: string }).id,
686
+ * action: "card_created",
687
+ * }).run();
688
+ * return card;
689
+ * });
690
+ *
691
+ * await op.permissionsAsync(); // [{ create, cards }, { create, activityLog }]
692
+ * await op.run();
693
+ * ```
694
+ */
695
+ declare function composeSequentialCallback<TResult>(db: Db, callback: (db: Db) => Promise<TResult>): Operation<TResult> & {
696
+ permissionsAsync: () => Promise<PermissionDescriptor[]>;
697
+ };
603
698
 
604
699
  /**
605
700
  * Options for controlling default and maximum pagination limits.
@@ -654,4 +749,4 @@ declare function parseCursorParams(request: Request, options?: PaginationOptions
654
749
  */
655
750
  declare function parseOffsetParams(request: Request, options?: PaginationOptions): OffsetParams;
656
751
 
657
- export { type CacheBackend, type CacheConfig, type CursorPage, type CursorParams, type Db, type DbConfig, type DeleteBuilder, type DeleteReturningBuilder, type FindFirstOptions, type FindManyOptions, 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, createDb, parseCursorParams, parseOffsetParams };
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 };
package/dist/index.js CHANGED
@@ -1,3 +1,6 @@
1
+ // src/create-db.ts
2
+ import { drizzle as drizzle3 } from "drizzle-orm/d1";
3
+
1
4
  // src/query-builder.ts
2
5
  import { count } from "drizzle-orm";
3
6
  import { drizzle } from "drizzle-orm/d1";
@@ -92,7 +95,8 @@ function makePermissions(unsafe, action, table) {
92
95
  }
93
96
 
94
97
  // src/paginate.ts
95
- import { and as and2, or as or2, lt, gt, eq } from "drizzle-orm";
98
+ import { and as and2, or as or2, lt, gt, eq, getTableColumns } from "drizzle-orm";
99
+ import { getTableConfig } from "drizzle-orm/sqlite-core";
96
100
  function parseIntParam(raw, fallback) {
97
101
  if (raw == null) return fallback;
98
102
  const n = Number(raw);
@@ -135,6 +139,23 @@ function decodeCursor(cursor) {
135
139
  return null;
136
140
  }
137
141
  }
142
+ function getPrimaryKeyColumns(table) {
143
+ const tableColumns = getTableColumns(table);
144
+ const singleColumnPks = [];
145
+ for (const col of Object.values(tableColumns)) {
146
+ if (col.primary) {
147
+ singleColumnPks.push(col);
148
+ }
149
+ }
150
+ if (singleColumnPks.length > 0) {
151
+ return singleColumnPks;
152
+ }
153
+ const config = getTableConfig(table);
154
+ if (config.primaryKeys.length > 0) {
155
+ return config.primaryKeys[0].columns;
156
+ }
157
+ return [];
158
+ }
138
159
  function buildCursorWhere(cursorColumns, cursorValues, direction = "desc") {
139
160
  const compare = direction === "desc" ? lt : gt;
140
161
  if (cursorColumns.length === 1) {
@@ -247,7 +268,13 @@ function createQueryBuilder(config) {
247
268
  return qo;
248
269
  }
249
270
  if (params.type === "cursor") {
250
- const cursorColumns = options?.cursorColumns ?? [];
271
+ const explicitCursorColumns = options?.cursorColumns;
272
+ const cursorColumns = explicitCursorColumns && explicitCursorColumns.length > 0 ? explicitCursorColumns : getPrimaryKeyColumns(config.table);
273
+ if (cursorColumns.length === 0) {
274
+ throw new Error(
275
+ "paginate(): cursor pagination requires either explicit `cursorColumns` or a table with a primary key. Pass `cursorColumns` in the paginate options."
276
+ );
277
+ }
251
278
  return {
252
279
  permissions,
253
280
  async run(_params) {
@@ -302,50 +329,80 @@ function createQueryBuilder(config) {
302
329
 
303
330
  // src/mutate-builder.ts
304
331
  import { drizzle as drizzle2 } from "drizzle-orm/d1";
332
+
333
+ // src/batchable.ts
334
+ var BATCHABLE = /* @__PURE__ */ Symbol.for("@cfast/db/batchable");
335
+ function getBatchable(op) {
336
+ if (op === null || typeof op !== "object") return void 0;
337
+ const meta = op[BATCHABLE];
338
+ return meta;
339
+ }
340
+
341
+ // src/mutate-builder.ts
305
342
  function checkIfNeeded(config, grants, permissions) {
306
343
  if (!config.unsafe) {
307
344
  checkOperationPermissions(grants, permissions);
308
345
  }
309
346
  }
310
- function buildMutationWithReturning(config, permissions, tableName, execute) {
311
- return {
347
+ function buildMutationWithReturning(config, permissions, tableName, buildQuery) {
348
+ const drizzleDb = drizzle2(config.d1, { schema: config.schema });
349
+ const runOnce = async (returning) => {
350
+ checkIfNeeded(config, config.grants, permissions);
351
+ const built = buildQuery(drizzleDb, returning);
352
+ const result = await built.execute(returning);
353
+ config.onMutate?.(tableName);
354
+ return result;
355
+ };
356
+ const baseOp = {
312
357
  permissions,
313
358
  async run(_params) {
314
- checkIfNeeded(config, config.grants, permissions);
315
- await execute(false);
316
- config.onMutate?.(tableName);
359
+ await runOnce(false);
317
360
  },
318
361
  returning() {
319
- return {
362
+ const returningOp = {
320
363
  permissions,
321
364
  async run(_params) {
322
- checkIfNeeded(config, config.grants, permissions);
323
- const result = await execute(true);
324
- config.onMutate?.(tableName);
325
- return result;
365
+ return runOnce(true);
326
366
  }
327
367
  };
368
+ returningOp[BATCHABLE] = {
369
+ build: (sharedDb) => buildQuery(sharedDb, true).query,
370
+ tableName,
371
+ withResult: true
372
+ };
373
+ return returningOp;
328
374
  }
329
375
  };
376
+ baseOp[BATCHABLE] = {
377
+ build: (sharedDb) => buildQuery(sharedDb, false).query,
378
+ tableName,
379
+ withResult: false
380
+ };
381
+ return baseOp;
330
382
  }
331
383
  function createInsertBuilder(config) {
332
- const db = drizzle2(config.d1, { schema: config.schema });
333
384
  const permissions = makePermissions(config.unsafe, "create", config.table);
334
385
  const tableName = getTableName2(config.table);
335
386
  return {
336
387
  values(values) {
337
- return buildMutationWithReturning(config, permissions, tableName, async (returning) => {
338
- const query = db.insert(config.table).values(values);
339
- if (returning) {
340
- return query.returning().get();
341
- }
342
- await query.run();
343
- });
388
+ const buildQuery = (sharedDb, returning) => {
389
+ const base = sharedDb.insert(config.table).values(values);
390
+ const query = returning ? base.returning() : base;
391
+ return {
392
+ query,
393
+ execute: async (wantsResult) => {
394
+ if (wantsResult) {
395
+ return query.get();
396
+ }
397
+ await base.run();
398
+ }
399
+ };
400
+ };
401
+ return buildMutationWithReturning(config, permissions, tableName, buildQuery);
344
402
  }
345
403
  };
346
404
  }
347
405
  function createUpdateBuilder(config) {
348
- const db = drizzle2(config.d1, { schema: config.schema });
349
406
  const permissions = makePermissions(config.unsafe, "update", config.table);
350
407
  const tableName = getTableName2(config.table);
351
408
  return {
@@ -360,21 +417,27 @@ function createUpdateBuilder(config) {
360
417
  config.unsafe
361
418
  );
362
419
  const combinedWhere = combineWhere(condition, permFilter);
363
- return buildMutationWithReturning(config, permissions, tableName, async (returning) => {
364
- const query = db.update(config.table).set(values);
365
- if (combinedWhere) query.where(combinedWhere);
366
- if (returning) {
367
- return query.returning().get();
368
- }
369
- await query.run();
370
- });
420
+ const buildQuery = (sharedDb, returning) => {
421
+ const base = sharedDb.update(config.table).set(values);
422
+ if (combinedWhere) base.where(combinedWhere);
423
+ const query = returning ? base.returning() : base;
424
+ return {
425
+ query,
426
+ execute: async (wantsResult) => {
427
+ if (wantsResult) {
428
+ return query.get();
429
+ }
430
+ await base.run();
431
+ }
432
+ };
433
+ };
434
+ return buildMutationWithReturning(config, permissions, tableName, buildQuery);
371
435
  }
372
436
  };
373
437
  }
374
438
  };
375
439
  }
376
440
  function createDeleteBuilder(config) {
377
- const db = drizzle2(config.d1, { schema: config.schema });
378
441
  const permissions = makePermissions(config.unsafe, "delete", config.table);
379
442
  const tableName = getTableName2(config.table);
380
443
  return {
@@ -387,14 +450,21 @@ function createDeleteBuilder(config) {
387
450
  config.unsafe
388
451
  );
389
452
  const combinedWhere = combineWhere(condition, permFilter);
390
- return buildMutationWithReturning(config, permissions, tableName, async (returning) => {
391
- const query = db.delete(config.table);
392
- if (combinedWhere) query.where(combinedWhere);
393
- if (returning) {
394
- return query.returning().get();
395
- }
396
- await query.run();
397
- });
453
+ const buildQuery = (sharedDb, returning) => {
454
+ const base = sharedDb.delete(config.table);
455
+ if (combinedWhere) base.where(combinedWhere);
456
+ const query = returning ? base.returning() : base;
457
+ return {
458
+ query,
459
+ execute: async (wantsResult) => {
460
+ if (wantsResult) {
461
+ return query.get();
462
+ }
463
+ await base.run();
464
+ }
465
+ };
466
+ };
467
+ return buildMutationWithReturning(config, permissions, tableName, buildQuery);
398
468
  }
399
469
  };
400
470
  }
@@ -584,6 +654,26 @@ function buildDb(config, isUnsafe) {
584
654
  permissions: allPermissions,
585
655
  async run(params) {
586
656
  const p = params ?? {};
657
+ if (!isUnsafe) {
658
+ checkOperationPermissions(config.grants, allPermissions);
659
+ }
660
+ const batchables = operations.map((op) => getBatchable(op));
661
+ const everyOpBatchable = operations.length > 0 && batchables.every((b) => b !== void 0);
662
+ if (everyOpBatchable) {
663
+ const sharedDb = drizzle3(config.d1, { schema: config.schema });
664
+ const items = batchables.map((b) => b.build(sharedDb));
665
+ const batchResults = await sharedDb.batch(
666
+ items
667
+ );
668
+ const tables = /* @__PURE__ */ new Set();
669
+ for (const b of batchables) {
670
+ tables.add(b.tableName);
671
+ }
672
+ for (const t of tables) {
673
+ cacheManager?.invalidateTable(t);
674
+ }
675
+ return batchables.map((b, i) => b.withResult ? batchResults[i] : void 0);
676
+ }
587
677
  const results = [];
588
678
  for (const op of operations) {
589
679
  results.push(await op.run(p));
@@ -636,9 +726,101 @@ function composeSequential(operations) {
636
726
  }
637
727
  };
638
728
  }
729
+ function createSentinel() {
730
+ const target = function() {
731
+ };
732
+ return new Proxy(target, {
733
+ get(_t, prop) {
734
+ if (prop === "then") return void 0;
735
+ if (prop === Symbol.toPrimitive) return () => "[sentinel]";
736
+ if (prop === "toString") return () => "[sentinel]";
737
+ if (prop === "valueOf") return () => 0;
738
+ return createSentinel();
739
+ },
740
+ apply() {
741
+ return createSentinel();
742
+ }
743
+ });
744
+ }
745
+ function wrapForTracking(target, perms) {
746
+ return new Proxy(target, {
747
+ get(t, prop, receiver) {
748
+ const orig = Reflect.get(t, prop, receiver);
749
+ if (prop === "permissions") return orig;
750
+ if (prop === "run" && typeof orig === "function") {
751
+ return async () => {
752
+ const opPerms = t.permissions;
753
+ if (Array.isArray(opPerms)) {
754
+ perms.push(...opPerms);
755
+ }
756
+ return createSentinel();
757
+ };
758
+ }
759
+ if (typeof orig === "function") {
760
+ return (...args) => {
761
+ const result = orig.apply(t, args);
762
+ if (result && typeof result === "object") {
763
+ return wrapForTracking(result, perms);
764
+ }
765
+ return result;
766
+ };
767
+ }
768
+ return orig;
769
+ }
770
+ });
771
+ }
772
+ function createTrackingDb(real, perms) {
773
+ return {
774
+ query: (table) => wrapForTracking(real.query(table), perms),
775
+ insert: (table) => wrapForTracking(real.insert(table), perms),
776
+ update: (table) => wrapForTracking(real.update(table), perms),
777
+ delete: (table) => wrapForTracking(real.delete(table), perms),
778
+ unsafe: () => createTrackingDb(real.unsafe(), perms),
779
+ batch: (operations) => {
780
+ for (const op of operations) {
781
+ perms.push(...op.permissions);
782
+ }
783
+ return {
784
+ permissions: deduplicateDescriptors(operations.flatMap((op) => op.permissions)),
785
+ async run() {
786
+ return [createSentinel()];
787
+ }
788
+ };
789
+ },
790
+ cache: real.cache
791
+ };
792
+ }
793
+ function composeSequentialCallback(db, callback) {
794
+ const collected = [];
795
+ const trackingDb = createTrackingDb(db, collected);
796
+ const dryRunPromise = (async () => {
797
+ try {
798
+ await callback(trackingDb);
799
+ } catch {
800
+ }
801
+ })();
802
+ const permissions = [];
803
+ const populated = dryRunPromise.then(() => {
804
+ for (const d of deduplicateDescriptors(collected)) {
805
+ permissions.push(d);
806
+ }
807
+ });
808
+ return {
809
+ permissions,
810
+ async permissionsAsync() {
811
+ await populated;
812
+ return permissions;
813
+ },
814
+ async run(_params) {
815
+ await populated;
816
+ return callback(db);
817
+ }
818
+ };
819
+ }
639
820
  export {
640
821
  compose,
641
822
  composeSequential,
823
+ composeSequentialCallback,
642
824
  createDb,
643
825
  parseCursorParams,
644
826
  parseOffsetParams
package/llms.txt CHANGED
@@ -23,13 +23,17 @@ import * as schema from "./schema"; // must be `import *`
23
23
 
24
24
  const db = createDb({
25
25
  d1: D1Database, // env.DB
26
- schema: Record<string, DrizzleTable>,
26
+ schema, // typeof import("./schema") -- no cast needed
27
27
  grants: Grant[], // from resolveGrants(permissions, roles)
28
28
  user: { id: string } | null, // null = anonymous
29
29
  cache?: CacheConfig | false, // default: { backend: "cache-api" }
30
30
  });
31
31
  ```
32
32
 
33
+ `schema` is typed as `Record<string, unknown>` so you can pass `import * as schema`
34
+ directly even when the module also exports Drizzle `relations()`. Non-table entries
35
+ are ignored at runtime when looking up tables by key.
36
+
33
37
  ### Operation<TResult>
34
38
 
35
39
  ```typescript
@@ -60,6 +64,24 @@ db.delete(table).where(cond): DeleteReturningBuilder
60
64
 
61
65
  All write builders are `Operation<void>` with an optional `.returning()` that changes return type.
62
66
 
67
+ #### Single-op shorthand (no compose required)
68
+
69
+ For a single mutation, call `.run()` directly on the Operation -- there's no need to wrap
70
+ it in `compose()` or `db.batch()`. Reserve those for multi-op workflows.
71
+
72
+ ```typescript
73
+ // Insert
74
+ const card = await db.insert(cards).values({ title: "New" }).returning().run();
75
+
76
+ // Update
77
+ await db.update(cards).set({ archived: true }).where(eq(cards.id, id)).run();
78
+
79
+ // Delete
80
+ await db.delete(cards).where(eq(cards.id, id)).run();
81
+ ```
82
+
83
+ `.run()` arguments are optional -- only pass `params` when your query uses `sql.placeholder()`.
84
+
63
85
  ### compose(operations, executor): Operation<TResult>
64
86
 
65
87
  ```typescript
@@ -89,9 +111,70 @@ await op.run(); // runs operations in order, returns results array
89
111
 
90
112
  Runs operations in order, returns results array. Shorthand for `compose` when there are no data dependencies between operations.
91
113
 
114
+ ### composeSequentialCallback(db, callback): Operation<TResult>
115
+
116
+ For workflows where later operations depend on the **results** of earlier ones
117
+ (e.g. inserting a child row that references the inserted parent's id):
118
+
119
+ ```typescript
120
+ import { composeSequentialCallback } from "@cfast/db";
121
+
122
+ const op = composeSequentialCallback(db, async (tx) => {
123
+ const card = await tx.insert(cards).values({ title: "New" }).returning().run();
124
+ await tx.insert(activityLog).values({
125
+ cardId: (card as { id: string }).id,
126
+ action: "card_created",
127
+ }).run();
128
+ return card;
129
+ });
130
+
131
+ await op.permissionsAsync(); // [{ create, cards }, { create, activityLog }]
132
+ await op.run();
133
+ ```
134
+
135
+ The callback runs **twice**: first against a tracking proxy that records each
136
+ operation's permission descriptors and returns sentinel values from `.run()`,
137
+ then against the real db. Because of the dry-run pass, the callback **must be
138
+ free of side effects outside of `db.*` calls** (no `fetch`, no global state
139
+ mutation, no `console.log` you care about).
140
+
141
+ `op.permissions` is populated asynchronously after the dry-run resolves; use
142
+ `op.permissionsAsync()` for a guaranteed-populated array. `op.run()` always
143
+ awaits the dry-run internally, so permissions are checked on every sub-op.
144
+
145
+ Use this only when you actually need data dependencies. For independent
146
+ operations, prefer `composeSequential([...])` (faster, fully synchronous
147
+ permissions).
148
+
92
149
  ### db.batch(operations): Operation<unknown[]>
93
150
 
94
- Runs operations sequentially with merged permissions. Not D1 native batch.
151
+ Runs multiple operations with merged, deduplicated permissions. **Atomic** when
152
+ every operation was produced by `db.insert/update/delete`: the batch is sent to
153
+ D1's native `batch()` API, which executes the statements as a single transaction
154
+ and rolls everything back if any statement fails.
155
+
156
+ Use this whenever you need transactional safety -- for example, decrementing
157
+ stock across multiple products during checkout, or inserting an order plus its
158
+ line items together:
159
+
160
+ ```typescript
161
+ // Atomic checkout: stock decrements + order creation either all succeed or
162
+ // all roll back. Permissions for every sub-op are checked upfront.
163
+ await db.batch([
164
+ db.update(products).set({ stock: sql`stock - 1` }).where(eq(products.id, id1)),
165
+ db.update(products).set({ stock: sql`stock - 1` }).where(eq(products.id, id2)),
166
+ db.insert(orders).values({ userId, items: [...] }),
167
+ ]).run();
168
+ ```
169
+
170
+ Permission checks happen **before** any SQL is issued. If the user lacks any
171
+ required grant, the batch throws and zero statements run.
172
+
173
+ Operations produced by `compose()`/`composeSequential()` executors don't carry
174
+ the batchable hook; mixing them into `db.batch([...])` causes a fallback to
175
+ sequential execution (and loses the atomicity guarantee). For pure compose
176
+ workflows that need atomicity, build the underlying ops with `db.insert/update/
177
+ delete` directly and pass them straight to `db.batch([...])`.
95
178
 
96
179
  ### db.unsafe(): Db
97
180
 
@@ -177,6 +260,26 @@ export async function action({ context, request }) {
177
260
  - **@cfast/auth** -- `auth.requireUser(request)` returns `{ user, grants }` which you pass directly to `createDb()`.
178
261
  - **@cfast/actions** -- Actions define operations using `@cfast/db`. The framework extracts `.permissions` for client-side UI adaptation.
179
262
 
263
+ ## Schema Gotchas
264
+
265
+ ### Self-referential foreign keys
266
+
267
+ Drizzle cannot infer the return type of a `references()` callback that points at the same table. Without an explicit annotation, TypeScript fails with `TS7022: ... implicitly has type 'any'`. Annotate the callback with `AnySQLiteColumn`:
268
+
269
+ ```typescript
270
+ import { sqliteTable, text, type AnySQLiteColumn } from "drizzle-orm/sqlite-core";
271
+
272
+ export const folders = sqliteTable("folders", {
273
+ id: text("id").primaryKey(),
274
+ name: text("name").notNull(),
275
+ parentFolderId: text("parent_folder_id").references(
276
+ (): AnySQLiteColumn => folders.id,
277
+ ),
278
+ });
279
+ ```
280
+
281
+ Use `AnyPgColumn` / `AnyMySqlColumn` for other dialects. The same pattern applies to any self-reference (comment threads, org charts, category trees).
282
+
180
283
  ## Common Mistakes
181
284
 
182
285
  - **Forgetting `.run()`** -- Operations are lazy. `db.query(t).findMany()` returns an Operation, not results. You must call `.run()`.
@@ -184,6 +287,7 @@ export async function action({ context, request }) {
184
287
  - **Using `import { posts }` instead of `import * as schema`** -- The `schema` config must be `import *` so Drizzle's relational API can look up tables by key name.
185
288
  - **Using `db.unsafe()` for admin endpoints** -- Use a role with `grant("manage", "all")` instead. Reserve `unsafe()` for genuinely user-less contexts (cron, migrations).
186
289
  - **Expecting relational `with` to have permission filters** -- Permission WHERE clauses only apply to the root table, not joined relations.
290
+ - **Self-referential FK without `AnySQLiteColumn` annotation** -- TypeScript fails with TS7022. See "Schema Gotchas" above.
187
291
 
188
292
  ## See Also
189
293
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfast/db",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Permission-aware Drizzle queries for Cloudflare D1",
5
5
  "keywords": [
6
6
  "cfast",
@@ -37,7 +37,7 @@
37
37
  "drizzle-orm": ">=0.35"
38
38
  },
39
39
  "dependencies": {
40
- "@cfast/permissions": "0.1.0"
40
+ "@cfast/permissions": "0.2.0"
41
41
  },
42
42
  "peerDependenciesMeta": {
43
43
  "@cloudflare/workers-types": {