@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 +127 -32
- package/dist/index.js +350 -74
- package/llms.txt +153 -4
- package/package.json +2 -2
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,
|
|
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
|
-
/**
|
|
352
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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<
|
|
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<
|
|
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<
|
|
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<
|
|
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 ||
|
|
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(
|
|
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
|
|
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
|
|
77
|
-
if (
|
|
102
|
+
const matching = resolvePermissionFilters(grants, action, table);
|
|
103
|
+
if (matching.length === 0) return void 0;
|
|
78
104
|
const columns = table;
|
|
79
|
-
const
|
|
80
|
-
|
|
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
|
|
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,
|
|
311
|
-
|
|
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
|
-
|
|
315
|
-
await execute(false);
|
|
316
|
-
config.onMutate?.(tableName);
|
|
392
|
+
await runOnce(false);
|
|
317
393
|
},
|
|
318
394
|
returning() {
|
|
319
|
-
|
|
395
|
+
const returningOp = {
|
|
320
396
|
permissions,
|
|
321
397
|
async run(_params) {
|
|
322
|
-
|
|
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
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- **
|
|
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
|
|
|
@@ -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
|
|
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
|
|
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.
|
|
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": {
|