@cfast/db 0.5.0 → 0.6.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.
@@ -0,0 +1,221 @@
1
+ import { Grant, DrizzleTable, PermissionDescriptor } from '@cfast/permissions';
2
+ import { Table, TablesRelationalConfig, ExtractTablesWithRelations, TableRelationalConfig, BuildQueryResult } from 'drizzle-orm';
3
+
4
+ /**
5
+ * Augments a row type with a `_can` object containing per-action booleans.
6
+ *
7
+ * Every query result from `findMany` / `findFirst` includes `_can` when the
8
+ * `Db` was created with grants and a user. Each CRUD action maps to `true`
9
+ * (permitted for this row), `false` (denied), or a row-dependent boolean
10
+ * when the grant has a `where` clause.
11
+ */
12
+ type WithCan<T> = T & {
13
+ _can: Record<string, boolean>;
14
+ };
15
+ type InferRow<TTable> = TTable extends {
16
+ $inferSelect: infer R;
17
+ } ? R : Record<string, unknown>;
18
+ /** @internal */
19
+ type FindTableKeyByName<TSchema extends TablesRelationalConfig, TTableName extends string> = {
20
+ [K in keyof TSchema]: TSchema[K]["dbName"] extends TTableName ? K : never;
21
+ }[keyof TSchema];
22
+ /** @internal */
23
+ type LookupTableConfig<TFullSchema extends Record<string, unknown>, TTable> = TTable extends Table<infer TTableConfig> ? FindTableKeyByName<ExtractTablesWithRelations<TFullSchema>, TTableConfig["name"]> extends infer TKey extends keyof ExtractTablesWithRelations<TFullSchema> ? ExtractTablesWithRelations<TFullSchema>[TKey] : never : never;
24
+ type InferQueryResult<TFullSchema extends Record<string, unknown>, TTable, TConfig> = [TFullSchema] extends [Record<string, never>] ? InferRow<TTable> : LookupTableConfig<TFullSchema, TTable> extends infer TTableConfig extends TableRelationalConfig ? BuildQueryResult<ExtractTablesWithRelations<TFullSchema>, TTableConfig, TConfig extends Record<string, unknown> ? TConfig : true> : InferRow<TTable>;
25
+ type Operation<TResult> = {
26
+ permissions: PermissionDescriptor[];
27
+ run: (params?: Record<string, unknown>) => Promise<TResult>;
28
+ };
29
+ type CacheBackend = "cache-api" | "kv";
30
+ type CacheConfig = {
31
+ backend: CacheBackend;
32
+ kv?: KVNamespace;
33
+ ttl?: string;
34
+ staleWhileRevalidate?: string;
35
+ exclude?: string[];
36
+ onHit?: (key: string, table: string) => void;
37
+ onMiss?: (key: string, table: string) => void;
38
+ onInvalidate?: (tables: string[]) => void;
39
+ };
40
+ type QueryCacheOptions = false | {
41
+ ttl?: string;
42
+ staleWhileRevalidate?: string;
43
+ tags?: string[];
44
+ };
45
+ type DbConfig<TSchema extends Record<string, unknown> = Record<string, unknown>> = {
46
+ d1: D1Database;
47
+ schema: TSchema;
48
+ grants: Grant[];
49
+ user: {
50
+ id: string;
51
+ } | null;
52
+ cache?: CacheConfig | false;
53
+ };
54
+ type FindManyOptions = {
55
+ columns?: Record<string, boolean>;
56
+ where?: unknown;
57
+ orderBy?: unknown;
58
+ limit?: number;
59
+ offset?: number;
60
+ with?: Record<string, unknown>;
61
+ cache?: QueryCacheOptions;
62
+ };
63
+ type FindFirstOptions = Omit<FindManyOptions, "limit" | "offset">;
64
+ type CursorParams = {
65
+ type: "cursor";
66
+ cursor: string | null;
67
+ limit: number;
68
+ };
69
+ type OffsetParams = {
70
+ type: "offset";
71
+ page: number;
72
+ limit: number;
73
+ };
74
+ type PaginateParams = CursorParams | OffsetParams;
75
+ type CursorPage<T> = {
76
+ items: T[];
77
+ nextCursor: string | null;
78
+ };
79
+ type OffsetPage<T> = {
80
+ items: T[];
81
+ total: number;
82
+ page: number;
83
+ totalPages: number;
84
+ };
85
+ type PaginateOptions = {
86
+ columns?: Record<string, boolean>;
87
+ where?: unknown;
88
+ orderBy?: unknown;
89
+ cursorColumns?: unknown[];
90
+ orderDirection?: "asc" | "desc";
91
+ with?: Record<string, unknown>;
92
+ cache?: QueryCacheOptions;
93
+ };
94
+ type TransactionResult<T> = {
95
+ result: T;
96
+ meta: {
97
+ changes: number;
98
+ writeResults: D1Result[];
99
+ };
100
+ };
101
+ type Tx<TSchema extends Record<string, unknown> = Record<string, never>> = {
102
+ query: <TTable extends DrizzleTable>(table: TTable) => QueryBuilder<TTable, TSchema>;
103
+ insert: <TTable extends DrizzleTable>(table: TTable) => InsertBuilder<TTable>;
104
+ update: <TTable extends DrizzleTable>(table: TTable) => UpdateBuilder<TTable>;
105
+ delete: <TTable extends DrizzleTable>(table: TTable) => DeleteBuilder<TTable>;
106
+ transaction: <T>(callback: (tx: Tx<TSchema>) => Promise<T>) => Promise<T>;
107
+ };
108
+ type Db<TSchema extends Record<string, unknown> = Record<string, never>> = {
109
+ query: <TTable extends DrizzleTable>(table: TTable) => QueryBuilder<TTable, TSchema>;
110
+ insert: <TTable extends DrizzleTable>(table: TTable) => InsertBuilder<TTable>;
111
+ update: <TTable extends DrizzleTable>(table: TTable) => UpdateBuilder<TTable>;
112
+ delete: <TTable extends DrizzleTable>(table: TTable) => DeleteBuilder<TTable>;
113
+ unsafe: () => Db<TSchema>;
114
+ batch: (operations: Operation<unknown>[]) => Operation<unknown[]>;
115
+ /**
116
+ * Runs a callback inside a transaction with atomic commit-or-rollback semantics.
117
+ *
118
+ * All writes (`tx.insert`/`tx.update`/`tx.delete`) recorded inside the callback
119
+ * are deferred and flushed together as a single atomic `db.batch([...])` when
120
+ * the callback returns successfully. If the callback throws, pending writes are
121
+ * discarded and the error is re-thrown — nothing reaches D1.
122
+ *
123
+ * Reads (`tx.query(...)`) execute eagerly so the caller can branch on their
124
+ * results. **D1 does not provide snapshot isolation across async code**, so
125
+ * reads inside a transaction can see concurrent writes. For concurrency-safe
126
+ * read-modify-write (e.g. stock decrement), combine the transaction with a
127
+ * relative SQL update and a WHERE guard:
128
+ *
129
+ * ```ts
130
+ * await db.transaction(async (tx) => {
131
+ * // The WHERE guard ensures the decrement only applies when stock is
132
+ * // still >= qty at commit time. Two concurrent transactions cannot
133
+ * // both oversell because D1's atomic batch re-evaluates the WHERE.
134
+ * await tx.update(products)
135
+ * .set({ stock: sql`stock - ${qty}` })
136
+ * .where(and(eq(products.id, pid), gte(products.stock, qty)))
137
+ * .run();
138
+ * return tx.insert(orders).values({ productId: pid, qty }).returning().run();
139
+ * });
140
+ * ```
141
+ *
142
+ * Nested `db.transaction()` calls inside the callback are flattened into the
143
+ * parent's pending queue so everything still commits atomically.
144
+ *
145
+ * @typeParam T - The return type of the callback.
146
+ * @param callback - The transaction body. Receives a `tx` handle with
147
+ * `query`/`insert`/`update`/`delete` methods (no `unsafe`, `batch`, or
148
+ * `cache` — those are intentionally off-limits inside a transaction).
149
+ * @returns A {@link TransactionResult} containing the callback's return value
150
+ * and transaction metadata (`meta.changes`, `meta.writeResults`), or rejects
151
+ * with whatever the callback threw (after rolling back pending writes).
152
+ */
153
+ transaction: <T>(callback: (tx: Tx<TSchema>) => Promise<T>) => Promise<TransactionResult<T>>;
154
+ /** Cache control methods for manual invalidation. */
155
+ cache: {
156
+ /** Invalidate cached queries by tag names and/or table names. */
157
+ invalidate: (options: {
158
+ /** Tag names to invalidate (from {@link QueryCacheOptions} `tags`). */
159
+ tags?: string[];
160
+ /** Table names to invalidate (bumps their version counters). */
161
+ tables?: string[];
162
+ }) => Promise<void>;
163
+ };
164
+ /**
165
+ * Clears the per-instance `with` lookup cache so that the next query
166
+ * re-runs every grant lookup function.
167
+ *
168
+ * In production this is rarely needed because each request gets a fresh
169
+ * `Db` via `createDb()`. In tests that reuse a single `Db` across grant
170
+ * mutations (e.g. inserting a new friendship and then querying recipes),
171
+ * call this after the mutation to avoid stale lookup results.
172
+ *
173
+ * For finer-grained control, wrap each logical request in
174
+ * {@link runWithLookupCache} instead -- that scopes the cache via
175
+ * `AsyncLocalStorage` so it is automatically discarded at scope exit.
176
+ *
177
+ * @example
178
+ * ```ts
179
+ * const db = createDb({ ... });
180
+ * await db.query(recipes).findMany().run(); // populates lookup cache
181
+ * await db.insert(friendGrants).values({ ... }).run(); // adds new grant
182
+ * db.clearLookupCache(); // drop stale lookups
183
+ * await db.query(recipes).findMany().run(); // sees new grant
184
+ * ```
185
+ */
186
+ clearLookupCache: () => void;
187
+ /**
188
+ * The Drizzle schema this Db was created with. Exposed for the seed engine
189
+ * so `seed(db)` can introspect tables without a separate schema import.
190
+ * @internal
191
+ */
192
+ readonly _schema: Record<string, unknown>;
193
+ };
194
+ type QueryBuilder<TTable extends DrizzleTable = DrizzleTable, TSchema extends Record<string, unknown> = Record<string, never>> = {
195
+ findMany: <TConfig extends FindManyOptions = Record<string, never>, TRow = InferQueryResult<TSchema, TTable, TConfig>>(options?: TConfig) => Operation<WithCan<TRow>[]>;
196
+ findFirst: <TConfig extends FindFirstOptions = Record<string, never>, TRow = InferQueryResult<TSchema, TTable, TConfig>>(options?: TConfig) => Operation<WithCan<TRow> | undefined>;
197
+ paginate: <TRow = InferRow<TTable>>(params: CursorParams | OffsetParams, options?: PaginateOptions) => Operation<CursorPage<TRow>> | Operation<OffsetPage<TRow>>;
198
+ };
199
+ type InsertBuilder<TTable extends DrizzleTable = DrizzleTable> = {
200
+ values: (values: Record<string, unknown>) => InsertReturningBuilder<TTable>;
201
+ };
202
+ type InsertReturningBuilder<TTable extends DrizzleTable = DrizzleTable> = Operation<void> & {
203
+ returning: () => Operation<InferRow<TTable>>;
204
+ };
205
+ type UpdateBuilder<TTable extends DrizzleTable = DrizzleTable> = {
206
+ set: (values: Record<string, unknown>) => UpdateWhereBuilder<TTable>;
207
+ };
208
+ type UpdateWhereBuilder<TTable extends DrizzleTable = DrizzleTable> = {
209
+ where: (condition: unknown) => UpdateReturningBuilder<TTable>;
210
+ };
211
+ type UpdateReturningBuilder<TTable extends DrizzleTable = DrizzleTable> = Operation<void> & {
212
+ returning: () => Operation<InferRow<TTable>>;
213
+ };
214
+ type DeleteBuilder<TTable extends DrizzleTable = DrizzleTable> = {
215
+ where: (condition: unknown) => DeleteReturningBuilder<TTable>;
216
+ };
217
+ type DeleteReturningBuilder<TTable extends DrizzleTable = DrizzleTable> = Operation<void> & {
218
+ returning: () => Operation<InferRow<TTable>>;
219
+ };
220
+
221
+ export type { CursorParams as C, DbConfig as D, FindFirstOptions as F, InferQueryResult as I, Operation as O, PaginateOptions as P, QueryBuilder as Q, TransactionResult as T, UpdateBuilder as U, WithCan as W, Db as a, OffsetParams as b, CacheBackend as c, CacheConfig as d, CursorPage as e, DeleteBuilder as f, DeleteReturningBuilder as g, FindManyOptions as h, InferRow as i, InsertBuilder as j, InsertReturningBuilder as k, OffsetPage as l, PaginateParams as m, QueryCacheOptions as n, Tx as o, UpdateReturningBuilder as p, UpdateWhereBuilder as q };
package/llms.txt CHANGED
@@ -47,8 +47,8 @@ type Operation<TResult> = {
47
47
  ### Reads
48
48
 
49
49
  ```typescript
50
- db.query(table).findMany(options?): Operation<Row[]>
51
- db.query(table).findFirst(options?): Operation<Row | undefined>
50
+ db.query(table).findMany(options?): Operation<WithCan<Row>[]>
51
+ db.query(table).findFirst(options?): Operation<WithCan<Row> | undefined>
52
52
  db.query(table).paginate(params, options?): Operation<CursorPage<Row> | OffsetPage<Row>>
53
53
  ```
54
54
 
@@ -102,6 +102,34 @@ const recipes = await db
102
102
  .run();
103
103
  ```
104
104
 
105
+ #### Row-level `_can` annotations (#270)
106
+
107
+ Every `findMany` and `findFirst` result includes a `_can` object with per-action
108
+ booleans, evaluated from the user's grants at query time. No opt-in required --
109
+ permissions are first-class on every row.
110
+
111
+ ```typescript
112
+ const comments = await db.query(commentsTable).findMany().run();
113
+ // comments[0]._can -> { read: true, create: true, update: true, delete: false }
114
+
115
+ // Type: WithCan<Comment>[] -- each row has _can: Record<string, boolean>
116
+ type WithCan<T> = T & { _can: Record<string, boolean> };
117
+ ```
118
+
119
+ How `_can` is computed for each CRUD action:
120
+ - **Unrestricted grant** (no `where` clause): `_can.action = true` for every row (literal `1` in SQL, no CASE needed).
121
+ - **Restricted grant** (has `where` clause): `_can.action` varies per row via a SQL `CASE WHEN <condition> THEN 1 ELSE 0 END` computed column.
122
+ - **No grant**: `_can.action = false` for every row.
123
+ - **`manage` grant**: expands to all four CRUD actions (`read`, `create`, `update`, `delete`).
124
+
125
+ The `read` action is always `true` on returned rows because the permission WHERE
126
+ clause already filters out rows the user cannot read.
127
+
128
+ `_can` is **not** added in `unsafe()` mode or when `user` is `null`.
129
+
130
+ Performance: adds N computed columns per query (where N = distinct granted CRUD
131
+ actions, typically 3-5). Unrestricted grants are free (literal `1`).
132
+
105
133
  ### Writes
106
134
 
107
135
  ```typescript
@@ -403,19 +431,36 @@ cache: {
403
431
  Per-query: `db.query(t).findMany({ cache: false })` or `{ cache: { ttl: "5m", tags: ["posts"] } }`.
404
432
  Manual invalidation: `await db.cache.invalidate({ tags: ["posts"], tables: ["posts"] })`.
405
433
 
406
- ### defineSeed
434
+ ### One-liner seed
407
435
 
408
- `defineSeed({ entries })` is the canonical way to seed a local D1 database.
409
- Accepts an ordered list of `{ table, rows }` entries — put parent tables
410
- before child tables to respect FK ordering. `seed.run(db)` hops through
411
- `db.unsafe()` internally so seeds never need their own grants. Empty `rows`
412
- arrays are skipped, so placeholder entries are safe.
436
+ All seed functionality lives under the `@cfast/db/seed` entrypoint, which
437
+ bundles `@faker-js/faker` so you never install or import it yourself. The
438
+ simplest API is a one-liner:
413
439
 
414
440
  ```typescript
415
- import { createDb, defineSeed } from "@cfast/db";
441
+ import { seed } from "@cfast/db/seed";
442
+ import { createDb } from "@cfast/db";
416
443
  import * as schema from "~/db/schema";
417
444
 
418
- const seed = defineSeed({
445
+ const db = createDb({ d1, schema, grants: [], user: null });
446
+ await seed(db);
447
+ ```
448
+
449
+ `seed(db)` introspects the schema from the Db instance, topologically sorts
450
+ tables, generates realistic data via faker, and inserts it through
451
+ `db.unsafe()`. Pass `{ transcript: "./seed.sql" }` as the second argument
452
+ to also write the INSERT statements to a file.
453
+
454
+ ### defineSeed (static fixtures)
455
+
456
+ For hand-authored fixture data (not generated), use `defineSeed`:
457
+
458
+ ```typescript
459
+ import { defineSeed } from "@cfast/db/seed";
460
+ import { createDb } from "@cfast/db";
461
+ import * as schema from "~/db/schema";
462
+
463
+ const mySeed = defineSeed({
419
464
  entries: [
420
465
  { table: schema.users, rows: [{ id: "u-1", email: "ada@example.com", name: "Ada" }] },
421
466
  { table: schema.posts, rows: [{ id: "p-1", title: "Hello", authorId: "u-1" }] },
@@ -423,14 +468,95 @@ const seed = defineSeed({
423
468
  });
424
469
 
425
470
  const db = createDb({ d1, schema, grants: [], user: null });
426
- await seed.run(db);
471
+ await mySeed.run(db);
427
472
  ```
428
473
 
429
- Multi-row entries are flushed via `db.batch([...])` (atomic per table);
430
- single-row entries skip the batch path so Drizzle's non-empty tuple
431
- invariant holds. Use this from `scripts/seed.ts` and run it via
432
- `pnpm db:seed:local` (the scaffolded `create-cfast` package ships this
433
- script and command out of the box).
474
+ ### Schema-driven seed generation (createSeedEngine)
475
+
476
+ `createSeedEngine(schema)` introspects Drizzle schema metadata to
477
+ auto-generate realistic test data using the bundled faker. Handles FK
478
+ resolution, topological ordering, `per` relational generation,
479
+ many-to-many deduplication, and auth table detection.
480
+
481
+ #### Column-level seed overrides
482
+
483
+ `seedConfig(column, fn)` attaches a custom generator to a column:
484
+
485
+ ```typescript
486
+ import { seedConfig, tableSeed } from "@cfast/db/seed";
487
+
488
+ const posts = sqliteTable("posts", {
489
+ id: text("id").primaryKey(),
490
+ title: seedConfig(text("title").notNull(), f => f.lorem.sentence()),
491
+ content: seedConfig(text("content"), f => f.lorem.paragraphs(3)),
492
+ authorId: text("author_id").references(() => users.id),
493
+ });
494
+ ```
495
+
496
+ Fields without `seedConfig()` are auto-inferred from column type:
497
+ - `text` -> `faker.lorem.words()`
498
+ - `integer`/`real` -> `faker.number.int()`/`faker.number.float()`
499
+ - `timestamp` -> `faker.date.recent()`
500
+ - `boolean` -> `faker.datatype.boolean()`
501
+ - FK (`.references()`) -> random existing row from referenced table
502
+ - Primary key -> auto-generated UUID
503
+ - Nullable -> occasionally `null` (~10%)
504
+ - Columns with `$defaultFn` or static defaults are skipped
505
+
506
+ #### Table-level seed config
507
+
508
+ `tableSeed(table, { count, per })` sets the row count and optional
509
+ per-parent relationship:
510
+
511
+ ```typescript
512
+ const users = tableSeed(sqliteTable("users", { ... }), { count: 10 });
513
+ const posts = tableSeed(sqliteTable("posts", { ... }), { count: 5, per: users });
514
+ // -> 5 per user = 50 total. authorId auto-filled with parent user's id.
515
+ ```
516
+
517
+ #### Seed context (ctx)
518
+
519
+ Column-level seed functions receive `(faker, ctx)` where `ctx` provides:
520
+
521
+ - `ctx.parent` -- the parent row (from `per`), `undefined` for root tables
522
+ - `ctx.ref(table)` -- pick a random existing row from any table
523
+ - `ctx.index` -- zero-based position within current batch
524
+ - `ctx.all(table)` -- all generated rows for a table
525
+
526
+ ```typescript
527
+ authorId: seedConfig(text("author_id").references(() => users.id),
528
+ (faker, ctx) => ctx.parent?.id), // inherit from parent
529
+ role: seedConfig(text("role"),
530
+ (f, ctx) => ctx.index === 0 ? "admin" : f.helpers.arrayElement(["member", "viewer"])),
531
+ ```
532
+
533
+ #### Runtime API
534
+
535
+ ```typescript
536
+ import { createSeedEngine, createSingleTableSeed } from "@cfast/db/seed";
537
+
538
+ // Seed all tables from schema config
539
+ const engine = createSeedEngine(schema);
540
+ const generated = await engine.run(db);
541
+
542
+ // Also write SQL transcript
543
+ await engine.run(db, { transcript: "./seed.sql" });
544
+
545
+ // Single-table override
546
+ const singleTable = createSingleTableSeed(schema, posts, 5);
547
+ await singleTable.run(db);
548
+ ```
549
+
550
+ #### Many-to-many deduplication
551
+
552
+ Tables with 2+ FK columns are auto-detected as join tables.
553
+ Duplicate composite key combinations are silently skipped.
554
+
555
+ #### Auth integration
556
+
557
+ Tables named "users" get special handling: the first 5 rows use
558
+ `admin@example.com`, `user@example.com`, etc., and names use
559
+ `faker.person.fullName()`.
434
560
 
435
561
  ## Usage Examples
436
562
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfast/db",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Permission-aware Drizzle queries for Cloudflare D1",
5
5
  "keywords": [
6
6
  "cfast",
@@ -23,6 +23,10 @@
23
23
  ".": {
24
24
  "import": "./dist/index.js",
25
25
  "types": "./dist/index.d.ts"
26
+ },
27
+ "./seed": {
28
+ "import": "./dist/seed.js",
29
+ "types": "./dist/seed.d.ts"
26
30
  }
27
31
  },
28
32
  "files": [
@@ -33,6 +37,9 @@
33
37
  "publishConfig": {
34
38
  "access": "public"
35
39
  },
40
+ "dependencies": {
41
+ "@faker-js/faker": "^9.0.0"
42
+ },
36
43
  "peerDependencies": {
37
44
  "@cfast/permissions": ">=0.3.0 <0.6.0",
38
45
  "drizzle-orm": ">=0.35"
@@ -51,11 +58,11 @@
51
58
  "tsup": "^8",
52
59
  "typescript": "^5.7",
53
60
  "vitest": "^4.1.0",
54
- "@cfast/permissions": "0.5.1"
61
+ "@cfast/permissions": "0.6.0"
55
62
  },
56
63
  "scripts": {
57
- "build": "tsup src/index.ts --format esm --dts",
58
- "dev": "tsup src/index.ts --format esm --dts --watch",
64
+ "build": "tsup src/index.ts src/seed.ts --format esm --dts",
65
+ "dev": "tsup src/index.ts src/seed.ts --format esm --dts --watch",
59
66
  "typecheck": "tsc --noEmit",
60
67
  "lint": "eslint src/",
61
68
  "test": "vitest run"