@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.
- package/dist/index.js +137 -43
- package/llms.txt +47 -2
- 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 ||
|
|
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(
|
|
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
|
|
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
|
|
80
|
-
if (
|
|
102
|
+
const matching = resolvePermissionFilters(grants, action, table);
|
|
103
|
+
if (matching.length === 0) return void 0;
|
|
81
104
|
const columns = table;
|
|
82
|
-
const
|
|
83
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- **
|
|
14
|
-
-
|
|
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.
|
|
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.
|
|
40
|
+
"@cfast/permissions": "0.3.0"
|
|
41
41
|
},
|
|
42
42
|
"peerDependenciesMeta": {
|
|
43
43
|
"@cloudflare/workers-types": {
|