@suluk/drizzle 0.1.3 → 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 +5 -3
- package/src/handlers.ts +116 -0
- package/src/index.ts +3 -0
- package/test/handlers.test.ts +103 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/drizzle",
|
|
3
|
-
"version": "0.1.
|
|
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"
|
|
@@ -26,13 +26,15 @@
|
|
|
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/handlers.ts
ADDED
|
@@ -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
|
@@ -41,3 +41,6 @@ export {
|
|
|
41
41
|
} from "./mutations";
|
|
42
42
|
// SQLite CREATE TABLE generator — build a dev in-memory schema FROM the Drizzle tables (no hand-mirrored SQL drift).
|
|
43
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";
|
|
@@ -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
|
+
});
|