@cfast/db 0.0.1
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/LICENSE +21 -0
- package/README.md +686 -0
- package/dist/index.d.ts +636 -0
- package/dist/index.js +628 -0
- package/package.json +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
# @cfast/db
|
|
2
|
+
|
|
3
|
+
**Lazy, permission-aware Drizzle operations for Cloudflare D1.**
|
|
4
|
+
|
|
5
|
+
`@cfast/db` wraps Drizzle ORM and returns lazy `Operation` objects instead of promises. An `Operation` knows which permissions it requires (`.permissions`) and can be inspected before execution (`.run()`). When you call `.run()`, permissions are checked first. If denied, a `ForbiddenError` is thrown before any SQL touches the database.
|
|
6
|
+
|
|
7
|
+
This is application-level Row-Level Security for D1 (which has no native RLS). It's not a separate middleware layer you have to remember to apply — it's the return type of every query.
|
|
8
|
+
|
|
9
|
+
## Why This Exists
|
|
10
|
+
|
|
11
|
+
D1 is SQLite. It has no `CREATE POLICY`, no `GRANT`, no row-level security. Every query runs with full access.
|
|
12
|
+
|
|
13
|
+
Most applications solve this by scattering permission checks across route handlers, loaders, and actions. This works until it doesn't — someone forgets a check, a new endpoint bypasses the middleware, or a refactor moves a query out of the handler that was guarding it.
|
|
14
|
+
|
|
15
|
+
`@cfast/db` makes the permission check structural. You cannot get a query result without going through `.run()`, and `.run()` always checks permissions. The only escape hatch is `.unsafe()`, which is explicit and greppable.
|
|
16
|
+
|
|
17
|
+
## Design Decisions and Their Rationale
|
|
18
|
+
|
|
19
|
+
### Why lazy Operations instead of direct queries?
|
|
20
|
+
|
|
21
|
+
The two-phase design (inspect `.permissions`, then `.run()`) exists because permissions are useful *before* execution:
|
|
22
|
+
|
|
23
|
+
1. **UI adaptation** — You can check `.permissions` on the client to decide whether to show an edit button, without making a round-trip. `@cfast/actions` extracts `.permissions` from operations and sends them to the client for this purpose.
|
|
24
|
+
|
|
25
|
+
2. **Upfront composition** — `compose()` merges permissions from multiple operations. If any sub-operation would be denied, you know before any SQL runs. This prevents partial writes (update succeeded, but audit log insert was denied).
|
|
26
|
+
|
|
27
|
+
3. **Introspection** — Logging, debugging, and admin dashboards can inspect what an operation requires without executing it.
|
|
28
|
+
|
|
29
|
+
The cost is one extra level of indirection (`.run({})` instead of `await db.query(...)`) and a slightly larger API surface. We think this is worth it because permission bugs are hard to detect and easy to ship.
|
|
30
|
+
|
|
31
|
+
### Why `Record<string, unknown>` for params instead of type-safe placeholders?
|
|
32
|
+
|
|
33
|
+
The README spec originally called for type-level inference of `sql.placeholder()` names into the `params` type of `.run()`. The current implementation uses `Record<string, unknown>`.
|
|
34
|
+
|
|
35
|
+
**Why:** Drizzle's `sql.placeholder()` returns `SQL.Placeholder<TName>`, and Drizzle's own `.prepare().execute()` can infer the params type. However, our builder pattern constructs Operations from Drizzle query options *before* the query is prepared, and the type information about which placeholders exist lives inside opaque `unknown` types (the `where`, `orderBy` fields). Propagating placeholder types through the builder chain would require either:
|
|
36
|
+
- Carrying generic type parameters through every builder method (massive API complexity), or
|
|
37
|
+
- Using Drizzle's internal type utilities which are not part of their public API
|
|
38
|
+
|
|
39
|
+
**The tradeoff:** Runtime behavior is correct — Drizzle validates placeholder params at execution time. You lose compile-time checking of param names. This is a known gap we plan to close, but we chose shipping correct runtime behavior over blocking on perfect types.
|
|
40
|
+
|
|
41
|
+
### Why table-level cache invalidation instead of row-level?
|
|
42
|
+
|
|
43
|
+
When a mutation runs (insert, update, delete), all cached queries touching that table are invalidated by bumping an in-memory version counter. This is intentionally coarse-grained.
|
|
44
|
+
|
|
45
|
+
**Why not row-level?** Row-level invalidation requires knowing which rows a cached query *would* return after the mutation. For a query like `SELECT * FROM posts WHERE category = 'tech' ORDER BY created_at LIMIT 10`, an insert into the `tech` category might push a row into the result set or not, depending on its `created_at`. Determining this correctly requires re-executing the query, which defeats the purpose of caching.
|
|
46
|
+
|
|
47
|
+
**The tradeoff:** More cache misses than necessary — updating one post invalidates cached queries for all posts. For D1 workloads (typically low-to-medium traffic, single-region primary), the extra D1 reads are cheap. The simplicity eliminates an entire class of stale-data bugs.
|
|
48
|
+
|
|
49
|
+
### Why in-memory version counters?
|
|
50
|
+
|
|
51
|
+
Table version counters live in a `Map<string, number>` inside the `CacheManager` instance. They are not persisted.
|
|
52
|
+
|
|
53
|
+
**What this means:** When a Cloudflare Worker cold-starts, all version counters reset to 0. Two Worker isolates handling concurrent requests each have their own counter state.
|
|
54
|
+
|
|
55
|
+
**Why this is acceptable:**
|
|
56
|
+
- Cache keys include the version number, so a reset to 0 produces keys that may collide with *previous* version-0 keys. But those old cache entries have TTLs and will have expired (default 60s).
|
|
57
|
+
- Two isolates with different counters produce different cache keys, so they won't serve each other's stale data. They just won't share cache hits either.
|
|
58
|
+
- The alternative (storing versions in KV or D1) adds latency to every mutation and creates its own consistency problems.
|
|
59
|
+
|
|
60
|
+
**When this breaks:** If you set very long TTLs (hours) and have frequent cold starts, you could see stale data after a restart. Keep TTLs short (seconds to low minutes) and this is a non-issue.
|
|
61
|
+
|
|
62
|
+
### Why `db.unsafe()` instead of a "system" role?
|
|
63
|
+
|
|
64
|
+
A "system" role with `grant("manage", "all")` would work at the permission level, but it conflates application roles with infrastructure concerns. An admin user and a cron job are not the same thing — the admin should be auditable, the cron job should not require a user record in the database.
|
|
65
|
+
|
|
66
|
+
`db.unsafe()` returns a new `Db` instance where every operation has empty `.permissions` and `.run()` skips checking entirely. This is:
|
|
67
|
+
- **Greppable** — `git grep '.unsafe()'` finds every permission bypass in your codebase
|
|
68
|
+
- **Scoped** — `unsafe()` only affects the `Db` instance it returns, not the original
|
|
69
|
+
- **Honest** — it doesn't pretend to check permissions. There's no "system" role grant that could be accidentally inherited or misconfigured
|
|
70
|
+
|
|
71
|
+
### Why `compose()` takes an executor function?
|
|
72
|
+
|
|
73
|
+
`compose()` could have been simpler — just merge permissions and return a batch. Instead, it takes an executor function that receives `run` functions for each sub-operation:
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
compose([opA, opB], async (runA, runB) => {
|
|
77
|
+
const a = await runA({});
|
|
78
|
+
await runB({ targetId: a.id });
|
|
79
|
+
return { done: true };
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Why:** Operations often depend on each other's results. The audit log needs the ID of the post that was just updated. A notification needs the author's email from the updated row. The executor pattern lets you wire these data dependencies while still getting merged permissions and all-or-nothing checking.
|
|
84
|
+
|
|
85
|
+
The alternative (a simple batch that runs operations independently) can't express data dependencies between operations.
|
|
86
|
+
|
|
87
|
+
### Why does `batch()` run operations sequentially?
|
|
88
|
+
|
|
89
|
+
`db.batch()` iterates operations in order, awaiting each `.run()`. It does not use D1's native `d1.batch()` API.
|
|
90
|
+
|
|
91
|
+
**Why:** Each operation's `.run()` performs its own permission check and WHERE clause injection. D1's native batch takes raw prepared statements, which would bypass the permission layer. To use native batch, we'd need to separate "prepare the statement with permissions applied" from "execute it", which is a different internal architecture.
|
|
92
|
+
|
|
93
|
+
**The tradeoff:** You don't get D1's batch optimization (single round-trip for multiple statements). For most applications, the difference is negligible — D1 is colocated with the Worker, so per-query latency is sub-millisecond. If you need true batch performance for bulk operations, use `db.unsafe()` and call D1's batch API directly.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## API Reference
|
|
98
|
+
|
|
99
|
+
### `createDb(config)`
|
|
100
|
+
|
|
101
|
+
Creates a permission-aware database instance. Call this once per request, passing the authenticated user.
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
import { createDb } from "@cfast/db";
|
|
105
|
+
import { permissions } from "./permissions";
|
|
106
|
+
import * as schema from "./schema";
|
|
107
|
+
|
|
108
|
+
const db = createDb({
|
|
109
|
+
d1: env.DB,
|
|
110
|
+
schema,
|
|
111
|
+
permissions,
|
|
112
|
+
user: currentUser,
|
|
113
|
+
cache: { backend: "cache-api" },
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
| Field | Type | Required | Description |
|
|
118
|
+
|---|---|---|---|
|
|
119
|
+
| `d1` | `D1Database` | Yes | Your Cloudflare D1 binding. |
|
|
120
|
+
| `schema` | `Record<string, Table>` | Yes | Your Drizzle schema. Must be `import * as schema` — the keys must match your table variable names because Drizzle's relational query API uses them for lookup. |
|
|
121
|
+
| `permissions` | `Permissions` | Yes | The result of `definePermissions()` from `@cfast/permissions`. Contains resolved grants with role hierarchy already flattened. |
|
|
122
|
+
| `user` | `{ id: string; role: string } \| null` | Yes | The current user. `null` means anonymous — the role `"anonymous"` is used for permission checks. The `id` field is passed to `where` clause functions in grants for row-level filtering. |
|
|
123
|
+
| `cache` | `CacheConfig \| false` | No | Cache configuration. Defaults to `{ backend: "cache-api" }`. Pass `false` to disable the cache manager entirely. |
|
|
124
|
+
|
|
125
|
+
**Returns:** A `Db` instance. This instance is bound to the user you passed — create a new one per request.
|
|
126
|
+
|
|
127
|
+
**Why per-request?** The `Db` instance captures the user at creation time. Permission checks and WHERE clause injection use this captured user. Sharing a `Db` across requests would apply one user's permissions to another user's queries.
|
|
128
|
+
|
|
129
|
+
### `Operation<TResult>`
|
|
130
|
+
|
|
131
|
+
The core type. Every method on `db` returns an `Operation` instead of a promise.
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
type Operation<TResult> = {
|
|
135
|
+
permissions: PermissionDescriptor[];
|
|
136
|
+
run: (params: Record<string, unknown>) => Promise<TResult>;
|
|
137
|
+
};
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
| Property | Type | Description |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| `.permissions` | `PermissionDescriptor[]` | Structural permission requirements. Available immediately — no execution needed. Each descriptor has `{ action, table }`. |
|
|
143
|
+
| `.run(params)` | `(params: Record<string, unknown>) => Promise<TResult>` | Checks permissions, applies permission WHERE clauses, executes via Drizzle, returns results. Throws `ForbiddenError` if the user's role lacks a required grant. |
|
|
144
|
+
|
|
145
|
+
**The `params` argument:** Pass `{}` when the operation has no placeholders. When using `sql.placeholder("name")`, pass the values here — Drizzle validates them at runtime. See [Known Limitations](#known-limitations) for why this isn't type-checked at compile time.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
### Reads: `db.query(table)`
|
|
150
|
+
|
|
151
|
+
Returns a query builder with `findMany` and `findFirst`. Both return Operations.
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
const allVisible = db.query(posts).findMany();
|
|
155
|
+
|
|
156
|
+
allVisible.permissions;
|
|
157
|
+
// → [{ action: "read", table: posts }]
|
|
158
|
+
|
|
159
|
+
await allVisible.run({});
|
|
160
|
+
// Anonymous user → SELECT * FROM posts WHERE published = 1
|
|
161
|
+
// Editor user → SELECT * FROM posts (no permission filter)
|
|
162
|
+
// Admin user → SELECT * FROM posts (manage grants have no filter)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**How permission WHERE clauses are applied:**
|
|
166
|
+
|
|
167
|
+
1. At `.run()` time, the library looks up the user's role in `permissions.resolvedGrants`
|
|
168
|
+
2. It finds all grants matching the action ("read") and the table
|
|
169
|
+
3. If **any** matching grant has no `where` clause, the user has unrestricted access — no filter is added. This is because an unrestricted grant is strictly more permissive than any filtered grant.
|
|
170
|
+
4. If **all** matching grants have `where` clauses, they're combined with `OR` (the user can see rows matching *any* of their grants)
|
|
171
|
+
5. The resulting permission filter is combined with the user's own `where` clause via `AND`
|
|
172
|
+
|
|
173
|
+
This means: `user_filter AND (perm_filter_1 OR perm_filter_2)`. The permission filter is always applied — you cannot accidentally bypass it.
|
|
174
|
+
|
|
175
|
+
**Query options:**
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
db.query(posts).findMany({
|
|
179
|
+
columns: { id: true, title: true }, // Column selection
|
|
180
|
+
where: eq(posts.category, "tech"), // User-supplied filter (AND'd with permission filter)
|
|
181
|
+
orderBy: desc(posts.createdAt), // Ordering
|
|
182
|
+
limit: 10, // Pagination
|
|
183
|
+
offset: 20,
|
|
184
|
+
with: { comments: true }, // Drizzle relational queries
|
|
185
|
+
cache: { ttl: "5m", tags: ["posts"] }, // Per-query cache control
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
db.query(posts).findFirst({
|
|
189
|
+
where: eq(posts.id, "abc-123"),
|
|
190
|
+
});
|
|
191
|
+
// Returns: TResult | undefined
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**Relational queries (`with`):** The `with` option passes through to Drizzle's relational query API. Note that permission filters are currently only applied to the root table, not to joined relations. This is a known limitation — see [Known Limitations](#known-limitations).
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
### Writes: `db.insert(table)`
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
const createPost = db.insert(posts).values({
|
|
202
|
+
title: "Hello World",
|
|
203
|
+
authorId: currentUser.id,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
createPost.permissions;
|
|
207
|
+
// → [{ action: "create", table: posts }]
|
|
208
|
+
|
|
209
|
+
await createPost.run({});
|
|
210
|
+
// Checks: does this user's role have a "create" grant on posts?
|
|
211
|
+
// If yes → INSERT INTO posts ...
|
|
212
|
+
// If no → throws ForbiddenError
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**With `.returning()`:**
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
const createPost = db.insert(posts)
|
|
219
|
+
.values({ title: "Hello", authorId: currentUser.id })
|
|
220
|
+
.returning();
|
|
221
|
+
|
|
222
|
+
const inserted = await createPost.run({});
|
|
223
|
+
// inserted: the full inserted row
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**No row-level WHERE injection for inserts.** Insert permissions are checked at the role level only — either the role can create rows in this table or it can't. Row-level `where` clauses on "create" grants are not applied (there's no existing row to filter against). If you need to enforce that users can only create posts with their own `authorId`, validate that in application code or use a `where` clause on update/delete to prevent tampering afterward.
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
### Writes: `db.update(table)`
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
const publishPost = db.update(posts)
|
|
234
|
+
.set({ published: true })
|
|
235
|
+
.where(eq(posts.id, "abc-123"));
|
|
236
|
+
|
|
237
|
+
publishPost.permissions;
|
|
238
|
+
// → [{ action: "update", table: posts }]
|
|
239
|
+
|
|
240
|
+
await publishPost.run({});
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**Row-level permission injection for updates:** If the user's "update" grant has a `where` clause (e.g., `where: (post, user) => eq(post.authorId, user.id)`), it's AND'd with the user-supplied condition:
|
|
244
|
+
|
|
245
|
+
```sql
|
|
246
|
+
-- User role with row-level grant:
|
|
247
|
+
UPDATE posts SET published = true
|
|
248
|
+
WHERE id = 'abc-123' AND author_id = 'user-123'
|
|
249
|
+
|
|
250
|
+
-- Editor role with unrestricted grant:
|
|
251
|
+
UPDATE posts SET published = true
|
|
252
|
+
WHERE id = 'abc-123'
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
If the permission WHERE clause causes zero rows to match, the UPDATE silently affects no rows. It does **not** throw `ForbiddenError` — the role-level check passed, but the row-level constraint narrowed the result to nothing. This matches how database-level RLS works: the query succeeds but the row is invisible to the user.
|
|
256
|
+
|
|
257
|
+
**With `.returning()`:**
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
const updated = await db.update(posts)
|
|
261
|
+
.set({ published: true })
|
|
262
|
+
.where(eq(posts.id, "abc-123"))
|
|
263
|
+
.returning()
|
|
264
|
+
.run({});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
### Writes: `db.delete(table)`
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
const removePost = db.delete(posts)
|
|
273
|
+
.where(eq(posts.id, "abc-123"));
|
|
274
|
+
|
|
275
|
+
removePost.permissions;
|
|
276
|
+
// → [{ action: "delete", table: posts }]
|
|
277
|
+
|
|
278
|
+
await removePost.run({});
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Same row-level WHERE injection as `update()`. Same silent-no-match behavior.
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
### `db.unsafe()`
|
|
286
|
+
|
|
287
|
+
Returns a new `Db` instance that skips all permission checks.
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
const op = db.unsafe().delete(posts).where(eq(posts.id, "abc-123"));
|
|
291
|
+
|
|
292
|
+
op.permissions;
|
|
293
|
+
// → [] (empty — no permissions required)
|
|
294
|
+
|
|
295
|
+
await op.run({});
|
|
296
|
+
// Executes immediately, no permission check, no permission WHERE injection
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
**When to use:**
|
|
300
|
+
- Scheduled tasks / cron handlers (no authenticated user)
|
|
301
|
+
- Database migrations and seeding
|
|
302
|
+
- Background jobs that run outside of a request context
|
|
303
|
+
- System operations that intentionally bypass user-level permissions
|
|
304
|
+
|
|
305
|
+
**When NOT to use:**
|
|
306
|
+
- Admin endpoints — use a role with appropriate grants instead, so admin actions are still auditable through the permission system
|
|
307
|
+
- "I don't want to set up permissions yet" — use `grant("manage", "all")` on a development role instead. `unsafe()` should be reserved for genuinely user-less contexts.
|
|
308
|
+
|
|
309
|
+
**Auditability:** `git grep '.unsafe()'` finds every permission bypass in your codebase. This is intentional — if you're reviewing a PR and see `.unsafe()`, it should prompt a conversation about whether that's appropriate.
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
313
|
+
### `compose(operations, executor)`
|
|
314
|
+
|
|
315
|
+
Merges multiple operations into a single operation with combined, deduplicated permissions.
|
|
316
|
+
|
|
317
|
+
```typescript
|
|
318
|
+
import { compose } from "@cfast/db";
|
|
319
|
+
|
|
320
|
+
const publishWorkflow = compose(
|
|
321
|
+
[updatePost, insertAuditLog],
|
|
322
|
+
async (doUpdate, doAudit) => {
|
|
323
|
+
const updated = await doUpdate({});
|
|
324
|
+
await doAudit({});
|
|
325
|
+
return { published: true };
|
|
326
|
+
},
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
publishWorkflow.permissions;
|
|
330
|
+
// → [{ action: "update", table: posts }, { action: "create", table: auditLogs }]
|
|
331
|
+
|
|
332
|
+
await publishWorkflow.run({});
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
| Parameter | Type | Description |
|
|
336
|
+
|---|---|---|
|
|
337
|
+
| `operations` | `Operation<unknown>[]` | Operations to compose. |
|
|
338
|
+
| `executor` | `(...runs) => R \| Promise<R>` | Receives a `run` function for each operation, in order. You control execution order, data flow between operations, and the return value. |
|
|
339
|
+
|
|
340
|
+
**Permission deduplication:** If multiple operations require `{ action: "update", table: posts }`, the composed permissions list it once. Deduplication uses `action:tableName` as the key.
|
|
341
|
+
|
|
342
|
+
**Nesting:** `compose()` returns an `Operation`, so composed operations can themselves be composed:
|
|
343
|
+
|
|
344
|
+
```typescript
|
|
345
|
+
const fullWorkflow = compose(
|
|
346
|
+
[publishWorkflow, sendNotification],
|
|
347
|
+
async (doPublish, doNotify) => {
|
|
348
|
+
await doPublish({});
|
|
349
|
+
await doNotify({});
|
|
350
|
+
},
|
|
351
|
+
);
|
|
352
|
+
// fullWorkflow.permissions includes all permissions from publishWorkflow + sendNotification
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
**Important:** `compose()` itself does not check permissions — it only merges them. Each sub-operation's `.run()` still performs its own permission check when the executor calls it. This means compose is purely a grouping mechanism for inspecting combined permissions. If you need all-or-nothing checking before any SQL runs, check the composed `.permissions` yourself before calling `.run()`.
|
|
356
|
+
|
|
357
|
+
---
|
|
358
|
+
|
|
359
|
+
### `db.batch(operations)`
|
|
360
|
+
|
|
361
|
+
Groups multiple operations into a single operation with merged permissions.
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
const batchOp = db.batch([
|
|
365
|
+
db.insert(posts).values({ title: "Post 1" }),
|
|
366
|
+
db.insert(posts).values({ title: "Post 2" }),
|
|
367
|
+
db.insert(auditLogs).values({ action: "bulk_create" }),
|
|
368
|
+
]);
|
|
369
|
+
|
|
370
|
+
batchOp.permissions;
|
|
371
|
+
// → [{ action: "create", table: posts }, { action: "create", table: auditLogs }]
|
|
372
|
+
|
|
373
|
+
await batchOp.run({});
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
**Implementation detail:** `batch()` runs operations sequentially via their individual `.run()` methods, not via D1's native batch API. See [Design Decisions](#why-does-batch-run-operations-sequentially) for why.
|
|
377
|
+
|
|
378
|
+
---
|
|
379
|
+
|
|
380
|
+
## Caching
|
|
381
|
+
|
|
382
|
+
The cache layer manages table-level version counters and provides Cache API / KV backends. Mutations automatically invalidate affected tables.
|
|
383
|
+
|
|
384
|
+
### Cache Configuration
|
|
385
|
+
|
|
386
|
+
```typescript
|
|
387
|
+
const db = createDb({
|
|
388
|
+
// ...
|
|
389
|
+
cache: {
|
|
390
|
+
backend: "cache-api", // "cache-api" | "kv"
|
|
391
|
+
ttl: "30s", // Default TTL (supports "Ns", "Nm", "Nh")
|
|
392
|
+
staleWhileRevalidate: "5m", // SWR window
|
|
393
|
+
exclude: ["sessions"], // Tables that should never be cached
|
|
394
|
+
onHit: (key, table) => {}, // Observability hook
|
|
395
|
+
onMiss: (key, table) => {},
|
|
396
|
+
onInvalidate: (tables) => {},
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
For KV backend, also pass the namespace binding:
|
|
402
|
+
|
|
403
|
+
```typescript
|
|
404
|
+
cache: {
|
|
405
|
+
backend: "kv",
|
|
406
|
+
kv: env.CACHE,
|
|
407
|
+
}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### How Cache Keys Work
|
|
411
|
+
|
|
412
|
+
```
|
|
413
|
+
cache key = cfast:{role}:v{tableVersion}:{hash(sql)}
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
The role is embedded in the key, so an anonymous user's cached result can never be served to an editor. The table version is incremented on every mutation, so stale entries are never read (they have different keys).
|
|
417
|
+
|
|
418
|
+
The hash uses a fast 32-bit string hash (djb2 variant). This is not cryptographic — it's for cache bucketing, not security. Collisions are possible but harmless (worst case: a cache miss).
|
|
419
|
+
|
|
420
|
+
### Automatic Invalidation
|
|
421
|
+
|
|
422
|
+
Every mutation builder receives an `onMutate` callback. After a successful insert/update/delete, it bumps the table's version counter. Any subsequent read generates a cache key with the new version, causing a cache miss.
|
|
423
|
+
|
|
424
|
+
```typescript
|
|
425
|
+
await db.insert(posts).values({ title: "New" }).run({});
|
|
426
|
+
// → table version for "posts" incremented
|
|
427
|
+
// → all cached "posts" queries will miss on next read
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
### Per-Query Cache Control
|
|
431
|
+
|
|
432
|
+
```typescript
|
|
433
|
+
db.query(posts).findMany({ cache: false }); // Skip cache
|
|
434
|
+
db.query(posts).findMany({ cache: { ttl: "5m" } }); // Custom TTL
|
|
435
|
+
db.query(posts).findMany({ cache: { ttl: "1m", staleWhileRevalidate: "5m" } });
|
|
436
|
+
db.query(posts).findMany({ cache: { tags: ["user-posts"] } }); // Tag for targeted invalidation
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
### Manual Invalidation
|
|
440
|
+
|
|
441
|
+
```typescript
|
|
442
|
+
await db.cache.invalidate({ tags: ["user-posts"] }); // By tag
|
|
443
|
+
await db.cache.invalidate({ tables: ["posts"] }); // By table
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
### Cache Backend Tradeoffs
|
|
447
|
+
|
|
448
|
+
| | Cache API | KV |
|
|
449
|
+
|---|---|---|
|
|
450
|
+
| **Latency** | ~0ms (edge-local) | 10-50ms (global) |
|
|
451
|
+
| **Consistency** | Per-edge-node | Eventually consistent (up to 60s) |
|
|
452
|
+
| **Hit rate** | Depends on traffic distribution | Global sharing, better for low-traffic |
|
|
453
|
+
| **Cost** | Free | KV pricing applies |
|
|
454
|
+
| **Best for** | High-traffic, multi-region | Low-traffic, global consistency |
|
|
455
|
+
|
|
456
|
+
**Cache API** stores entries in the Cloudflare edge node that processed the request. If your traffic is concentrated in one region, hit rates are excellent. If traffic is spread across many edges, each edge maintains its own cache — you pay the miss cost once per edge.
|
|
457
|
+
|
|
458
|
+
**KV** stores entries globally. Every edge reads from the same store. Better hit rates for low-traffic apps, but reads have higher latency and writes are eventually consistent (a mutation on one edge may take up to 60s to propagate).
|
|
459
|
+
|
|
460
|
+
---
|
|
461
|
+
|
|
462
|
+
## Complete Example
|
|
463
|
+
|
|
464
|
+
```typescript
|
|
465
|
+
// schema.ts
|
|
466
|
+
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
|
467
|
+
|
|
468
|
+
export const posts = sqliteTable("posts", {
|
|
469
|
+
id: text("id").primaryKey(),
|
|
470
|
+
title: text("title").notNull(),
|
|
471
|
+
content: text("content"),
|
|
472
|
+
authorId: text("author_id").notNull(),
|
|
473
|
+
published: integer("published", { mode: "boolean" }).default(false),
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
export const auditLogs = sqliteTable("audit_logs", {
|
|
477
|
+
id: text("id").primaryKey(),
|
|
478
|
+
action: text("action").notNull(),
|
|
479
|
+
targetId: text("target_id").notNull(),
|
|
480
|
+
userId: text("user_id").notNull(),
|
|
481
|
+
});
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
```typescript
|
|
485
|
+
// permissions.ts
|
|
486
|
+
import { definePermissions, grant } from "@cfast/permissions";
|
|
487
|
+
import { eq } from "drizzle-orm";
|
|
488
|
+
import { sql } from "drizzle-orm";
|
|
489
|
+
import { posts, auditLogs } from "./schema";
|
|
490
|
+
|
|
491
|
+
export const permissions = definePermissions({
|
|
492
|
+
roles: ["anonymous", "user", "editor", "admin"] as const,
|
|
493
|
+
hierarchy: {
|
|
494
|
+
user: ["anonymous"],
|
|
495
|
+
editor: ["user"],
|
|
496
|
+
admin: ["editor"],
|
|
497
|
+
},
|
|
498
|
+
grants: {
|
|
499
|
+
anonymous: [
|
|
500
|
+
grant("read", posts, { where: (cols: any) => sql`${cols.published} = 1` }),
|
|
501
|
+
],
|
|
502
|
+
user: [
|
|
503
|
+
grant("create", posts),
|
|
504
|
+
grant("update", posts, {
|
|
505
|
+
where: (cols: any, user: any) => sql`${cols.authorId} = ${user.id}`,
|
|
506
|
+
}),
|
|
507
|
+
],
|
|
508
|
+
editor: [
|
|
509
|
+
grant("read", posts), // unrestricted — overrides anonymous's filtered read
|
|
510
|
+
grant("update", posts), // unrestricted — overrides user's filtered update
|
|
511
|
+
grant("delete", posts),
|
|
512
|
+
grant("create", auditLogs),
|
|
513
|
+
],
|
|
514
|
+
admin: [
|
|
515
|
+
grant("manage", "all"),
|
|
516
|
+
],
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
```typescript
|
|
522
|
+
// worker.ts (in a React Router loader or action)
|
|
523
|
+
import { createDb, compose } from "@cfast/db";
|
|
524
|
+
import { eq } from "drizzle-orm";
|
|
525
|
+
import { permissions } from "./permissions";
|
|
526
|
+
import * as schema from "./schema";
|
|
527
|
+
import { posts, auditLogs } from "./schema";
|
|
528
|
+
|
|
529
|
+
export async function loader({ context }) {
|
|
530
|
+
const db = createDb({
|
|
531
|
+
d1: context.env.DB,
|
|
532
|
+
schema,
|
|
533
|
+
permissions,
|
|
534
|
+
user: context.user, // from @cfast/auth
|
|
535
|
+
cache: false,
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// Read — permission filter applied automatically
|
|
539
|
+
const visiblePosts = await db.query(posts).findMany().run({});
|
|
540
|
+
|
|
541
|
+
// Inspect permissions without executing
|
|
542
|
+
const deleteOp = db.delete(posts).where(eq(posts.id, "abc"));
|
|
543
|
+
console.log(deleteOp.permissions);
|
|
544
|
+
// → [{ action: "delete", table: posts }]
|
|
545
|
+
|
|
546
|
+
return { posts: visiblePosts };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export async function action({ context, request }) {
|
|
550
|
+
const db = createDb({
|
|
551
|
+
d1: context.env.DB,
|
|
552
|
+
schema,
|
|
553
|
+
permissions,
|
|
554
|
+
user: context.user,
|
|
555
|
+
cache: false,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// Compose a workflow: update post + audit log
|
|
559
|
+
const publishWorkflow = compose(
|
|
560
|
+
[
|
|
561
|
+
db.update(posts).set({ published: true }).where(eq(posts.id, "abc")),
|
|
562
|
+
db.insert(auditLogs).values({
|
|
563
|
+
id: crypto.randomUUID(),
|
|
564
|
+
action: "publish",
|
|
565
|
+
targetId: "abc",
|
|
566
|
+
userId: context.user.id,
|
|
567
|
+
}),
|
|
568
|
+
],
|
|
569
|
+
async (doUpdate, doAudit) => {
|
|
570
|
+
await doUpdate({});
|
|
571
|
+
await doAudit({});
|
|
572
|
+
return { published: true };
|
|
573
|
+
},
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
// Check combined permissions
|
|
577
|
+
console.log(publishWorkflow.permissions);
|
|
578
|
+
// → [{ action: "update", table: posts }, { action: "create", table: auditLogs }]
|
|
579
|
+
|
|
580
|
+
// Execute — each sub-operation checks its own permissions
|
|
581
|
+
await publishWorkflow.run({});
|
|
582
|
+
|
|
583
|
+
return { ok: true };
|
|
584
|
+
}
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
---
|
|
588
|
+
|
|
589
|
+
## Known Limitations
|
|
590
|
+
|
|
591
|
+
### 1. `run()` params are not type-safe
|
|
592
|
+
|
|
593
|
+
`Operation.run()` accepts `Record<string, unknown>`. If you use `sql.placeholder("postId")` in a where clause, TypeScript does not enforce that you pass `{ postId: string }` to `.run()`. Drizzle validates at runtime, so incorrect params will throw — but you won't catch the error at compile time.
|
|
594
|
+
|
|
595
|
+
**Planned fix:** Extract placeholder types from the Drizzle query builder's generic parameters and propagate them through to `Operation<TResult>`.
|
|
596
|
+
|
|
597
|
+
### 2. Relational query permissions are root-table only
|
|
598
|
+
|
|
599
|
+
When using `with` for Drizzle relational queries, permission filters are only applied to the root table. Joined relations (e.g., `with: { comments: true }`) use Drizzle's default behavior without permission filtering.
|
|
600
|
+
|
|
601
|
+
**Why:** Drizzle's relational query API applies `with` as a separate nested query. Injecting permission filters into nested relations would require intercepting Drizzle's internal query building, which couples us to undocumented internals.
|
|
602
|
+
|
|
603
|
+
**Workaround:** Query relations separately and apply permissions explicitly, or add `where` clauses directly in the `with` options.
|
|
604
|
+
|
|
605
|
+
### 3. Cache reads are not wired into query builder
|
|
606
|
+
|
|
607
|
+
The `CacheManager` is fully implemented (key generation, table versioning, get/set with Cache API and KV, tag invalidation, TTL parsing, observability hooks) and mutation-side invalidation works (table versions are bumped after insert/update/delete). However, query builder's `.run()` does not currently check/populate the cache before/after hitting D1.
|
|
608
|
+
|
|
609
|
+
**What works now:** Table version tracking, manual invalidation via `db.cache.invalidate()`, observability hooks, cache configuration.
|
|
610
|
+
|
|
611
|
+
**What doesn't work yet:** Automatic read-through caching in `db.query().findMany().run()`.
|
|
612
|
+
|
|
613
|
+
### 4. `batch()` doesn't use D1's native batch API
|
|
614
|
+
|
|
615
|
+
Operations are executed sequentially. See [Design Decisions](#why-does-batch-run-operations-sequentially) for the rationale. True D1 batch can be used via `db.unsafe()` if you need the performance.
|
|
616
|
+
|
|
617
|
+
### 5. Table identity relies on reference equality
|
|
618
|
+
|
|
619
|
+
`db.query(posts)` finds the table in the schema object using `===` (reference equality). If you import your table from a different path that results in a different object (e.g., re-exporting from a barrel file that re-creates the table), the lookup will fail with "Table not found in schema". Always import tables from the same module you pass as `schema`.
|
|
620
|
+
|
|
621
|
+
---
|
|
622
|
+
|
|
623
|
+
## Integration with Other @cfast Packages
|
|
624
|
+
|
|
625
|
+
- **`@cfast/permissions`** — Provides `definePermissions()`, `grant()`, `checkPermissions()`, and `ForbiddenError`. This package compiles the resulting grants into Drizzle WHERE clauses at `.run()` time.
|
|
626
|
+
- **`@cfast/actions`** — Actions define operations using `@cfast/db`. The framework extracts `.permissions` for client-side introspection (UI adaptation) and calls `.run()` for server-side execution.
|
|
627
|
+
- **`@cfast/admin`** — Admin CRUD operations go through the same `Operation` pipeline. An admin sees all rows. A moderator sees what the moderator role allows.
|
|
628
|
+
|
|
629
|
+
## Internals
|
|
630
|
+
|
|
631
|
+
For contributors and the detail-curious. Not part of the public API.
|
|
632
|
+
|
|
633
|
+
### File Structure
|
|
634
|
+
|
|
635
|
+
| File | Responsibility |
|
|
636
|
+
|---|---|
|
|
637
|
+
| `types.ts` | All public types. No runtime code. |
|
|
638
|
+
| `permissions.ts` | `resolvePermissionFilters()` — finds matching grants and extracts WHERE clause functions. `checkOperationPermissions()` — delegates to `@cfast/permissions`'s `checkPermissions()`, throws `ForbiddenError`. |
|
|
639
|
+
| `query-builder.ts` | `createQueryBuilder()` — builds `findMany`/`findFirst` Operations. Handles WHERE injection (permission filter AND user filter). Uses Drizzle's relational query API (`db.query[key].findMany`). |
|
|
640
|
+
| `mutate-builder.ts` | `createInsertBuilder()`, `createUpdateBuilder()`, `createDeleteBuilder()`. Handles permission checking and WHERE injection for mutations. Calls `onMutate` after success for cache invalidation. |
|
|
641
|
+
| `compose.ts` | `compose()` — merges operations, deduplicates permissions, wraps executor. |
|
|
642
|
+
| `cache.ts` | `createCacheManager()` — in-memory table versioning, key generation, Cache API and KV get/set, tag tracking, TTL parsing. |
|
|
643
|
+
| `create-db.ts` | `createDb()` — wires all the above into a `Db` instance. `buildDb()` is the internal factory that accepts the `isUnsafe` flag. |
|
|
644
|
+
|
|
645
|
+
### Permission Resolution Flow
|
|
646
|
+
|
|
647
|
+
```
|
|
648
|
+
.run(params) called
|
|
649
|
+
│
|
|
650
|
+
├─ unsafe? → skip everything, execute query directly
|
|
651
|
+
│
|
|
652
|
+
├─ checkOperationPermissions(permissions, user, descriptors)
|
|
653
|
+
│ └─ calls @cfast/permissions checkPermissions(role, permissions, descriptors)
|
|
654
|
+
│ └─ if denied → throw ForbiddenError (no SQL executed)
|
|
655
|
+
│
|
|
656
|
+
├─ resolvePermissionFilters(permissions, user, action, table)
|
|
657
|
+
│ ├─ find grants matching action + table in resolvedGrants[role]
|
|
658
|
+
│ ├─ "manage" action matches any action, "all" subject matches any table
|
|
659
|
+
│ ├─ if ANY matching grant has no where clause → return [] (unrestricted)
|
|
660
|
+
│ └─ otherwise → return all where clause functions
|
|
661
|
+
│
|
|
662
|
+
├─ execute where clause functions: fn(tableColumns, user) → SQL expression
|
|
663
|
+
│ └─ multiple clauses combined with OR
|
|
664
|
+
│
|
|
665
|
+
├─ combine: AND(userWhere, OR(permFilter1, permFilter2))
|
|
666
|
+
│
|
|
667
|
+
└─ execute via Drizzle
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
### Drizzle Table Identity
|
|
671
|
+
|
|
672
|
+
Drizzle stores table names using Symbols (`Symbol.for("drizzle:Name")`), not on a plain `._` property. The `@cfast/permissions` package provides `getTableName(table)` which reads from this Symbol. Both `@cfast/permissions` and `@cfast/db` use **name-based comparison** (not reference equality) when matching grant subjects against operation tables. This means two different imports of the same logical table will match correctly as long as they share the same Drizzle name.
|
|
673
|
+
|
|
674
|
+
### Using `db.unsafe()` for System Tables
|
|
675
|
+
|
|
676
|
+
Use `db.unsafe()` when inserting into system tables (like `audit_logs`) that no user role should have direct grants for. This is intentional: audit logs are a side effect of user actions, not something users should need permission to create.
|
|
677
|
+
|
|
678
|
+
```typescript
|
|
679
|
+
// Good: audit log bypasses permission checks
|
|
680
|
+
await db.unsafe().insert(auditLogs).values({ ... }).run({});
|
|
681
|
+
|
|
682
|
+
// Bad: requires a "create" grant on audit_logs for the current user's role
|
|
683
|
+
await db.insert(auditLogs).values({ ... }).run({});
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
`git grep '.unsafe()'` finds every permission bypass in your codebase.
|