@suluk/drizzle 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@suluk/drizzle",
3
+ "version": "0.1.0",
4
+ "description": "Drizzle ORM schema -> v4 'Suluk' contract: table -> Zod (drizzle-zod) -> v4 Schema Objects, DB metadata, and generated CRUD RouteContracts. CANDIDATE tooling.",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "dependencies": {
11
+ "@suluk/core": "0.1.0",
12
+ "@suluk/zod": "0.1.0",
13
+ "@suluk/hono": "0.1.0"
14
+ },
15
+ "peerDependencies": {
16
+ "drizzle-orm": "^0.4.0 || ^0.30.0 || ^0.40.0 || ^0.45.0",
17
+ "drizzle-zod": "^0.8.0",
18
+ "zod": "^4.0.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/bun": "latest",
22
+ "drizzle-orm": "^0.45.2",
23
+ "drizzle-zod": "^0.8.3",
24
+ "zod": "^4.4.3"
25
+ },
26
+ "scripts": {
27
+ "test": "bun test",
28
+ "typecheck": "tsc --noEmit -p ."
29
+ }
30
+ }
package/src/crud.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Drizzle table → CRUD RouteContracts (the @suluk/hono shape). This closes the floor-to-contract chain:
3
+ * Drizzle (data) → Hono RouteContract (interface) → emitV4 → v4 document. We generate the conventional five
4
+ * REST operations from the table's select/insert/update Zod schemas. Nothing here is opinionated about the
5
+ * handler — these are pure contract shapes, derivable into a v4 doc with no running server. CANDIDATE tooling.
6
+ */
7
+ import * as z from "zod";
8
+ import { getTableName } from "drizzle-orm";
9
+ import type { RouteContract, RouteResponse } from "@suluk/hono";
10
+ import { tableSchemas } from "./schemas";
11
+ import { pascalCase, type AnyTable } from "./meta";
12
+
13
+ export interface CrudOptions {
14
+ /** Base path for the collection. Default "/" + tableName, e.g. "/users". */
15
+ basePath?: string;
16
+ /** Path-param name for the item id. Default "id" ⇒ ".../:id". */
17
+ idParam?: string;
18
+ }
19
+
20
+ /**
21
+ * Generate the five conventional CRUD RouteContracts for a drizzle table:
22
+ * - list GET {base} → 200 array(select)
23
+ * - get GET {base}/:id → 200 select, 404
24
+ * - create POST {base} (json insert) → 201 select
25
+ * - update PATCH {base}/:id (json update) → 200 select
26
+ * - delete DELETE {base}/:id → 204
27
+ * Names are list<Pascal>/get<Pascal>/create<Pascal>/update<Pascal>/delete<Pascal> (C009 by-name handles).
28
+ * `:id` is typed as a string param (path params arrive as strings; the DB layer coerces).
29
+ */
30
+ export function crudRoutes(table: AnyTable, opts: CrudOptions = {}): RouteContract[] {
31
+ const tableName = getTableName(table);
32
+ const base = opts.basePath ?? `/${tableName}`;
33
+ const idParam = opts.idParam ?? "id";
34
+ const Pascal = pascalCase(tableName);
35
+ const { select, insert, update } = tableSchemas(table);
36
+
37
+ // path-param object — `:id` (and any future composite key) is a string in the URI template.
38
+ const idParams = z.object({ [idParam]: z.string() });
39
+ const itemPath = `${base}/:${idParam}`;
40
+
41
+ const ok = (status: number, schema: z.ZodType, description: string): RouteResponse => ({ status, schema, description });
42
+ const bare = (status: number, description: string): RouteResponse => ({ status, description });
43
+
44
+ return [
45
+ {
46
+ method: "get",
47
+ path: base,
48
+ name: `list${Pascal}`,
49
+ summary: `List ${tableName}`,
50
+ tags: [tableName],
51
+ responses: [ok(200, z.array(select), `A page of ${tableName}.`)],
52
+ },
53
+ {
54
+ method: "get",
55
+ path: itemPath,
56
+ name: `get${Pascal}`,
57
+ summary: `Fetch one ${tableName} row by ${idParam}`,
58
+ tags: [tableName],
59
+ request: { params: idParams },
60
+ responses: [ok(200, select, `The ${tableName} row.`), bare(404, "Not found.")],
61
+ },
62
+ {
63
+ method: "post",
64
+ path: base,
65
+ name: `create${Pascal}`,
66
+ summary: `Create a ${tableName} row`,
67
+ tags: [tableName],
68
+ request: { json: insert },
69
+ responses: [ok(201, select, `The created ${tableName} row.`)],
70
+ },
71
+ {
72
+ method: "patch",
73
+ path: itemPath,
74
+ name: `update${Pascal}`,
75
+ summary: `Update a ${tableName} row by ${idParam}`,
76
+ tags: [tableName],
77
+ request: { params: idParams, json: update },
78
+ responses: [ok(200, select, `The updated ${tableName} row.`), bare(404, "Not found.")],
79
+ },
80
+ {
81
+ method: "delete",
82
+ path: itemPath,
83
+ name: `delete${Pascal}`,
84
+ summary: `Delete a ${tableName} row by ${idParam}`,
85
+ tags: [tableName],
86
+ request: { params: idParams },
87
+ responses: [bare(204, "Deleted.")],
88
+ },
89
+ ];
90
+ }
package/src/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @suluk/drizzle — the DATA floor of the Suluk cycle: a Drizzle ORM table is the system of record, and this
3
+ * package projects it into the v4 "Suluk" contract. The chain is
4
+ *
5
+ * Drizzle table
6
+ * → Zod (drizzle-zod: select / insert / update) [tableSchemas]
7
+ * → v4 Schema Objects (@suluk/zod zodToV4) [tableToV4, tableComponents]
8
+ * → Hono RouteContracts (the @suluk/hono interface) [crudRoutes]
9
+ * → v4 document (@suluk/hono emitV4) [closes the floor-to-contract chain]
10
+ *
11
+ * Plus the honest DB metadata read straight off the column descriptors [tableMetadata]. Losses are never
12
+ * silent: the v4 conversion surfaces zodToV4 warnings (tableToV4Warnings) and component-name collisions
13
+ * (tableComponentsAudit). CANDIDATE tooling (not official OAS).
14
+ */
15
+ export {
16
+ tableMetadata,
17
+ pascalCase,
18
+ tableComponentName,
19
+ type AnyTable,
20
+ type ColumnMeta,
21
+ type TableMeta,
22
+ } from "./meta";
23
+
24
+ export {
25
+ tableSchemas,
26
+ tableToV4,
27
+ tableToV4Warnings,
28
+ tableComponents,
29
+ tableComponentsAudit,
30
+ type TableZodSchemas,
31
+ type TableV4Schemas,
32
+ } from "./schemas";
33
+
34
+ export { crudRoutes, type CrudOptions } from "./crud";
package/src/meta.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Drizzle table → metadata + naming. The "DATA floor" reads the table's column descriptors directly
3
+ * (drizzle-orm's getTableColumns) — the source of truth about nullability, defaults, primary keys, and
4
+ * SQL-level enums — and lifts them into a small, JSON-friendly record the rest of the package projects from.
5
+ * CANDIDATE tooling (not official OAS).
6
+ */
7
+ import { getTableColumns, getTableName } from "drizzle-orm";
8
+
9
+ /** Any drizzle table object accepted by getTableColumns/getTableName. We stay structural — the concrete
10
+ * dialect type (SQLite/Pg/MySQL) is irrelevant here; we only read the column descriptor surface. */
11
+ export type AnyTable = Parameters<typeof getTableColumns>[0];
12
+
13
+ /** One column's metadata, lifted from drizzle's column descriptor (verified against drizzle-orm 0.45). */
14
+ export interface ColumnMeta {
15
+ name: string;
16
+ /** drizzle's coarse JS dataType, e.g. "string" | "number" | "boolean" | "date". */
17
+ dataType: string;
18
+ /** drizzle's concrete column type tag, e.g. "SQLiteText" | "SQLiteInteger". */
19
+ columnType: string;
20
+ /** NOT NULL at the SQL level. */
21
+ notNull: boolean;
22
+ /** Has a DB-side default (also true for autoincrement PKs) ⇒ optional on insert. */
23
+ hasDefault: boolean;
24
+ /** Part of the (single-column) primary key. */
25
+ primaryKey: boolean;
26
+ /** SQL CHECK/enum allowed values when the column was declared with `{ enum: [...] }`. */
27
+ enumValues?: string[];
28
+ }
29
+
30
+ export interface TableMeta {
31
+ name: string;
32
+ /** Column names flagged `primary` (ordered as drizzle reports the columns). */
33
+ primaryKey: string[];
34
+ columns: ColumnMeta[];
35
+ }
36
+
37
+ /**
38
+ * Read a drizzle table's metadata. This is the honest floor: every value comes from the column descriptor,
39
+ * nothing is inferred. `enumValues` is only present when the underlying column actually carries one — we
40
+ * don't synthesize an empty array (that would be a silent invention).
41
+ */
42
+ export function tableMetadata(table: AnyTable): TableMeta {
43
+ const cols = getTableColumns(table);
44
+ const columns: ColumnMeta[] = [];
45
+ const primaryKey: string[] = [];
46
+
47
+ for (const [name, col] of Object.entries(cols)) {
48
+ // drizzle's descriptor surface — read defensively (any dialect, any version in our peer range).
49
+ const c = col as unknown as {
50
+ dataType: string;
51
+ columnType: string;
52
+ notNull: boolean;
53
+ hasDefault: boolean;
54
+ primary: boolean;
55
+ enumValues?: string[];
56
+ };
57
+ const meta: ColumnMeta = {
58
+ name,
59
+ dataType: c.dataType,
60
+ columnType: c.columnType,
61
+ notNull: !!c.notNull,
62
+ hasDefault: !!c.hasDefault,
63
+ primaryKey: !!c.primary,
64
+ // enumValues is often an empty array on non-enum columns — only surface a non-empty one.
65
+ ...(Array.isArray(c.enumValues) && c.enumValues.length ? { enumValues: c.enumValues } : {}),
66
+ };
67
+ columns.push(meta);
68
+ if (meta.primaryKey) primaryKey.push(name);
69
+ }
70
+
71
+ return { name: getTableName(table), primaryKey, columns };
72
+ }
73
+
74
+ /** "user_accounts" / "users" → "UserAccounts" / "Users". The v4 component key (C009 by-name). */
75
+ export function pascalCase(s: string): string {
76
+ return s.replace(/(^|[-_\s]+)(\w)/g, (_m, _sep, ch: string) => ch.toUpperCase());
77
+ }
78
+
79
+ /** A drizzle table's PascalCase component name, derived from its SQL name. */
80
+ export function tableComponentName(table: AnyTable): string {
81
+ return pascalCase(getTableName(table));
82
+ }
package/src/schemas.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Drizzle table → Zod → v4 Schema Objects. The chain is: table --drizzle-zod--> Zod (select/insert/update)
3
+ * --@suluk/zod zodToV4--> v4 Schema Object (= JSON Schema 2020-12). drizzle-zod already encodes the DB's
4
+ * shape correctly — insert makes notNull-AND-no-default columns required (e.g. "email"), leaves autoincrement
5
+ * PKs and defaulted columns optional, and makes nullable columns `.nullable()`. We do not re-derive any of
6
+ * that; we only compose the three projections and lift them to v4. CANDIDATE tooling (not official OAS).
7
+ */
8
+ import { createInsertSchema, createSelectSchema } from "drizzle-zod";
9
+ import { zodToV4 } from "@suluk/zod";
10
+ import type * as z from "zod";
11
+ import type { Schema } from "@suluk/core";
12
+ import { tableComponentName, type AnyTable } from "./meta";
13
+
14
+ /** The three Zod projections of a table. */
15
+ export interface TableZodSchemas {
16
+ /** Full row shape — every column required (createSelectSchema). */
17
+ select: z.ZodType;
18
+ /** Write shape — notNull-AND-no-default columns required; PK/defaulted/nullable relaxed (createInsertSchema). */
19
+ insert: z.ZodType;
20
+ /** Partial write shape — every insert field optional (insert.partial()), for PATCH. */
21
+ update: z.ZodType;
22
+ }
23
+
24
+ /** The three v4 Schema Objects, mirroring {@link TableZodSchemas}. */
25
+ export interface TableV4Schemas {
26
+ select: Schema;
27
+ insert: Schema;
28
+ update: Schema;
29
+ }
30
+
31
+ /**
32
+ * Build the select / insert / update Zod schemas for a table.
33
+ * update = insert.partial() — the canonical PATCH body (any subset of writable columns).
34
+ */
35
+ export function tableSchemas(table: AnyTable): TableZodSchemas {
36
+ const select = createSelectSchema(table) as unknown as z.ZodType;
37
+ const insert = createInsertSchema(table) as unknown as z.ZodType;
38
+ // .partial() exists on the Zod object createInsertSchema returns; cast through to keep the public type clean.
39
+ const update = (insert as unknown as { partial(): z.ZodType }).partial();
40
+ return { select, insert, update };
41
+ }
42
+
43
+ /**
44
+ * Lift a table's three Zod schemas to v4 Schema Objects via zodToV4. drizzle-zod produces plain object
45
+ * schemas (no .transform/.refine), so this is lossless here — but we still honor the house rule and surface
46
+ * any zodToV4 warnings rather than dropping them silently (see {@link tableToV4Warnings}).
47
+ */
48
+ export function tableToV4(table: AnyTable): TableV4Schemas {
49
+ const { select, insert, update } = tableSchemas(table);
50
+ return {
51
+ select: zodToV4(select).schema,
52
+ insert: zodToV4(insert).schema,
53
+ update: zodToV4(update).schema,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Same conversion as {@link tableToV4} but also returns the enumerated lossy boundary (per-projection
59
+ * zodToV4 warnings). Empty arrays ⇒ fully lossless. Callers wanting the honest-loss accounting use this.
60
+ */
61
+ export function tableToV4Warnings(table: AnyTable): {
62
+ schemas: TableV4Schemas;
63
+ warnings: { select: string[]; insert: string[]; update: string[] };
64
+ } {
65
+ const { select, insert, update } = tableSchemas(table);
66
+ const s = zodToV4(select), i = zodToV4(insert), u = zodToV4(update);
67
+ return {
68
+ schemas: { select: s.schema, insert: i.schema, update: u.schema },
69
+ warnings: { select: s.warnings, insert: i.warnings, update: u.warnings },
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Build a v4 components.schemas record from a set of tables: { [PascalName]: select-v4-schema }.
75
+ * Keyed by the table's PascalCase name (C009 by-name). Collisions (two tables mapping to the same Pascal
76
+ * name) are NOT silently merged — the last writer wins AND a warning is surfaced via {@link tableComponentsAudit}.
77
+ */
78
+ export function tableComponents(tables: readonly AnyTable[]): Record<string, Schema> {
79
+ return tableComponentsAudit(tables).schemas;
80
+ }
81
+
82
+ /** Like {@link tableComponents} but enumerates name collisions instead of dropping them silently. */
83
+ export function tableComponentsAudit(tables: readonly AnyTable[]): {
84
+ schemas: Record<string, Schema>;
85
+ collisions: string[];
86
+ } {
87
+ const schemas: Record<string, Schema> = {};
88
+ const collisions: string[] = [];
89
+ for (const table of tables) {
90
+ const key = tableComponentName(table);
91
+ if (key in schemas) collisions.push(`component name collision: '${key}' produced by more than one table`);
92
+ schemas[key] = tableToV4(table).select;
93
+ }
94
+ return { schemas, collisions };
95
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * @suluk/drizzle tests — prove the DATA floor: drizzle-zod's required/nullable accounting survives the lift
3
+ * to v4, metadata reads honestly off the column descriptors, crudRoutes emits the five conventional ops, and
4
+ * the whole Drizzle → Hono → v4 chain produces a STRUCTURALLY VALID v4 document. CANDIDATE tooling.
5
+ */
6
+ import { test, expect, describe } from "bun:test";
7
+ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
8
+ import { emitV4 } from "@suluk/hono";
9
+ import { validateDocument } from "@suluk/core";
10
+ import {
11
+ tableSchemas,
12
+ tableToV4,
13
+ tableToV4Warnings,
14
+ tableMetadata,
15
+ tableComponents,
16
+ tableComponentsAudit,
17
+ crudRoutes,
18
+ } from "../src/index";
19
+
20
+ // The example table the spec calls for: autoinc PK, a required (notNull, no-default) email, two nullables,
21
+ // and a notNull-with-default enum. drizzle-zod's required-set should be exactly ["email"] on insert.
22
+ const users = sqliteTable("users", {
23
+ id: integer("id").primaryKey({ autoIncrement: true }),
24
+ email: text("email").notNull(),
25
+ name: text("name"),
26
+ age: integer("age"),
27
+ role: text("role", { enum: ["admin", "user"] }).notNull().default("user"),
28
+ });
29
+
30
+ /** A v4 Schema Object can be a boolean (true/false) per JSON Schema; narrow to the object form for tests. */
31
+ function obj(schema: unknown): Record<string, unknown> {
32
+ expect(typeof schema).toBe("object");
33
+ return schema as Record<string, unknown>;
34
+ }
35
+
36
+ /** Pull the `required` array off a v4 object Schema (JSON Schema 2020-12). */
37
+ function required(schema: unknown): string[] {
38
+ return (obj(schema).required as string[] | undefined) ?? [];
39
+ }
40
+
41
+ describe("tableSchemas / tableToV4", () => {
42
+ test("insert requires only notNull-AND-no-default columns (email); PK + defaulted + nullable are optional", () => {
43
+ const { insert } = tableToV4(users);
44
+ expect(required(insert).sort()).toEqual(["email"]);
45
+ });
46
+
47
+ test("select requires ALL columns (the full row shape)", () => {
48
+ const { select } = tableToV4(users);
49
+ expect(required(select).sort()).toEqual(["age", "email", "id", "name", "role"]);
50
+ });
51
+
52
+ test("update (insert.partial) requires NO columns", () => {
53
+ const { update } = tableToV4(users);
54
+ expect(required(update)).toEqual([]);
55
+ // and it's a usable Zod schema — an empty patch parses.
56
+ expect(() => tableSchemas(users).update.parse({})).not.toThrow();
57
+ });
58
+
59
+ test("nullable columns lift to a null-tolerant v4 schema", () => {
60
+ const { select } = tableToV4(users);
61
+ const props = obj(select).properties as Record<string, { anyOf?: { type?: string }[] }>;
62
+ const nameTypes = (props.name.anyOf ?? []).map((s) => s.type);
63
+ expect(nameTypes).toContain("null");
64
+ });
65
+
66
+ test("the v4 lift is lossless here — no dropped Zod effects", () => {
67
+ const { warnings } = tableToV4Warnings(users);
68
+ expect(warnings.select).toEqual([]);
69
+ expect(warnings.insert).toEqual([]);
70
+ expect(warnings.update).toEqual([]);
71
+ });
72
+ });
73
+
74
+ describe("tableMetadata", () => {
75
+ const meta = tableMetadata(users);
76
+
77
+ test("name + primaryKey are read off the descriptors", () => {
78
+ expect(meta.name).toBe("users");
79
+ expect(meta.primaryKey).toEqual(["id"]);
80
+ });
81
+
82
+ test("the role enum's allowed values are surfaced", () => {
83
+ const role = meta.columns.find((c) => c.name === "role")!;
84
+ expect(role.enumValues).toEqual(["admin", "user"]);
85
+ expect(role.notNull).toBe(true);
86
+ expect(role.hasDefault).toBe(true);
87
+ });
88
+
89
+ test("non-enum columns carry no enumValues (no silent invention)", () => {
90
+ const email = meta.columns.find((c) => c.name === "email")!;
91
+ expect(email.enumValues).toBeUndefined();
92
+ expect(email.notNull).toBe(true);
93
+ expect(email.hasDefault).toBe(false);
94
+ expect(email.primaryKey).toBe(false);
95
+ });
96
+
97
+ test("every column is reported with its drizzle descriptor fields", () => {
98
+ expect(meta.columns.map((c) => c.name).sort()).toEqual(["age", "email", "id", "name", "role"]);
99
+ const id = meta.columns.find((c) => c.name === "id")!;
100
+ expect(id.primaryKey).toBe(true);
101
+ expect(id.columnType).toBe("SQLiteInteger");
102
+ expect(id.dataType).toBe("number");
103
+ });
104
+ });
105
+
106
+ describe("tableComponents", () => {
107
+ test("keys by PascalCase table name, value is the select v4 schema", () => {
108
+ const comps = tableComponents([users]);
109
+ expect(Object.keys(comps)).toEqual(["Users"]);
110
+ expect(required(comps.Users).sort()).toEqual(["age", "email", "id", "name", "role"]);
111
+ });
112
+
113
+ test("collisions are enumerated, not dropped silently", () => {
114
+ // two tables with the same SQL name both map to "Users".
115
+ const usersB = sqliteTable("users", { id: integer("id").primaryKey() });
116
+ const { collisions } = tableComponentsAudit([users, usersB]);
117
+ expect(collisions.length).toBe(1);
118
+ expect(collisions[0]).toContain("Users");
119
+ });
120
+ });
121
+
122
+ describe("crudRoutes", () => {
123
+ const routes = crudRoutes(users);
124
+
125
+ test("emits the five conventional CRUD operations with by-name handles", () => {
126
+ expect(routes.length).toBe(5);
127
+ expect(routes.map((r) => r.name)).toEqual([
128
+ "listUsers",
129
+ "getUsers",
130
+ "createUsers",
131
+ "updateUsers",
132
+ "deleteUsers",
133
+ ]);
134
+ });
135
+
136
+ test("methods + paths follow REST convention; basePath defaults to /<table>", () => {
137
+ const byName = Object.fromEntries(routes.map((r) => [r.name!, r]));
138
+ expect(byName.listUsers.method).toBe("get");
139
+ expect(byName.listUsers.path).toBe("/users");
140
+ expect(byName.getUsers.method).toBe("get");
141
+ expect(byName.getUsers.path).toBe("/users/:id");
142
+ expect(byName.createUsers.method).toBe("post");
143
+ expect(byName.updateUsers.method).toBe("patch");
144
+ expect(byName.deleteUsers.method).toBe("delete");
145
+ });
146
+
147
+ test("statuses: list 200, get 200+404, create 201, update 200+404, delete 204", () => {
148
+ const byName = Object.fromEntries(routes.map((r) => [r.name!, r]));
149
+ const statuses = (name: string) =>
150
+ (byName[name].responses as { status: number }[]).map((x) => x.status).sort((a, b) => a - b);
151
+ expect(statuses("listUsers")).toEqual([200]);
152
+ expect(statuses("getUsers")).toEqual([200, 404]);
153
+ expect(statuses("createUsers")).toEqual([201]);
154
+ expect(statuses("updateUsers")).toEqual([200, 404]);
155
+ expect(statuses("deleteUsers")).toEqual([204]);
156
+ });
157
+
158
+ test("create body is the insert schema (email required); get/update carry the :id path param", () => {
159
+ const byName = Object.fromEntries(routes.map((r) => [r.name!, r]));
160
+ expect(byName.createUsers.request?.json).toBeDefined();
161
+ expect(byName.getUsers.request?.params).toBeDefined();
162
+ expect(byName.updateUsers.request?.json).toBeDefined();
163
+ expect(byName.updateUsers.request?.params).toBeDefined();
164
+ });
165
+
166
+ test("opts.basePath + opts.idParam are honored", () => {
167
+ const r = crudRoutes(users, { basePath: "/v1/people", idParam: "userId" });
168
+ expect(r[0].path).toBe("/v1/people");
169
+ expect(r[1].path).toBe("/v1/people/:userId");
170
+ });
171
+ });
172
+
173
+ describe("end-to-end: Drizzle → Hono → v4 (floor-to-contract)", () => {
174
+ test("emitV4(crudRoutes(users)) yields a STRUCTURALLY VALID v4 document", () => {
175
+ const { document, diagnostics } = emitV4(crudRoutes(users));
176
+ // no provable signature collisions among the five distinct ops.
177
+ expect(diagnostics.filter((d) => d.kind === "collision")).toEqual([]);
178
+ const result = validateDocument(document);
179
+ if (!result.valid) console.error("validation errors:", result.errors);
180
+ expect(result.valid).toBe(true);
181
+ // sanity: the five operations made it into the paths.
182
+ const opNames = Object.values(document.paths).flatMap((pi) => Object.keys(pi.requests));
183
+ expect(opNames.sort()).toEqual(["createUsers", "deleteUsers", "getUsers", "listUsers", "updateUsers"]);
184
+ });
185
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": { "types": ["bun"] },
4
+ "include": ["src", "test"]
5
+ }