@cfast/db 0.2.0 → 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.
Files changed (3) hide show
  1. package/dist/index.js +137 -43
  2. package/llms.txt +47 -2
  3. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -12,14 +12,17 @@ import {
12
12
  } from "@cfast/permissions";
13
13
  import { CRUD_ACTIONS } from "@cfast/permissions";
14
14
  function resolvePermissionFilters(grants, action, table) {
15
+ const targetName = getTableName(table);
15
16
  const matching = grants.filter((g) => {
16
17
  const actionMatch = g.action === action || g.action === "manage";
17
- 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;
18
19
  return actionMatch && tableMatch;
19
20
  });
20
21
  if (matching.length === 0) return [];
21
22
  if (matching.some((g) => !g.where)) return [];
22
- return matching.filter((g) => !!g.where).map((g) => g.where);
23
+ return matching.filter(
24
+ (g) => !!g.where
25
+ );
23
26
  }
24
27
  function grantMatchesAction(grantAction, requiredAction) {
25
28
  if (grantAction === requiredAction) return true;
@@ -74,13 +77,38 @@ function deduplicateDescriptors(descriptors) {
74
77
  }
75
78
  return result;
76
79
  }
77
- 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) {
78
101
  if (unsafe || !user) return void 0;
79
- const filters = resolvePermissionFilters(grants, action, table);
80
- if (filters.length === 0) return void 0;
102
+ const matching = resolvePermissionFilters(grants, action, table);
103
+ if (matching.length === 0) return void 0;
81
104
  const columns = table;
82
- const clauses = filters.map(
83
- (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])
84
112
  );
85
113
  return or(...clauses);
86
114
  }
