@suluk/drizzle 0.1.2 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suluk/drizzle",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Drizzle ORM schema -> v4 'Suluk' contract: table -> Zod (drizzle-zod) -> v4 Schema Objects, DB metadata, and generated CRUD RouteContracts. CANDIDATE tooling.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -19,20 +19,22 @@
19
19
  ".": "./src/index.ts"
20
20
  },
21
21
  "dependencies": {
22
- "@suluk/core": "^0.1.7",
22
+ "@suluk/core": "^0.1.11",
23
23
  "@suluk/zod": "^0.1.2",
24
24
  "@suluk/hono": "^0.1.2"
25
25
  },
26
26
  "peerDependencies": {
27
27
  "drizzle-orm": "^0.4.0 || ^0.30.0 || ^0.40.0 || ^0.45.0",
28
28
  "drizzle-zod": "^0.8.0",
29
- "zod": "^4.0.0"
29
+ "zod": "^4.0.0",
30
+ "hono": "^4.0.0"
30
31
  },
31
32
  "devDependencies": {
32
33
  "@types/bun": "latest",
33
34
  "drizzle-orm": "^0.45.2",
34
35
  "drizzle-zod": "^0.8.3",
35
- "zod": "^4.4.3"
36
+ "zod": "^4.4.3",
37
+ "hono": "^4.12.25"
36
38
  },
