@cfast/db 0.1.1 → 0.3.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";
@@ -9,14 +12,17 @@ import {
9
12
  } from "@cfast/permissions";
10
13
  import { CRUD_ACTIONS } from "@cfast/permissions";
11
14
  function resolvePermissionFilters(grants, action, table) {
15
+ const targetName = getTableName(table);
12
16
  const matching = grants.filter((g) => {
13
17
  const actionMatch = g.action === action || g.action === "manage";
14
- const tableMatch = g.subject === "all" || g.subject === table || typeof g.subject === "object" && getTableName(g.subject) === getTableName(table);
18
+ const tableMatch = g.subject === "all" || g.subject === table || getTableName(g.subject) === targetName;
15
19
  return actionMatch && tableMatch;
16
20
  });
17
21
  if (matching.length === 0) return [];
18
22
  if (matching.some((g) => !g.where)) return [];
19
- return matching.filter((g) => !!g.where).map((g) => g.where);
23
+ return matching.filter(
24
+ (g) => !!g.where
25
+ );
20
26
  }
21
27
  function grantMatchesAction(grantAction, requiredAction) {
22
28
  if (grantAction === requiredAction) return true;
@@ -71,13 +77,38 @@ function deduplicateDescriptors(descriptors) {
71
77
  }
72
78
  return result;
73
79
  }