@@ -191,12 +219,14 @@ function buildQueryOperation(config, db, tableKey, method, options) {
191
219
  if (!config.unsafe) {
192
220
  checkOperationPermissions(config.grants, permissions);
193
221
  }
194
- const permFilter = buildPermissionFilter(
222
+ const permFilter = await buildPermissionFilter(
195
223
  config.grants,
196
224
  "read",
197
225
  config.table,
198
226
  config.user,
199
- config.unsafe
227
+ config.unsafe,
228
+ config.getLookupDb,
229
+ config.lookupCache
200
230
  );
201
231
  const userWhere = options?.where;
202
232
  const combinedWhere = combineWhere(userWhere, permFilter);
@@ -243,16 +273,18 @@ function createQueryBuilder(config) {
243
273
  if (!tableKey) throw new Error("Table not found in schema");
244
274
  return tableKey;
245
275
  }
246
- function checkAndBuildWhere(extraWhere) {
276
+ async function checkAndBuildWhere(extraWhere) {
247
277
  if (!config.unsafe) {
248
278
  checkOperationPermissions(config.grants, permissions);
249
279
  }
250
- const permFilter = buildPermissionFilter(
280
+ const permFilter = await buildPermissionFilter(
251
281
  config.grants,
252
282
  "read",
253
283
  config.table,
254
284
  config.user,
255
- config.unsafe
285
+ config.unsafe,
286
+ config.getLookupDb,
287
+ config.lookupCache
256
288
  );
257
289
  return combineWhere(
258
290
  combineWhere(options?.where, permFilter),
@@ -282,7 +314,7 @@ function createQueryBuilder(config) {
282
314
  const cursorValues = decodeCursor(params.cursor);
283
315
  const direction = options?.orderDirection ?? "desc";
284
316
  const cursorWhere = cursorValues ? buildCursorWhere(cursorColumns, cursorValues, direction) : void 0;
285
- const combinedWhere = checkAndBuildWhere(cursorWhere);
317
+ const combinedWhere = await checkAndBuildWhere(cursorWhere);
286
318
  const queryOptions = buildBaseQueryOptions(combinedWhere);
287
319
  queryOptions.limit = params.limit + 1;
288
320
  const queryTable = getQueryTable(db, key);
@@ -303,7 +335,7 @@ function createQueryBuilder(config) {
303
335
  permissions,
304
336
  async run(_params) {
305
337
  const key = ensureTableKey();
306
- const combinedWhere = checkAndBuildWhere();
338
+ const combinedWhere = await checkAndBuildWhere();
307
339
  const queryOptions = buildBaseQueryOptions(combinedWhere);
308
340
  queryOptions.limit = params.limit;
309
341
  queryOptions.offset = (params.page - 1) * params.limit;
@@ -344,10 +376,11 @@ function checkIfNeeded(config, grants, permissions) {
344
376
  checkOperationPermissions(grants, permissions);
345
377
  }
346
378
  }
347
- function buildMutationWithReturning(config, permissions, tableName, buildQuery) {
379
+ function buildMutationWithReturning(config, permissions, tableName, buildQuery, prepareFn) {
348
380
  const drizzleDb = drizzle2(config.d1, { schema: config.schema });
349
381
  const runOnce = async (returning) => {
350
382
  checkIfNeeded(config, config.grants, permissions);
383
+ if (prepareFn) await prepareFn();
351
384
  const built = buildQuery(drizzleDb, returning);
352
385
  const result = await built.execute(returning);
353
386
  config.onMutate?.(tableName);
@@ -366,6 +399,7 @@ function buildMutationWithReturning(config, permissions, tableName, buildQuery)
366
399
  }
367
400
  };
368
401
  returningOp[BATCHABLE] = {
402
+ prepare: prepareFn,
369
403
  build: (sharedDb) => buildQuery(sharedDb, true).query,
370
404
  tableName,
371
405
  withResult: true
@@ -374,6 +408,7 @@ function buildMutationWithReturning(config, permissions, tableName, buildQuery)
374
408
  }
375
409
  };
376
410
  baseOp[BATCHABLE] = {
411
+ prepare: prepareFn,
377
412
  build: (sharedDb) => buildQuery(sharedDb, false).query,
378
413
  tableName,
379
414
  withResult: false
@@ -409,17 +444,31 @@ function createUpdateBuilder(config) {
409
444
  set(values) {
410
445
  return {
411
446
  where(condition) {
412
- const permFilter = buildPermissionFilter(
413
- config.grants,
414
- "update",
415
- config.table,
416
- config.user,
417
- config.unsafe
418
- );
419
- const combinedWhere = combineWhere(condition, permFilter);
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
+ })();
466
+ }
467
+ return preparePromise;
468
+ };
420
469
  const buildQuery = (sharedDb, returning) => {
421
470
  const base = sharedDb.update(config.table).set(values);
422
- if (combinedWhere) base.where(combinedWhere);
471
+ if (resolvedCombinedWhere) base.where(resolvedCombinedWhere);
423
472
  const query = returning ? base.returning() : base;
424
473
  return {
425
474
  query,
@@ -431,7 +480,13 @@ function createUpdateBuilder(config) {
431
480
  }
432
481
  };
433
482
  };
434
- return buildMutationWithReturning(config, permissions, tableName, buildQuery);
483
+ return buildMutationWithReturning(
484
+ config,
485
+ permissions,
486
+ tableName,
487
+ buildQuery,
488
+ prepareFn
489
+ );
435
490
  }
436
491
  };
437
492
  }
@@ -442,17 +497,31 @@ function createDeleteBuilder(config) {
442
497
  const tableName = getTableName2(config.table);
443
498
  return {
444
499
  where(condition) {
445
- const permFilter = buildPermissionFilter(
446
- config.grants,
447
- "delete",
448
- config.table,
449
- config.user,
450
- config.unsafe
451
- );
452
- const combinedWhere = combineWhere(condition, permFilter);
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
+ })();
519
+ }
520
+ return preparePromise;
521
+ };
453
522
  const buildQuery = (sharedDb, returning) => {
454
523
  const base = sharedDb.delete(config.table);
455
- if (combinedWhere) base.where(combinedWhere);
524
+ if (resolvedCombinedWhere) base.where(resolvedCombinedWhere);
456
525
  const query = returning ? base.returning() : base;
457
526
  return {
458
527
  query,
@@ -464,7 +533,13 @@ function createDeleteBuilder(config) {
464
533
  }
465
534
  };
466
535
  };
467
- return buildMutationWithReturning(config, permissions, tableName, buildQuery);
536
+ return buildMutationWithReturning(
537
+ config,
538
+ permissions,
539
+ tableName,
540
+ buildQuery,
541
+ prepareFn
542
+ );
468
543
  }
469
544
  };
470
545
  }
@@ -592,14 +667,21 @@ function createCacheManager(config) {
592
667
 
593
668
  // src/create-db.ts
594
669
  function createDb(config) {
595
- return buildDb(config, false);
670
+ const lookupCache = createLookupCache();
671
+ return buildDb(config, false, lookupCache);
596
672
  }
597
- function buildDb(config, isUnsafe) {
673
+ function buildDb(config, isUnsafe, lookupCache) {
598
674
  const cacheManager = config.cache === false ? null : createCacheManager(config.cache ?? { backend: "cache-api" });
599
675
  const onMutate = (tableName) => {
600
676
  cacheManager?.invalidateTable(tableName);
601
677
  };
602
- 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 = {
603
685
  query(table) {
604
686
  return createQueryBuilder({
605
687
  d1: config.d1,
@@ -607,7 +689,9 @@ function buildDb(config, isUnsafe) {
607
689
  grants: config.grants,
608
690
  user: config.user,
609
691
  table,
610
- unsafe: isUnsafe
692
+ unsafe: isUnsafe,
693
+ lookupCache,
694
+ getLookupDb
611
695
  });
612
696
  },
613
697
  insert(table) {
@@ -618,7 +702,9 @@ function buildDb(config, isUnsafe) {
618
702
  user: config.user,
619
703
  table,
620
704
  unsafe: isUnsafe,
621
- onMutate
705
+ onMutate,
706
+ lookupCache,
707
+ getLookupDb
622
708
  });
623
709
  },
624
710
  update(table) {
@@ -629,7 +715,9 @@ function buildDb(config, isUnsafe) {
629
715
  user: config.user,
630
716
  table,
631
717
  unsafe: isUnsafe,
632
- onMutate
718
+ onMutate,
719
+ lookupCache,
720
+ getLookupDb
633
721
  });
634
722
  },
635
723
  delete(table) {
@@ -640,11 +728,13 @@ function buildDb(config, isUnsafe) {
640
728
  user: config.user,
641
729
  table,
642
730
  unsafe: isUnsafe,
643
- onMutate
731
+ onMutate,
732
+ lookupCache,
733
+ getLookupDb
644
734
  });
645
735
  },
646
736
  unsafe() {
647
- return buildDb(config, true);
737
+ return buildDb(config, true, lookupCache);
648
738
  },
649
739
  batch(operations) {
650
740
  const allPermissions = deduplicateDescriptors(
@@ -661,6 +751,9 @@ function buildDb(config, isUnsafe) {
661
751
  const everyOpBatchable = operations.length > 0 && batchables.every((b) => b !== void 0);
662
752
  if (everyOpBatchable) {
663
753
  const sharedDb = drizzle3(config.d1, { schema: config.schema });
754
+ await Promise.all(
755
+ batchables.map((b) => b.prepare?.() ?? Promise.resolve())
756
+ );
664
757
  const items = batchables.map((b) => b.build(sharedDb));
665
758
  const batchResults = await sharedDb.batch(
666
759
  items
@@ -696,6 +789,7 @@ function buildDb(config, isUnsafe) {
696
789
  }
697
790
  }
698
791
  };
792
+ return db;
699
793
  }
700
794
 
701
795
  // src/compose.ts
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
 
@@ -254,6 +255,50 @@ export async function action({ context, request }) {
254
255
  }
255
256
  ```
256
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
+
257
302
  ## Integration
258
303
 
259
304
  - **@cfast/permissions** -- `grants` come from `resolveGrants(permissions, user.roles)`. Permission WHERE clauses are defined via `grant()` in your permissions config.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfast/db",
3
- "version": "0.2.0",
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.2.0"
40
+ "@cfast/permissions": "0.3.0"
41
41
  },
42
42
  "peerDependenciesMeta": {
43
43
  "@cloudflare/workers-types": {