37
39
  "scripts": {
38
40
  "test": "bun test",
package/src/ddl.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Emit SQLite `CREATE TABLE` DDL from a drizzle table's {@link tableMetadata} — the generator that lets a dev
3
+ * in-memory bun:sqlite DB be built FROM the Drizzle schema instead of a hand-mirrored SQL string that silently
4
+ * drifts. Reads only the honest metadata floor (types, notNull, defaults, PK/autoincrement); identifiers are
5
+ * quoted so reserved words (e.g. `order`) are safe. Booleans map to INTEGER (drizzle's storage), enums to plain
6
+ * TEXT (drizzle adds no CHECK). Prod migrations stay the source of truth for prod; this is the dev-schema twin.
7
+ */
8
+ import { tableMetadata, type AnyTable, type ColumnMeta, type TableMeta } from "./meta";
9
+
10
+ const SQLITE_TYPE: Record<string, string> = {
11
+ SQLiteInteger: "INTEGER", SQLiteBoolean: "INTEGER", SQLiteTimestamp: "INTEGER",
12
+ SQLiteText: "TEXT", SQLiteTextJson: "TEXT", SQLiteReal: "REAL", SQLiteNumeric: "NUMERIC", SQLiteBlob: "BLOB",
13
+ };
14
+
15
+ /** Double-quote an identifier (table/column) so reserved words + odd names are always safe. */
16
+ const q = (id: string): string => `"${id.replace(/"/g, '""')}"`;
17
+ /** A SQL literal for a static default: booleans → 0/1, numbers verbatim, strings single-quoted. */
18
+ const lit = (v: string | number | boolean): string =>
19
+ typeof v === "boolean" ? (v ? "1" : "0") : typeof v === "number" ? String(v) : `'${String(v).replace(/'/g, "''")}'`;
20
+
21
+ function columnDDL(c: ColumnMeta): string {
22
+ const parts = [q(c.sqlName), SQLITE_TYPE[c.columnType] ?? "TEXT"];
23
+ if (c.primaryKey) parts.push(c.autoIncrement ? "PRIMARY KEY AUTOINCREMENT" : "PRIMARY KEY");
24
+ else if (c.notNull) parts.push("NOT NULL");
25
+ if (c.defaultValue !== undefined) parts.push("DEFAULT " + lit(c.defaultValue));
26
+ return parts.join(" ");
27
+ }
28
+
29
+ export interface DdlOptions {
30
+ /** prefix with `IF NOT EXISTS` (default true). */
31
+ ifNotExists?: boolean;
32
+ }
33
+
34
+ /**
35
+ * `CREATE TABLE` DDL for one drizzle table (or its already-read metadata). Single-column primary keys only — a
36
+ * table-level composite `primaryKey({columns})` isn't visible on the column-descriptor floor (it needs
37
+ * dialect-specific `getTableConfig`, deferred like FK/relation projection); such a table emits its columns without
38
+ * the composite constraint, so declare those tables' DDL by hand for now.
39
+ */
40
+ export function tableDDL(table: AnyTable | TableMeta, opts: DdlOptions = {}): string {
41
+ const m: TableMeta = "columns" in table ? table : tableMetadata(table);
42
+ const cols = m.columns.map(columnDDL).join(", ");
43
+ const exists = opts.ifNotExists === false ? "" : "IF NOT EXISTS ";
44
+ return `CREATE TABLE ${exists}${q(m.name)} (${cols});`;
45
+ }
46
+
47
+ /** `CREATE TABLE` DDL for many tables, newline-joined — the dev-schema twin of the prod migrations. */
48
+ export function schemaDDL(tables: (AnyTable | TableMeta)[], opts: DdlOptions = {}): string {
49
+ return tables.map((t) => tableDDL(t, opts)).join("\n");
50
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Generic gated CRUD HANDLERS for a drizzle table — written ONCE, driver-agnostic, so a dev server (bun:sqlite,
3
+ * synchronous) and a Workers runtime (D1, async) share ONE implementation instead of two hand-copied twins that
4
+ * drift. The db is injected as a RESOLVER `(c) => drizzle-instance` (dev: `() => db`; worker: `(c) => drizzle(c.env.DB)`),
5
+ * and every query uses an explicit awaited terminal (`.all()/.get()/.returning()/.run()`) that BOTH drivers support
6
+ * — `await` is transparent over bun:sqlite's synchronous results, so one async factory serves both.
7
+ *
8
+ * ACCESS is the @suluk/hono row-level engine: each entity's mode → per-op {@link gate} decision (anon→401 on an
9
+ * owner/admin op, non-admin→403 on an admin op) + owner-scoping (a signed-in caller only sees/mutates their rows).
10
+ * Optional `redact` strips private columns from non-admin reads; optional `afterUpdate` fires a post-update hook.
11
+ */
12
+ import { and, eq, asc, desc, getTableName, type SQL } from "drizzle-orm";
13
+ import type { Context } from "hono";
14
+ import type { SQLiteColumn, SQLiteTable } from "drizzle-orm/sqlite-core";
15
+ import { gate, policyFor, type AccessMode, type Policy } from "@suluk/hono";
16
+ import { parseListQuery } from "./query";
17
+
18
+ type AnyRow = Record<string, unknown>;
19
+ /** Structural drizzle handle — the chainable builder API both bun:sqlite and D1 expose (loosely typed, like the app twins). */
20
+ export interface CrudDb { select: (...a: unknown[]) => any; insert: (...a: unknown[]) => any; update: (...a: unknown[]) => any; delete: (...a: unknown[]) => any } // eslint-disable-line @typescript-eslint/no-explicit-any
21
+
22
+ export interface CrudHandlers {
23
+ list: (c: Context) => Promise<Response>;
24
+ get: (c: Context) => Promise<Response>;
25
+ create: (c: Context) => Promise<Response>;
26
+ update: (c: Context) => Promise<Response>;
27
+ delete: (c: Context) => Promise<Response>;
28
+ }
29
+
30
+ export interface CrudHandlerOptions {
31
+ ownerCol?: string;
32
+ access?: AccessMode;
33
+ /** override the default mode→policy preset (passed through to @suluk/hono's policyFor). */
34
+ policies?: Record<AccessMode, Policy>;
35
+ /** resolve the drizzle instance for a request (dev: `() => db`; worker: `(c) => drizzle(c.env.DB)`). */
36
+ db: (c: Context) => CrudDb;
37
+ /** the verified caller id (token/session/x-user) — used for owner-scoping + the create owner-stamp. */
38
+ principal: (c: Context) => string | null;
39
+ /** whether the caller is an admin (e.g. `c.get("isAdmin") === true`). */
40
+ isAdmin: (c: Context) => boolean;
41
+ /** strip private columns from a row for a non-admin reader (no-op by default). */
42
+ redact?: (tableName: string, row: AnyRow, admin: boolean) => AnyRow;
43
+ /** post-update hook (e.g. back-in-stock on a restock); fires only for tables in `afterUpdateTables`. */
44
+ afterUpdate?: (tableName: string, c: Context, db: CrudDb, before: AnyRow, after: AnyRow) => Promise<void>;
45
+ afterUpdateTables?: ReadonlySet<string>;
46
+ }
47
+
48
+ const denied = (c: Context, g: { status?: 401 | 403 }) => c.json({ error: g.status === 401 ? "unauthorized" : "forbidden" }, g.status ?? 403);
49
+
50
+ /** Build the five gated CRUD handlers for a drizzle table. The dev + worker callers differ ONLY in `opts.db`. */
51
+ export function crudHandlers(table: SQLiteTable, opts: CrudHandlerOptions): CrudHandlers {
52
+ const cols = table as unknown as Record<string, SQLiteColumn>;
53
+ const pk = cols.id;
54
+ const policy = policyFor(opts.access, opts.ownerCol, opts.policies);
55
+ const tname = getTableName(table);
56
+ const redact = opts.redact ?? ((_t, row) => row);
57
+ const numId = (c: Context) => Number(c.req.param("id"));
58
+ const ident = (c: Context) => ({ isAdmin: opts.isAdmin(c), principal: opts.principal(c) });
59
+ // owner-scope (when the rule demands it) AND the pk filter (for one row).
60
+ const scoped = (c: Context, scopeOwner: boolean, withPk: boolean): SQL | undefined => {
61
+ const own = scopeOwner && opts.ownerCol ? eq(cols[opts.ownerCol], opts.principal(c)) : undefined;
62
+ const id = withPk ? eq(pk, numId(c)) : undefined;
63
+ return own && id ? and(id, own) : (id ?? own);
64
+ };
65
+ return {
66
+ list: async (c) => {
67
+ const g = gate(policy.list, ident(c)); if (!g.ok) return denied(c, g);
68
+ const own = scoped(c, g.scopeOwner, false);
69
+ // owner-scope AND per-column equality filters (parseListQuery returns REAL columns only — unknown keys dropped,
70
+ // so a filter can never widen past the owner scope).
71
+ const lq = parseListQuery(c.req.query(), table);
72
+ const conds: SQL[] = [];
73
+ if (own) conds.push(own);
74
+ for (const [col, val] of Object.entries(lq.filters)) if (cols[col]) conds.push(eq(cols[col], val));
75
+ const where = conds.length > 1 ? and(...conds) : conds[0];
76
+ let qb = opts.db(c).select().from(table).$dynamic();
77
+ if (where) qb = qb.where(where);
78
+ if (lq.orderBy && cols[lq.orderBy.column]) qb = qb.orderBy(lq.orderBy.dir === "desc" ? desc(cols[lq.orderBy.column]) : asc(cols[lq.orderBy.column]));
79
+ // pagination OPT-IN: bound the page only when page/perPage is passed — otherwise the full list.
80
+ const raw = c.req.query();
81
+ if (raw.page != null || raw.perPage != null) qb = qb.limit(lq.limit).offset(lq.offset);
82
+ const admin = opts.isAdmin(c);
83
+ return c.json(((await qb.all()) as AnyRow[]).map((row) => redact(tname, row, admin)));
84
+ },
85
+ get: async (c) => {
86
+ const g = gate(policy.get, ident(c)); if (!g.ok) return denied(c, g);
87
+ const r = (await opts.db(c).select().from(table).where(scoped(c, g.scopeOwner, true)!).get()) as AnyRow | undefined;
88
+ return r ? c.json(redact(tname, r, opts.isAdmin(c))) : c.json({ error: "not found" }, 404);
89
+ },
90
+ create: async (c) => {
91
+ const g = gate(policy.create, ident(c)); if (!g.ok) return denied(c, g);
92
+ const body = (await c.req.json().catch(() => ({}))) as AnyRow;
93
+ const owner = opts.ownerCol ? { [opts.ownerCol]: opts.principal(c) } : {}; // stamp the creator/owner
94
+ const r = (await opts.db(c).insert(table).values({ ...body, ...owner }).returning()) as AnyRow[];
95
+ return c.json(r[0], 201);
96
+ },
97
+ update: async (c) => {
98
+ const g = gate(policy.update, ident(c)); if (!g.ok) return denied(c, g);
99
+ const body = (await c.req.json().catch(() => ({}))) as AnyRow;
100
+ delete body.id; if (opts.ownerCol) delete body[opts.ownerCol]; // never let the client move a row's id or owner
101
+ const w = scoped(c, g.scopeOwner, true)!;
102
+ const db = opts.db(c);
103
+ const hooked = !!opts.afterUpdate && !!opts.afterUpdateTables?.has(tname); // pre-read before-row only when a hook needs it
104
+ const before = hooked ? ((await db.select().from(table).where(w).get()) as AnyRow | undefined) : undefined;
105
+ await db.update(table).set(body).where(w).run();
106
+ const r = (await db.select().from(table).where(w).get()) as AnyRow | undefined;
107
+ if (hooked && before && r) await opts.afterUpdate!(tname, c, db, before, r);
108
+ return r ? c.json(r) : c.json({ error: "not found" }, 404);
109
+ },
110
+ delete: async (c) => {
111
+ const g = gate(policy.delete, ident(c)); if (!g.ok) return denied(c, g);
112
+ await opts.db(c).delete(table).where(scoped(c, g.scopeOwner, true)!).run();
113
+ return c.body(null, 204);
114
+ },
115
+ };
116
+ }
package/src/index.ts CHANGED
@@ -39,3 +39,8 @@ export {
39
39
  softDeleteValues, anonymizeValues, touchTimestamps, notSoftDeleted,
40
40
  type SoftDeleteOptions, type TimestampOptions,
41
41
  } from "./mutations";
42
+ // SQLite CREATE TABLE generator — build a dev in-memory schema FROM the Drizzle tables (no hand-mirrored SQL drift).
43
+ export { tableDDL, schemaDDL, type DdlOptions } from "./ddl";
44
+ // Driver-agnostic gated CRUD HANDLERS (the @suluk/hono gate engine over a drizzle table) — ONE impl for dev (bun:sqlite,
45
+ // sync) + worker (D1, async); the db is injected as a resolver, so the two runtimes share one factory, no twin drift.
46
+ export { crudHandlers, type CrudHandlers, type CrudHandlerOptions, type CrudDb } from "./handlers";
package/src/meta.ts CHANGED
@@ -12,7 +12,10 @@ export type AnyTable = Parameters<typeof getTableColumns>[0];
12
12
 
13
13
  /** One column's metadata, lifted from drizzle's column descriptor (verified against drizzle-orm 0.45). */
14
14
  export interface ColumnMeta {
15
+ /** the JS property key on the table object (e.g. `reviewId`) — the v4 component property name. */
15
16
  name: string;
17
+ /** the SQL column name (e.g. `review_id`) — what DDL + raw SQL must use; differs from `name` under camel/snake. */
18
+ sqlName: string;
16
19
  /** drizzle's coarse JS dataType, e.g. "string" | "number" | "boolean" | "date". */
17
20
  dataType: string;
18
21
  /** drizzle's concrete column type tag, e.g. "SQLiteText" | "SQLiteInteger". */
@@ -23,10 +26,15 @@ export interface ColumnMeta {
23
26
  hasDefault: boolean;
24
27
  /** Part of the (single-column) primary key. */
25
28
  primaryKey: boolean;
29
+ /** An AUTOINCREMENT primary key (SQLite integer PK declared with autoIncrement). */
30
+ autoIncrement: boolean;
26
31
  /** Carries a column-level UNIQUE constraint (drizzle's `.unique()` / `isUnique`). */
27
32
  unique: boolean;
28
33
  /** SQL CHECK/enum allowed values when the column was declared with `{ enum: [...] }`. */
29
34
  enumValues?: string[];
35
+ /** The STATIC default value (number/string/boolean) when the column carries one — for DDL emit. Absent for a
36
+ * runtime `$defaultFn` column (hasDefault true, no SQL-literal value) and for autoincrement PKs. */
37
+ defaultValue?: string | number | boolean;
30
38
  }
31
39
 
32
40
  export interface TableMeta {
@@ -57,19 +65,26 @@ export function tableMetadata(table: AnyTable): TableMeta {
57
65
  notNull: boolean;
58
66
  hasDefault: boolean;
59
67
  primary: boolean;
68
+ autoIncrement?: boolean;
60
69
  isUnique?: boolean;
61
70
  enumValues?: string[];
71
+ default?: unknown;
72
+ name?: string;
62
73
  };
74
+ const staticDefault = c.hasDefault && (typeof c.default === "string" || typeof c.default === "number" || typeof c.default === "boolean");
63
75
  const meta: ColumnMeta = {
64
76
  name,
77
+ sqlName: c.name ?? name, // drizzle's column descriptor carries the SQL name; fall back to the JS key
65
78
  dataType: c.dataType,
66
79
  columnType: c.columnType,
67
80
  notNull: !!c.notNull,
68
81
  hasDefault: !!c.hasDefault,
69
82
  primaryKey: !!c.primary,
83
+ autoIncrement: !!c.autoIncrement,
70
84
  unique: !!c.isUnique,
71
85
  // enumValues is often an empty array on non-enum columns — only surface a non-empty one.
72
86
  ...(Array.isArray(c.enumValues) && c.enumValues.length ? { enumValues: c.enumValues } : {}),
87
+ ...(staticDefault ? { defaultValue: c.default as string | number | boolean } : {}),
73
88
  };
74
89
  columns.push(meta);
75
90
  if (meta.primaryKey) primaryKey.push(name);
@@ -0,0 +1,56 @@
1
+ /**
2
+ * tableDDL / schemaDDL — generate SQLite CREATE TABLE from a Drizzle table. Asserts the type/default/PK mapping,
3
+ * reserved-word quoting, AND the SQL-column-name (snake_case) vs JS-key (camelCase) distinction, then round-trips:
4
+ * the generated DDL runs in a real bun:sqlite DB and accepts inserts that honor the defaults — proof the schema is
5
+ * valid + faithful, not just string-shaped.
6
+ */
7
+ import { test, expect, describe } from "bun:test";
8
+ import { Database } from "bun:sqlite";
9
+ import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";
10
+ import { tableDDL, schemaDDL } from "../src/index";
11
+
12
+ // "order" is a SQLite reserved word; customer_id (camel JS key) exercises the SQL-name path; boolean+enum+defaults the mapping.
13
+ const order = sqliteTable("order", {
14
+ id: integer("id").primaryKey({ autoIncrement: true }),
15
+ customerId: text("customer_id"), // camelCase key → snake_case SQL name
16
+ total: integer("total").notNull().default(0),
17
+ status: text("status", { enum: ["pending", "paid"] }).notNull().default("pending"),
18
+ paid: integer("paid", { mode: "boolean" }).notNull().default(false),
19
+ note: text("note"),
20
+ });
21
+
22
+ describe("tableDDL", () => {
23
+ const ddl = tableDDL(order);
24
+ test("quotes reserved table names + maps types + uses the SQL column name", () => {
25
+ expect(ddl).toContain('CREATE TABLE IF NOT EXISTS "order"');
26
+ expect(ddl).toContain('"customer_id" TEXT'); // SQL name, not the JS key "customerId"
27
+ expect(ddl).not.toContain("customerId");
28
+ expect(ddl).toContain('"total" INTEGER NOT NULL DEFAULT 0');
29
+ expect(ddl).toContain('"note" TEXT'); // nullable, no default
30
+ expect(ddl).not.toContain('"note" TEXT NOT NULL');
31
+ });
32
+ test("autoincrement PK, boolean→INTEGER 0/1, string defaults quoted", () => {
33
+ expect(ddl).toContain('"id" INTEGER PRIMARY KEY AUTOINCREMENT');
34
+ expect(ddl).toContain('"paid" INTEGER NOT NULL DEFAULT 0'); // boolean false → 0, type INTEGER
35
+ expect(ddl).toContain(`"status" TEXT NOT NULL DEFAULT 'pending'`);
36
+ });
37
+ test("ifNotExists:false drops the guard", () => {
38
+ expect(tableDDL(order, { ifNotExists: false })).toContain('CREATE TABLE "order"');
39
+ });
40
+ });
41
+
42
+ describe("round-trip in a real bun:sqlite DB", () => {
43
+ test("the generated DDL is valid SQL + applies the defaults under the SQL column names", () => {
44
+ const db = new Database(":memory:");
45
+ db.exec(schemaDDL([order]));
46
+ db.exec(`INSERT INTO "order" (customer_id, note) VALUES ('u1', 'hi')`); // rely on total/status/paid defaults
47
+ const row = db.query(`SELECT id, customer_id, total, status, paid, note FROM "order"`).get() as Record<string, unknown>;
48
+ expect(row.id).toBe(1); // autoincrement
49
+ expect(row.customer_id).toBe("u1"); // snake_case column exists
50
+ expect(row.total).toBe(0); // default 0
51
+ expect(row.status).toBe("pending"); // default 'pending'
52
+ expect(row.paid).toBe(0); // boolean default false → 0
53
+ expect(row.note).toBe("hi");
54
+ db.close();
55
+ });
56
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * crudHandlers — the driver-agnostic gated CRUD factory. Driven through a real Hono app over a bun:sqlite drizzle
3
+ * instance (so the awaited terminals .all()/.get()/.returning()/.run() are exercised exactly as the Worker runs them
4
+ * on D1). Covers owner-scoping, the access modes' gate (anon→401, non-admin→403), redaction, the afterUpdate hook,
5
+ * list filters + pagination, and create owner-stamp + update id/owner strip.
6
+ */
7
+ import { test, expect, describe, beforeEach } from "bun:test";
8
+ import { Hono, type Context } from "hono";
9
+ import { Database } from "bun:sqlite";
10
+ import { drizzle, type BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
11
+ import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";
12
+ import { crudHandlers, schemaDDL, type CrudDb } from "../src/index";
13
+
14
+ const thing = sqliteTable("thing", {
15
+ id: integer("id").primaryKey({ autoIncrement: true }),
16
+ ownerId: text("owner_id"),
17
+ name: text("name").notNull(),
18
+ secret: text("secret"), // a "private" column redacted from non-admin reads
19
+ });
20
+
21
+ let sqlite: Database, db: BunSQLiteDatabase;
22
+ function appFor(access: "owned" | "public" | "admin", hook?: { fired: string[] }) {
23
+ const app = new Hono();
24
+ const h = crudHandlers(thing, {
25
+ access, ownerCol: access === "owned" ? "ownerId" : undefined,
26
+ db: () => db as unknown as CrudDb,
27
+ principal: (c: Context) => c.req.header("x-user") ?? null,
28
+ isAdmin: (c: Context) => c.req.header("x-admin") === "1",
29
+ redact: (_t, row, admin) => { if (admin) return row; const { secret, ...rest } = row; return rest; },
30
+ afterUpdate: async (_t, _c, _db, before, after) => { hook?.fired.push(`${before.name}->${after.name}`); },
31
+ afterUpdateTables: new Set(["thing"]),
32
+ });
33
+ app.get("/thing", h.list); app.get("/thing/:id", h.get); app.post("/thing", h.create);
34
+ app.patch("/thing/:id", h.update); app.delete("/thing/:id", h.delete);
35
+ return app;
36
+ }
37
+ const J = (extra: Record<string, string> = {}) => ({ headers: { "content-type": "application/json", ...extra } });
38
+
39
+ beforeEach(() => { sqlite = new Database(":memory:"); sqlite.exec(schemaDDL([thing])); db = drizzle(sqlite); });
40
+
41
+ describe("owned access — owner-scoping + gate", () => {
42
+ test("anon list/create → 401; owner sees only own rows; admin sees all", async () => {
43
+ const app = appFor("owned");
44
+ expect((await app.request("/thing")).status).toBe(401); // owner op, anon
45
+ expect((await app.request("/thing", { method: "POST", ...J({ "x-user": "A" }), body: '{"name":"a1","ownerId":"HACK"}' })).status).toBe(201);
46
+ await app.request("/thing", { method: "POST", ...J({ "x-user": "B" }), body: '{"name":"b1"}' });
47
+ const aList = await (await app.request("/thing", { headers: { "x-user": "A" } })).json();
48
+ expect(aList.length).toBe(1); expect(aList[0].name).toBe("a1");
49
+ expect(aList[0].ownerId).toBe("A"); // create STAMPED the owner from principal, ignoring the body's "HACK"
50
+ const bList = await (await app.request("/thing", { headers: { "x-user": "B" } })).json();
51
+ expect(bList.length).toBe(1); expect(bList[0].name).toBe("b1");
52
+ const adminList = await (await app.request("/thing", { headers: { "x-admin": "1", "x-user": "Z" } })).json();
53
+ expect(adminList.length).toBe(2); // admin sees all
54
+ });
55
+
56
+ test("get/update/delete are owner-scoped (B can't touch A's row)", async () => {
57
+ const app = appFor("owned");
58
+ await app.request("/thing", { method: "POST", ...J({ "x-user": "A" }), body: '{"name":"a1"}' });
59
+ expect((await app.request("/thing/1", { headers: { "x-user": "A" } })).status).toBe(200);
60
+ expect((await app.request("/thing/1", { headers: { "x-user": "B" } })).status).toBe(404); // scoped away
61
+ expect((await app.request("/thing/1", { method: "PATCH", ...J({ "x-user": "B" }), body: '{"name":"hax"}' })).status).toBe(404);
62
+ expect((await app.request("/thing/1", { method: "DELETE", headers: { "x-user": "A" } })).status).toBe(204);
63
+ });
64
+ });
65
+
66
+ describe("redaction + afterUpdate hook", () => {
67
+ test("non-admin reads omit the private column; admin sees it", async () => {
68
+ const app = appFor("public");
69
+ await app.request("/thing", { method: "POST", ...J({ "x-admin": "1", "x-user": "admin" }), body: '{"name":"p","secret":"sssh"}' });
70
+ const anon = await (await app.request("/thing")).json();
71
+ expect(anon[0]).not.toHaveProperty("secret");
72
+ const admin = await (await app.request("/thing", { headers: { "x-admin": "1", "x-user": "admin" } })).json();
73
+ expect(admin[0].secret).toBe("sssh");
74
+ });
75
+
76
+ test("update fires the afterUpdate hook with before/after + strips id/owner from the body", async () => {
77
+ const fired: string[] = []; const app = appFor("owned", { fired });
78
+ await app.request("/thing", { method: "POST", ...J({ "x-user": "A" }), body: '{"name":"old"}' });
79
+ const res = await app.request("/thing/1", { method: "PATCH", ...J({ "x-user": "A" }), body: '{"name":"new","id":999,"ownerId":"HACK"}' });
80
+ const row = await res.json();
81
+ expect(row.id).toBe(1); expect(row.ownerId).toBe("A"); expect(row.name).toBe("new"); // id/owner not moved
82
+ expect(fired).toEqual(["old->new"]);
83
+ });
84
+ });
85
+
86
+ describe("public + admin modes + list features", () => {
87
+ test("public: anon reads, admin writes", async () => {
88
+ const app = appFor("public");
89
+ expect((await app.request("/thing")).status).toBe(200); // anyone reads
90
+ expect((await app.request("/thing", { method: "POST", ...J(), body: '{"name":"x"}' })).status).toBe(401); // create=admin, anon
91
+ expect((await app.request("/thing", { method: "POST", ...J({ "x-user": "u" }), body: '{"name":"x"}' })).status).toBe(403); // signed-in non-admin
92
+ expect((await app.request("/thing", { method: "POST", ...J({ "x-admin": "1", "x-user": "admin" }), body: '{"name":"x"}' })).status).toBe(201);
93
+ });
94
+
95
+ test("list filters by a real column + paginates opt-in", async () => {
96
+ const app = appFor("public");
97
+ for (const n of ["k", "k", "z"]) await app.request("/thing", { method: "POST", ...J({ "x-admin": "1", "x-user": "admin" }), body: JSON.stringify({ name: n }) });
98
+ const filtered = await (await app.request("/thing?name=k")).json();
99
+ expect(filtered.length).toBe(2);
100
+ const page = await (await app.request("/thing?perPage=1&page=1")).json();
101
+ expect(page.length).toBe(1);
102
+ });
103
+ });