74
- function buildPermissionFilter(grants, action, table, user, unsafe) {
80
+ function createLookupCache() {
81
+ return /* @__PURE__ */ new Map();
82
+ }
83
+ async function resolveGrantLookups(grant, user, lookupDb, cache) {
84
+ if (!grant.with) return {};
85
+ const cached = cache.get(grant);
86
+ if (cached) return cached;
87
+ const entries = Object.entries(grant.with);
88
+ const promise = (async () => {
89
+ const resolved = {};
90
+ await Promise.all(
91
+ entries.map(async ([key, fn]) => {
92
+ resolved[key] = await fn(user, lookupDb);
93
+ })
94
+ );
95
+ return resolved;
96
+ })();
97
+ cache.set(grant, promise);
98
+ return promise;
99
+ }
100
+ async function buildPermissionFilter(grants, action, table, user, unsafe, getLookupDb, cache) {
75
101
  if (unsafe || !user) return void 0;
76
- const filters = resolvePermissionFilters(grants, action, table);
77
- if (filters.length === 0) return void 0;
102
+ const matching = resolvePermissionFilters(grants, action, table);
103
+ if (matching.length === 0) return void 0;
78
104
  const columns = table;
79
- const clauses = filters.map(
80
- (fn) => fn(columns, user)
105
+ const needsLookupDb = matching.some((g) => g.with !== void 0);
106
+ const lookupDb = needsLookupDb ? getLookupDb() : void 0;
107
+ const lookupSets = await Promise.all(
108
+ matching.map((g) => resolveGrantLookups(g, user, lookupDb, cache))
109
+ );
110
+ const clauses = matching.map(
111
+ (g, i) => g.where(columns, user, lookupSets[i])
81
112
  );
82
113
  return or(...clauses);
83
114
  }
@@ -92,7 +123,8 @@ function makePermissions(unsafe, action, table) {
92
123
  }
93
124
 
94
125
  // src/paginate.ts
95
- import { and as and2, or as or2, lt, gt, eq } from "drizzle-orm";
126
+ import { and as and2, or as or2, lt, gt, eq, getTableColumns } from "drizzle-orm";
127
+ import { getTableConfig } from "drizzle-orm/sqlite-core";
96
128
  function parseIntParam(raw, fallback) {
97
129
  if (raw == null) return fallback;
98
130
  const n = Number(raw);
@@ -135,6 +167,23 @@ function decodeCursor(cursor) {
135
167
  return null;
136
168
  }
137
169
  }
170
+ function getPrimaryKeyColumns(table) {
171
+ const tableColumns = getTableColumns(table);
172
+ const singleColumnPks = [];
173
+ for (const col of Object.values(tableColumns)) {
174
+ if (col.primary) {
175
+ singleColumnPks.push(col);
176
+ }
177
+ }
178
+ if (singleColumnPks.length > 0) {
179
+ return singleColumnPks;
180
+ }
181
+ const config = getTableConfig(table);
182
+ if (config.primaryKeys.length > 0) {
183
+ return config.primaryKeys[0].columns;
184
+ }
185
+ return [];
186
+ }
138
187
  function buildCursorWhere(cursorColumns, cursorValues, direction = "desc") {
139
188
  const compare = direction === "desc" ? lt : gt;
140
189
  if (cursorColumns.length === 1) {
@@ -170,12 +219,14 @@ function buildQueryOperation(config, db, tableKey, method, options) {
170
219
  if (!config.unsafe) {
171
220
  checkOperationPermissions(config.grants, permissions);
172
221
  }
173
- const permFilter = buildPermissionFilter(
222
+ const permFilter = await buildPermissionFilter(
174
223
  config.grants,
175
224
  "read",
176
225
  config.table,
177
226
  config.user,
178
- config.unsafe
227
+ config.unsafe,
228
+ config.getLookupDb,
229
+ config.lookupCache
179
230
  );
180
231
  const userWhere = options?.where;
181
232
  const combinedWhere = combineWhere(userWhere, permFilter);
@@ -222,16 +273,18 @@ function createQueryBuilder(config) {
222
273
  if (!tableKey) throw new Error("Table not found in schema");
223
274
  return tableKey;
224
275
  }
225
- function checkAndBuildWhere(extraWhere) {
276
+ async function checkAndBuildWhere(extraWhere) {
226
277
  if (!config.unsafe) {
227
278
  checkOperationPermissions(config.grants, permissions);
228
279
  }
229
- const permFilter = buildPermissionFilter(
280
+ const permFilter = await buildPermissionFilter(
230
281
  config.grants,
231
282
  "read",
232
283
  config.table,
233
284
  config.user,
234
- config.unsafe
285
+ config.unsafe,
286
+ config.getLookupDb,
287
+ config.lookupCache
235
288
  );
236
289
  return combineWhere(
237
290
  combineWhere(options?.where, permFilter),
@@ -247,7 +300,13 @@ function createQueryBuilder(config) {
247
300
  return qo;
248
301
  }
249
302
  if (params.type === "cursor") {
250
- const cursorColumns = options?.cursorColumns ?? [];
303
+ const explicitCursorColumns = options?.cursorColumns;
304
+ const cursorColumns = explicitCursorColumns && explicitCursorColumns.length > 0 ? explicitCursorColumns : getPrimaryKeyColumns(config.table);
305
+ if (cursorColumns.length === 0) {
306
+ throw new Error(
307
+ "paginate(): cursor pagination requires either explicit `cursorColumns` or a table with a primary key. Pass `cursorColumns` in the paginate options."
308
+ );
309
+ }
251
310
  return {
252
311
  permissions,
253
312
  async run(_params) {
@@ -255,7 +314,7 @@ function createQueryBuilder(config) {
255
314
  const cursorValues = decodeCursor(params.cursor);
256
315
  const direction = options?.orderDirection ?? "desc";
257
316
  const cursorWhere = cursorValues ? buildCursorWhere(cursorColumns, cursorValues, direction) : void 0;
258
- const combinedWhere = checkAndBuildWhere(cursorWhere);
317
+ const combinedWhere = await checkAndBuildWhere(cursorWhere);
259
318
  const queryOptions = buildBaseQueryOptions(combinedWhere);
260
319
  queryOptions.limit = params.limit + 1;
261
320
  const queryTable = getQueryTable(db, key);
@@ -276,7 +335,7 @@ function createQueryBuilder(config) {
276
335
  permissions,
277
336
  async run(_params) {
278
337
  const key = ensureTableKey();
279
- const combinedWhere = checkAndBuildWhere();
338
+ const combinedWhere = await checkAndBuildWhere();
280
339
  const queryOptions = buildBaseQueryOptions(combinedWhere);
281
340
  queryOptions.limit = params.limit;
282
341
  queryOptions.offset = (params.page - 1) * params.limit;
@@ -302,99 +361,185 @@ function createQueryBuilder(config) {
302
361
 
303
362
  // src/mutate-builder.ts
304
363
  import { drizzle as drizzle2 } from "drizzle-orm/d1";
364
+
365
+ // src/batchable.ts
366
+ var BATCHABLE = /* @__PURE__ */ Symbol.for("@cfast/db/batchable");
367
+ function getBatchable(op) {
368
+ if (op === null || typeof op !== "object") return void 0;
369
+ const meta = op[BATCHABLE];
370
+ return meta;
371
+ }
372
+
373
+ // src/mutate-builder.ts
305
374
  function checkIfNeeded(config, grants, permissions) {
306
375
  if (!config.unsafe) {
307
376
  checkOperationPermissions(grants, permissions);
308
377
  }
309
378
  }
310
- function buildMutationWithReturning(config, permissions, tableName, execute) {
311
- return {
379
+ function buildMutationWithReturning(config, permissions, tableName, buildQuery, prepareFn) {
380
+ const drizzleDb = drizzle2(config.d1, { schema: config.schema });
381
+ const runOnce = async (returning) => {
382
+ checkIfNeeded(config, config.grants, permissions);
383
+ if (prepareFn) await prepareFn();
384
+ const built = buildQuery(drizzleDb, returning);
385
+ const result = await built.execute(returning);
386
+ config.onMutate?.(tableName);
387
+ return result;
388
+ };
389
+ const baseOp = {
312
390
  permissions,
313
391
  async run(_params) {
314
- checkIfNeeded(config, config.grants, permissions);
315
- await execute(false);
316
- config.onMutate?.(tableName);
392
+ await runOnce(false);
317
393
  },
318
394
  returning() {
319
- return {
395
+ const returningOp = {
320
396
  permissions,
321
397
  async run(_params) {
322
- checkIfNeeded(config, config.grants, permissions);
323
- const result = await execute(true);
324
- config.onMutate?.(tableName);
325
- return result;
398
+ return runOnce(true);
326
399
  }
327
400
  };
401
+ returningOp[BATCHABLE] = {
402
+ prepare: prepareFn,
403
+ build: (sharedDb) => buildQuery(sharedDb, true).query,
404
+ tableName,
405
+ withResult: true
406
+ };
407
+ return returningOp;
328
408
  }
329
409
  };
410
+ baseOp[BATCHABLE] = {
411
+ prepare: prepareFn,
412
+ build: (sharedDb) => buildQuery(sharedDb, false).query,
413
+ tableName,
414
+ withResult: false
415
+ };
416
+ return baseOp;
330
417
  }
331
418
  function createInsertBuilder(config) {
332
- const db = drizzle2(config.d1, { schema: config.schema });
333
419
  const permissions = makePermissions(config.unsafe, "create", config.table);
334
420
  const tableName = getTableName2(config.table);
335
421
  return {
336
422
  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
- });
423
+ const buildQuery = (sharedDb, returning) => {
424
+ const base = sharedDb.insert(config.table).values(values);
425
+ const query = returning ? base.returning() : base;
426
+ return {
427
+ query,
428
+ execute: async (wantsResult) => {
429
+ if (wantsResult) {
430
+ return query.get();
431
+ }
432
+ await base.run();
433
+ }
434
+ };
435
+ };
436
+ return buildMutationWithReturning(config, permissions, tableName, buildQuery);
344
437
  }
345
438
  };
346
439
  }
347
440
  function createUpdateBuilder(config) {
348
- const db = drizzle2(config.d1, { schema: config.schema });
349
441
  const permissions = makePermissions(config.unsafe, "update", config.table);
350
442
  const tableName = getTableName2(config.table);
351
443
  return {
352
444
  set(values) {
353
445
  return {
354
446
  where(condition) {
355
- const permFilter = buildPermissionFilter(
356
- config.grants,
357
- "update",
358
- config.table,
359
- config.user,
360
- config.unsafe
361
- );
362
- 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();
447
+ let resolvedCombinedWhere;
448
+ let preparePromise;
449
+ const prepareFn = () => {
450
+ if (!preparePromise) {
451
+ preparePromise = (async () => {
452
+ const permFilter = await buildPermissionFilter(
453
+ config.grants,
454
+ "update",
455
+ config.table,
456
+ config.user,
457
+ config.unsafe,
458
+ config.getLookupDb,
459
+ config.lookupCache
460
+ );
461
+ resolvedCombinedWhere = combineWhere(
462
+ condition,
463
+ permFilter
464
+ );
465
+ })();
368
466
  }
369
- await query.run();
370
- });
467
+ return preparePromise;
468
+ };
469
+ const buildQuery = (sharedDb, returning) => {
470
+ const base = sharedDb.update(config.table).set(values);
471
+ if (resolvedCombinedWhere) base.where(resolvedCombinedWhere);
472
+ const query = returning ? base.returning() : base;
473
+ return {
474
+ query,
475
+ execute: async (wantsResult) => {
476
+ if (wantsResult) {
477
+ return query.get();
478
+ }
479
+ await base.run();
480
+ }
481
+ };
482
+ };
483
+ return buildMutationWithReturning(
484
+ config,
485
+ permissions,
486
+ tableName,
487
+ buildQuery,
488
+ prepareFn
489
+ );
371
490
  }
372
491
  };
373
492
  }
374
493
  };
375
494
  }
376
495
  function createDeleteBuilder(config) {
377
- const db = drizzle2(config.d1, { schema: config.schema });
378
496
  const permissions = makePermissions(config.unsafe, "delete", config.table);
379
497
  const tableName = getTableName2(config.table);
380
498
  return {
381
499
  where(condition) {
382
- const permFilter = buildPermissionFilter(
383
- config.grants,
384
- "delete",
385
- config.table,
386
- config.user,
387
- config.unsafe
388
- );
389
- 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();
500
+ let resolvedCombinedWhere;
501
+ let preparePromise;
502
+ const prepareFn = () => {
503
+ if (!preparePromise) {
504
+ preparePromise = (async () => {
505
+ const permFilter = await buildPermissionFilter(
506
+ config.grants,
507
+ "delete",
508
+ config.table,
509
+ config.user,
510
+ config.unsafe,
511
+ config.getLookupDb,
512
+ config.lookupCache
513
+ );
514
+ resolvedCombinedWhere = combineWhere(
515
+ condition,
516
+ permFilter
517
+ );
518
+ })();
395
519
  }
396
- await query.run();
397
- });
520
+ return preparePromise;
521
+ };
522
+ const buildQuery = (sharedDb, returning) => {
523
+ const base = sharedDb.delete(config.table);
524
+ if (resolvedCombinedWhere) base.where(resolvedCombinedWhere);
525
+ const query = returning ? base.returning() : base;
526
+ return {
527
+ query,
528
+ execute: async (wantsResult) => {
529
+ if (wantsResult) {
530
+ return query.get();
531
+ }
532
+ await base.run();
533
+ }
534
+ };
535
+ };
536
+ return buildMutationWithReturning(
537
+ config,
538
+ permissions,
539
+ tableName,
540
+ buildQuery,
541
+ prepareFn
542
+ );
398
543
  }
399
544
  };
400
545
  }
@@ -522,14 +667,21 @@ function createCacheManager(config) {
522
667
 
523
668
  // src/create-db.ts
524
669
  function createDb(config) {
525
- return buildDb(config, false);
670
+ const lookupCache = createLookupCache();
671
+ return buildDb(config, false, lookupCache);
526
672
  }
527
- function buildDb(config, isUnsafe) {
673
+ function buildDb(config, isUnsafe, lookupCache) {
528
674
  const cacheManager = config.cache === false ? null : createCacheManager(config.cache ?? { backend: "cache-api" });
529
675
  const onMutate = (tableName) => {
530
676
  cacheManager?.invalidateTable(tableName);
531
677
  };
532
- return {
678
+ let lookupDbCache = null;
679
+ const getLookupDb = () => {
680
+ if (lookupDbCache) return lookupDbCache;
681
+ lookupDbCache = isUnsafe ? db : buildDb(config, true, lookupCache);
682
+ return lookupDbCache;
683
+ };
684
+ const db = {
533
685
  query(table) {
534
686
  return createQueryBuilder({
535
687
  d1: config.d1,
@@ -537,7 +689,9 @@ function buildDb(config, isUnsafe) {
537
689
  grants: config.grants,
538
690
  user: config.user,
539
691
  table,
540
- unsafe: isUnsafe
692
+ unsafe: isUnsafe,
693
+ lookupCache,
694
+ getLookupDb
541
695
  });
542
696
  },
543
697
  insert(table) {
@@ -548,7 +702,9 @@ function buildDb(config, isUnsafe) {
548
702
  user: config.user,
549
703
  table,
550
704
  unsafe: isUnsafe,
551
- onMutate
705
+ onMutate,
706
+ lookupCache,
707
+ getLookupDb
552
708
  });
553
709
  },
554
710
  update(table) {
@@ -559,7 +715,9 @@ function buildDb(config, isUnsafe) {
559
715
  user: config.user,
560
716
  table,
561
717
  unsafe: isUnsafe,
562
- onMutate
718
+ onMutate,
719
+ lookupCache,
720
+ getLookupDb
563
721
  });
564
722
  },
565
723
  delete(table) {
@@ -570,11 +728,13 @@ function buildDb(config, isUnsafe) {
570
728
  user: config.user,
571
729
  table,
572
730
  unsafe: isUnsafe,
573
- onMutate
731
+ onMutate,
732
+ lookupCache,
733
+ getLookupDb
574
734
  });
575
735
  },
576
736
  unsafe() {
577
- return buildDb(config, true);
737
+ return buildDb(config, true, lookupCache);
578
738
  },
579
739
  batch(operations) {
580
740
  const allPermissions = deduplicateDescriptors(
@@ -584,6 +744,29 @@ function buildDb(config, isUnsafe) {
584
744
  permissions: allPermissions,
585
745
  async run(params) {
586
746
  const p = params ?? {};
747
+ if (!isUnsafe) {
748
+ checkOperationPermissions(config.grants, allPermissions);
749
+ }
750
+ const batchables = operations.map((op) => getBatchable(op));
751
+ const everyOpBatchable = operations.length > 0 && batchables.every((b) => b !== void 0);
752
+ if (everyOpBatchable) {
753
+ const sharedDb = drizzle3(config.d1, { schema: config.schema });
754
+ await Promise.all(
755
+ batchables.map((b) => b.prepare?.() ?? Promise.resolve())
756
+ );
757
+ const items = batchables.map((b) => b.build(sharedDb));
758
+ const batchResults = await sharedDb.batch(
759
+ items
760
+ );
761
+ const tables = /* @__PURE__ */ new Set();
762
+ for (const b of batchables) {
763
+ tables.add(b.tableName);
764
+ }
765
+ for (const t of tables) {
766
+ cacheManager?.invalidateTable(t);
767
+ }
768
+ return batchables.map((b, i) => b.withResult ? batchResults[i] : void 0);
769
+ }
587
770
  const results = [];
588
771
  for (const op of operations) {
589
772
  results.push(await op.run(p));
@@ -606,6 +789,7 @@ function buildDb(config, isUnsafe) {
606
789
  }
607
790
  }
608
791
  };
792
+ return db;
609
793
  }
610
794
 
611
795
  // src/compose.ts
@@ -636,9 +820,101 @@ function composeSequential(operations) {
636
820
  }
637
821
  };
638
822
  }
823
+ function createSentinel() {
824
+ const target = function() {
825
+ };
826
+ return new Proxy(target, {
827
+ get(_t, prop) {
828
+ if (prop === "then") return void 0;
829
+ if (prop === Symbol.toPrimitive) return () => "[sentinel]";
830
+ if (prop === "toString") return () => "[sentinel]";
831
+ if (prop === "valueOf") return () => 0;
832
+ return createSentinel();
833
+ },
834
+ apply() {
835
+ return createSentinel();
836
+ }
837
+ });
838
+ }
839
+ function wrapForTracking(target, perms) {
840
+ return new Proxy(target, {
841
+ get(t, prop, receiver) {
842
+ const orig = Reflect.get(t, prop, receiver);
843
+ if (prop === "permissions") return orig;
844
+ if (prop === "run" && typeof orig === "function") {
845
+ return async () => {
846
+ const opPerms = t.permissions;
847
+ if (Array.isArray(opPerms)) {
848
+ perms.push(...opPerms);
849
+ }
850
+ return createSentinel();
851
+ };
852
+ }
853
+ if (typeof orig === "function") {
854
+ return (...args) => {
855
+ const result = orig.apply(t, args);
856
+ if (result && typeof result === "object") {
857
+ return wrapForTracking(result, perms);
858
+ }
859
+ return result;
860
+ };
861
+ }
862
+ return orig;
863
+ }
864
+ });
865
+ }
866
+ function createTrackingDb(real, perms) {
867
+ return {
868
+ query: (table) => wrapForTracking(real.query(table), perms),
869
+ insert: (table) => wrapForTracking(real.insert(table), perms),
870
+ update: (table) => wrapForTracking(real.update(table), perms),
871
+ delete: (table) => wrapForTracking(real.delete(table), perms),
872
+ unsafe: () => createTrackingDb(real.unsafe(), perms),
873
+ batch: (operations) => {
874
+ for (const op of operations) {
875
+ perms.push(...op.permissions);
876
+ }
877
+ return {
878
+ permissions: deduplicateDescriptors(operations.flatMap((op) => op.permissions)),
879
+ async run() {
880
+ return [createSentinel()];
881
+ }
882
+ };
883
+ },
884
+ cache: real.cache
885
+ };
886
+ }
887
+ function composeSequentialCallback(db, callback) {
888
+ const collected = [];
889
+ const trackingDb = createTrackingDb(db, collected);
890
+ const dryRunPromise = (async () => {
891
+ try {
892
+ await callback(trackingDb);
893
+ } catch {
894
+ }
895
+ })();
896
+ const permissions = [];
897
+ const populated = dryRunPromise.then(() => {
898
+ for (const d of deduplicateDescriptors(collected)) {
899
+ permissions.push(d);
900
+ }
901
+ });
902
+ return {
903
+ permissions,
904
+ async permissionsAsync() {
905
+ await populated;
906
+ return permissions;
907
+ },
908
+ async run(_params) {
909
+ await populated;
910
+ return callback(db);
911
+ }
912
+ };
913
+ }
639
914
  export {
640
915
  compose,
641
916
  composeSequential,
917
+ composeSequentialCallback,
642
918
  createDb,
643
919
  parseCursorParams,
644
920
  parseOffsetParams
package/llms.txt CHANGED
@@ -10,8 +10,9 @@ Use `@cfast/db` whenever you need to read or write a D1 database in a cfast app.
10
10
 
11
11
  - **Operations are lazy.** Every `db.query/insert/update/delete` call returns an `Operation<TResult>` with `.permissions` (inspectable immediately) and `.run(params)` (executes with permission checks). Nothing touches D1 until you call `.run()`.
12
12
  - **Permission checks are structural.** `.run()` always checks the user's grants before executing SQL. Row-level WHERE clauses from grants are injected automatically.
13
- - **One Db per request.** `createDb()` captures the user at creation time. Never share a `Db` across requests.
14
- - **`db.unsafe()` is the only escape hatch.** Returns a `Db` that skips all permission checks. Greppable via `git grep '.unsafe()'`.
13
+ - **Cross-table grants run prerequisite lookups.** When a grant declares a `with` map (see `@cfast/permissions`), `@cfast/db` resolves those lookups against an unsafe-mode handle **before** running the main query, caches the results for the lifetime of the per-request `Db`, and threads them into the `where` clause as its third argument. A single lookup runs at most once per request even across many queries.
14
+ - **One Db per request.** `createDb()` captures the user at creation time. Never share a `Db` across requests. The per-request grant lookup cache lives on the `Db` instance, so creating a fresh one each request gives every request a fresh cache.
15
+ - **`db.unsafe()` is the only escape hatch.** Returns a `Db` that skips all permission checks. Greppable via `git grep '.unsafe()'`. The unsafe sibling shares the per-request lookup cache so lookups dispatched through it (the `LookupDb` passed to grant `with` functions) never duplicate work.
15
16
 
16
17
  ## API Reference
17
18
 
@@ -23,13 +24,17 @@ import * as schema from "./schema"; // must be `import *`
23
24
 
24
25
  const db = createDb({
25
26
  d1: D1Database, // env.DB
26
- schema: Record<string, DrizzleTable>,
27
+ schema, // typeof import("./schema") -- no cast needed
27
28
  grants: Grant[], // from resolveGrants(permissions, roles)
28
29
  user: { id: string } | null, // null = anonymous
29
30
  cache?: CacheConfig | false, // default: { backend: "cache-api" }
30
31
  });
31
32
  ```
32
33
 
34
+ `schema` is typed as `Record<string, unknown>` so you can pass `import * as schema`
35
+ directly even when the module also exports Drizzle `relations()`. Non-table entries
36
+ are ignored at runtime when looking up tables by key.
37
+
33
38
  ### Operation<TResult>
34
39
 
35
40
  ```typescript
@@ -60,6 +65,24 @@ db.delete(table).where(cond): DeleteReturningBuilder
60
65
 
61
66
  All write builders are `Operation<void>` with an optional `.returning()` that changes return type.
62
67
 
68
+ #### Single-op shorthand (no compose required)
69
+
70
+ For a single mutation, call `.run()` directly on the Operation -- there's no need to wrap
71
+ it in `compose()` or `db.batch()`. Reserve those for multi-op workflows.
72
+
73
+ ```typescript
74
+ // Insert
75
+ const card = await db.insert(cards).values({ title: "New" }).returning().run();
76
+
77
+ // Update
78
+ await db.update(cards).set({ archived: true }).where(eq(cards.id, id)).run();
79
+
80
+ // Delete
81
+ await db.delete(cards).where(eq(cards.id, id)).run();
82
+ ```
83
+
84
+ `.run()` arguments are optional -- only pass `params` when your query uses `sql.placeholder()`.
85
+
63
86
  ### compose(operations, executor): Operation<TResult>
64
87
 
65
88
  ```typescript
@@ -89,9 +112,70 @@ await op.run(); // runs operations in order, returns results array
89
112
 
90
113
  Runs operations in order, returns results array. Shorthand for `compose` when there are no data dependencies between operations.
91
114
 
115
+ ### composeSequentialCallback(db, callback): Operation<TResult>
116
+
117
+ For workflows where later operations depend on the **results** of earlier ones
118
+ (e.g. inserting a child row that references the inserted parent's id):
119
+
120
+ ```typescript
121
+ import { composeSequentialCallback } from "@cfast/db";
122
+
123
+ const op = composeSequentialCallback(db, async (tx) => {
124
+ const card = await tx.insert(cards).values({ title: "New" }).returning().run();
125
+ await tx.insert(activityLog).values({
126
+ cardId: (card as { id: string }).id,
127
+ action: "card_created",
128
+ }).run();
129
+ return card;
130
+ });
131
+
132
+ await op.permissionsAsync(); // [{ create, cards }, { create, activityLog }]
133
+ await op.run();
134
+ ```
135
+
136
+ The callback runs **twice**: first against a tracking proxy that records each
137
+ operation's permission descriptors and returns sentinel values from `.run()`,
138
+ then against the real db. Because of the dry-run pass, the callback **must be
139
+ free of side effects outside of `db.*` calls** (no `fetch`, no global state
140
+ mutation, no `console.log` you care about).
141
+
142
+ `op.permissions` is populated asynchronously after the dry-run resolves; use
143
+ `op.permissionsAsync()` for a guaranteed-populated array. `op.run()` always
144
+ awaits the dry-run internally, so permissions are checked on every sub-op.
145
+
146
+ Use this only when you actually need data dependencies. For independent
147
+ operations, prefer `composeSequential([...])` (faster, fully synchronous
148
+ permissions).
149
+
92
150
  ### db.batch(operations): Operation<unknown[]>
93
151
 
94
- Runs operations sequentially with merged permissions. Not D1 native batch.
152
+ Runs multiple operations with merged, deduplicated permissions. **Atomic** when
153
+ every operation was produced by `db.insert/update/delete`: the batch is sent to
154
+ D1's native `batch()` API, which executes the statements as a single transaction
155
+ and rolls everything back if any statement fails.
156
+
157
+ Use this whenever you need transactional safety -- for example, decrementing
158
+ stock across multiple products during checkout, or inserting an order plus its
159
+ line items together:
160
+
161
+ ```typescript
162
+ // Atomic checkout: stock decrements + order creation either all succeed or
163
+ // all roll back. Permissions for every sub-op are checked upfront.
164
+ await db.batch([
165
+ db.update(products).set({ stock: sql`stock - 1` }).where(eq(products.id, id1)),
166
+ db.update(products).set({ stock: sql`stock - 1` }).where(eq(products.id, id2)),
167
+ db.insert(orders).values({ userId, items: [...] }),
168
+ ]).run();
169
+ ```
170
+
171
+ Permission checks happen **before** any SQL is issued. If the user lacks any
172
+ required grant, the batch throws and zero statements run.
173
+
174
+ Operations produced by `compose()`/`composeSequential()` executors don't carry
175
+ the batchable hook; mixing them into `db.batch([...])` causes a fallback to
176
+ sequential execution (and loses the atomicity guarantee). For pure compose
177
+ workflows that need atomicity, build the underlying ops with `db.insert/update/
178
+ delete` directly and pass them straight to `db.batch([...])`.
95
179
 
96
180
  ### db.unsafe(): Db
97
181
 
@@ -171,12 +255,76 @@ export async function action({ context, request }) {
171
255
  }
172
256
  ```
173
257
 
258
+ ### Cross-table grants ("show recipes from my friends")
259
+
260
+ When a row-level filter needs data from another table, declare a `with` map on the grant. `@cfast/db` resolves every prerequisite once per request and threads the result into the `where` clause:
261
+
262
+ ```typescript
263
+ // permissions.ts
264
+ export const permissions = definePermissions<AppUser, typeof schema>()({
265
+ roles: ["user"] as const,
266
+ grants: (g) => ({
267
+ user: [
268
+ g("read", recipes, {
269
+ with: {
270
+ friendIds: async (user, db) => {
271
+ const rows = await db
272
+ .query(friendGrants)
273
+ .findMany({ where: eq(friendGrants.grantee, user.id) })
274
+ .run();
275
+ return (rows as { target: string }[]).map((r) => r.target);
276
+ },
277
+ },
278
+ where: (recipe, user, { friendIds }) =>
279
+ or(
280
+ eq(recipe.visibility, "public"),
281
+ eq(recipe.authorId, user.id),
282
+ inArray(recipe.authorId, friendIds as string[]),
283
+ ),
284
+ }),
285
+ ],
286
+ }),
287
+ });
288
+
289
+ // loader.ts -- one createDb() per request, one friend-grant fetch per request
290
+ export async function loader({ context, request }) {
291
+ const { user, grants } = await auth.requireUser(request);
292
+ const db = createDb({ d1: context.env.DB, schema, grants, user });
293
+
294
+ // Both reads share the cached friendIds lookup -- it runs once total.
295
+ const myRecipes = await db.query(recipes).findMany({ limit: 10 }).run();
296
+ const popular = await db.query(recipes).paginate(params).run();
297
+
298
+ return { myRecipes, popular };
299
+ }
300
+ ```
301
+
174
302
  ## Integration
175
303
 
176
304
  - **@cfast/permissions** -- `grants` come from `resolveGrants(permissions, user.roles)`. Permission WHERE clauses are defined via `grant()` in your permissions config.
177
305
  - **@cfast/auth** -- `auth.requireUser(request)` returns `{ user, grants }` which you pass directly to `createDb()`.
178
306
  - **@cfast/actions** -- Actions define operations using `@cfast/db`. The framework extracts `.permissions` for client-side UI adaptation.
179
307
 
308
+ ## Schema Gotchas
309
+
310
+ ### Self-referential foreign keys
311
+
312
+ 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`:
313
+
314
+ ```typescript
315
+ import { sqliteTable, text, type AnySQLiteColumn } from "drizzle-orm/sqlite-core";
316
+
317
+ export const folders = sqliteTable("folders", {
318
+ id: text("id").primaryKey(),
319
+ name: text("name").notNull(),
320
+ parentFolderId: text("parent_folder_id").references(
321
+ (): AnySQLiteColumn => folders.id,
322
+ ),
323
+ });
324
+ ```
325
+
326
+ Use `AnyPgColumn` / `AnyMySqlColumn` for other dialects. The same pattern applies to any self-reference (comment threads, org charts, category trees).
327
+
180
328
  ## Common Mistakes
181
329
 
182
330
  - **Forgetting `.run()`** -- Operations are lazy. `db.query(t).findMany()` returns an Operation, not results. You must call `.run()`.
@@ -184,6 +332,7 @@ export async function action({ context, request }) {
184
332
  - **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
333
  - **Using `db.unsafe()` for admin endpoints** -- Use a role with `grant("manage", "all")` instead. Reserve `unsafe()` for genuinely user-less contexts (cron, migrations).
186
334
  - **Expecting relational `with` to have permission filters** -- Permission WHERE clauses only apply to the root table, not joined relations.
335
+ - **Self-referential FK without `AnySQLiteColumn` annotation** -- TypeScript fails with TS7022. See "Schema Gotchas" above.
187
336
 
188
337
  ## See Also
189
338
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfast/db",
3
- "version": "0.1.1",
3
+ "version": "0.3.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.3.0"
41
41
  },
42
42
  "peerDependenciesMeta": {
43
43
  "@cloudflare/workers-types": {