@sapporta/server 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.
Files changed (133) hide show
  1. package/package.json +40 -0
  2. package/src/actions/action.test.ts +108 -0
  3. package/src/actions/action.ts +60 -0
  4. package/src/actions/loader.ts +47 -0
  5. package/src/api/actions.ts +124 -0
  6. package/src/api/meta-mutations.ts +922 -0
  7. package/src/api/meta.ts +222 -0
  8. package/src/api/reports.ts +98 -0
  9. package/src/api/server.ts +24 -0
  10. package/src/api/tables.ts +108 -0
  11. package/src/api/views.ts +44 -0
  12. package/src/boot.ts +206 -0
  13. package/src/cli/ai-commands.ts +220 -0
  14. package/src/cli/check.ts +169 -0
  15. package/src/cli/cli-utils.test.ts +313 -0
  16. package/src/cli/describe.test.ts +151 -0
  17. package/src/cli/describe.ts +88 -0
  18. package/src/cli/emit-result.test.ts +160 -0
  19. package/src/cli/format.ts +150 -0
  20. package/src/cli/http-client.ts +55 -0
  21. package/src/cli/index.ts +162 -0
  22. package/src/cli/init.ts +35 -0
  23. package/src/cli/project-context.ts +38 -0
  24. package/src/cli/request.ts +146 -0
  25. package/src/cli/routes.ts +418 -0
  26. package/src/cli/rows-insert-master-detail.test.ts +124 -0
  27. package/src/cli/rows-insert-master-detail.ts +186 -0
  28. package/src/cli/rows-insert.test.ts +137 -0
  29. package/src/cli/rows-insert.ts +97 -0
  30. package/src/cli/serve-single.ts +49 -0
  31. package/src/create-project.ts +81 -0
  32. package/src/data/count.ts +62 -0
  33. package/src/data/crud.test.ts +188 -0
  34. package/src/data/crud.ts +242 -0
  35. package/src/data/lookup.test.ts +96 -0
  36. package/src/data/lookup.ts +104 -0
  37. package/src/data/query-parser.test.ts +67 -0
  38. package/src/data/query-parser.ts +106 -0
  39. package/src/data/sanitize.test.ts +57 -0
  40. package/src/data/sanitize.ts +25 -0
  41. package/src/data/save-pipeline.test.ts +115 -0
  42. package/src/data/save-pipeline.ts +93 -0
  43. package/src/data/validate.test.ts +110 -0
  44. package/src/data/validate.ts +98 -0
  45. package/src/db/errors.ts +20 -0
  46. package/src/db/logger.ts +63 -0
  47. package/src/db/sqlite-connection.test.ts +59 -0
  48. package/src/db/sqlite-connection.ts +79 -0
  49. package/src/index.ts +111 -0
  50. package/src/integration/api-actions.test.ts +60 -0
  51. package/src/integration/api-global.test.ts +21 -0
  52. package/src/integration/api-meta.test.ts +252 -0
  53. package/src/integration/api-reports.test.ts +77 -0
  54. package/src/integration/api-tables.test.ts +238 -0
  55. package/src/integration/api-views.test.ts +39 -0
  56. package/src/integration/cli-routes.test.ts +167 -0
  57. package/src/integration/fixtures/actions/create-account.ts +23 -0
  58. package/src/integration/fixtures/reports/account-list.ts +25 -0
  59. package/src/integration/fixtures/schema/accounts.ts +21 -0
  60. package/src/integration/fixtures/schema/audit-log.ts +19 -0
  61. package/src/integration/fixtures/schema/journal-entries.ts +20 -0
  62. package/src/integration/fixtures/views/dashboard.tsx +4 -0
  63. package/src/integration/fixtures/views/settings.tsx +3 -0
  64. package/src/integration/setup.ts +72 -0
  65. package/src/introspect/db-helpers.ts +109 -0
  66. package/src/introspect/describe-all.test.ts +73 -0
  67. package/src/introspect/describe-all.ts +80 -0
  68. package/src/introspect/describe.test.ts +65 -0
  69. package/src/introspect/describe.ts +184 -0
  70. package/src/introspect/exec.test.ts +103 -0
  71. package/src/introspect/exec.ts +57 -0
  72. package/src/introspect/indexes.test.ts +41 -0
  73. package/src/introspect/indexes.ts +95 -0
  74. package/src/introspect/inference.ts +98 -0
  75. package/src/introspect/list-tables.test.ts +40 -0
  76. package/src/introspect/list-tables.ts +62 -0
  77. package/src/introspect/query.test.ts +77 -0
  78. package/src/introspect/query.ts +47 -0
  79. package/src/introspect/sample.test.ts +67 -0
  80. package/src/introspect/sample.ts +50 -0
  81. package/src/introspect/sql-safety.ts +76 -0
  82. package/src/introspect/sqlite/db-helpers.test.ts +79 -0
  83. package/src/introspect/sqlite/db-helpers.ts +56 -0
  84. package/src/introspect/sqlite/describe-all.ts +21 -0
  85. package/src/introspect/sqlite/describe.test.ts +160 -0
  86. package/src/introspect/sqlite/describe.ts +185 -0
  87. package/src/introspect/sqlite/exec.ts +57 -0
  88. package/src/introspect/sqlite/indexes.test.ts +60 -0
  89. package/src/introspect/sqlite/indexes.ts +96 -0
  90. package/src/introspect/sqlite/list-tables.test.ts +100 -0
  91. package/src/introspect/sqlite/list-tables.ts +67 -0
  92. package/src/introspect/sqlite/query.ts +49 -0
  93. package/src/introspect/sqlite/sample.ts +50 -0
  94. package/src/introspect/table-rename.test.ts +235 -0
  95. package/src/introspect/table-rename.ts +115 -0
  96. package/src/introspect/types.ts +95 -0
  97. package/src/reports/check.test.ts +499 -0
  98. package/src/reports/check.ts +208 -0
  99. package/src/reports/engine.test.ts +1465 -0
  100. package/src/reports/engine.ts +678 -0
  101. package/src/reports/loader.ts +55 -0
  102. package/src/reports/report.ts +308 -0
  103. package/src/reports/sql-bind.ts +161 -0
  104. package/src/reports/sqlite-bind.test.ts +98 -0
  105. package/src/reports/sqlite-bind.ts +58 -0
  106. package/src/reports/sqlite-sql-client.ts +42 -0
  107. package/src/runtime.ts +3 -0
  108. package/src/schema/check.ts +90 -0
  109. package/src/schema/ddl.test.ts +210 -0
  110. package/src/schema/ddl.ts +180 -0
  111. package/src/schema/dynamic-builder.ts +297 -0
  112. package/src/schema/extract.test.ts +261 -0
  113. package/src/schema/extract.ts +285 -0
  114. package/src/schema/loader.test.ts +31 -0
  115. package/src/schema/loader.ts +60 -0
  116. package/src/schema/metadata-io.test.ts +261 -0
  117. package/src/schema/metadata-io.ts +161 -0
  118. package/src/schema/metadata-tables.test.ts +737 -0
  119. package/src/schema/metadata-tables.ts +341 -0
  120. package/src/schema/migrate.ts +195 -0
  121. package/src/schema/normalize-datatype.test.ts +58 -0
  122. package/src/schema/normalize-datatype.ts +99 -0
  123. package/src/schema/registry.test.ts +174 -0
  124. package/src/schema/registry.ts +139 -0
  125. package/src/schema/reserved.ts +227 -0
  126. package/src/schema/table.ts +135 -0
  127. package/src/test-fixtures/schema/accounts.ts +24 -0
  128. package/src/test-fixtures/schema/not-a-table.ts +6 -0
  129. package/src/testing/test-utils.ts +44 -0
  130. package/src/views/loader.test.ts +70 -0
  131. package/src/views/loader.ts +38 -0
  132. package/src/views/view.test.ts +121 -0
  133. package/src/views/view.ts +16 -0
@@ -0,0 +1,106 @@
1
+ import { eq, gt, gte, lt, lte, like, ilike, asc, desc, and, type SQL } from "drizzle-orm";
2
+ import type { TableDef } from "../schema/table.js";
3
+
4
+ export interface ParsedQuery {
5
+ where: SQL | undefined;
6
+ orderBy: SQL[];
7
+ limit: number;
8
+ offset: number;
9
+ }
10
+
11
+ /**
12
+ * Parse query string parameters into Drizzle query parts.
13
+ *
14
+ * Supported params:
15
+ * - filter[column]=value → eq(column, value)
16
+ * - filter[column][gt]=value → gt(column, value)
17
+ * - filter[column][gte]=value → gte(column, value)
18
+ * - filter[column][lt]=value → lt(column, value)
19
+ * - filter[column][lte]=value → lte(column, value)
20
+ * - filter[column][like]=value → like(column, %value%)
21
+ * - filter[column][ilike]=value → ilike(column, %value%)
22
+ * - sort=-column → desc(column)
23
+ * - sort=column → asc(column)
24
+ * - page=1&limit=50 → offset + limit
25
+ */
26
+ export function parseQuery(
27
+ params: Record<string, string>,
28
+ schema: TableDef,
29
+ ): ParsedQuery {
30
+ const conditions: SQL[] = [];
31
+ const orderClauses: SQL[] = [];
32
+
33
+ // Parse limit and page
34
+ const limit = Math.min(Math.max(parseInt(params.limit ?? "50", 10) || 50, 1), 1000);
35
+ const page = Math.max(parseInt(params.page ?? "1", 10) || 1, 1);
36
+ const offset = (page - 1) * limit;
37
+
38
+ // Parse filters
39
+ for (const [key, value] of Object.entries(params)) {
40
+ // filter[column]=value
41
+ const simpleMatch = key.match(/^filter\[(\w+)\]$/);
42
+ if (simpleMatch) {
43
+ const colName = simpleMatch[1];
44
+ const col = findColumn(schema, colName);
45
+ if (col) {
46
+ conditions.push(eq(col, value));
47
+ }
48
+ continue;
49
+ }
50
+
51
+ // filter[column][operator]=value
52
+ const opMatch = key.match(/^filter\[(\w+)\]\[(\w+)\]$/);
53
+ if (opMatch) {
54
+ const colName = opMatch[1];
55
+ const op = opMatch[2];
56
+ const col = findColumn(schema, colName);
57
+ if (col) {
58
+ switch (op) {
59
+ case "gt":
60
+ conditions.push(gt(col, value));
61
+ break;
62
+ case "gte":
63
+ conditions.push(gte(col, value));
64
+ break;
65
+ case "lt":
66
+ conditions.push(lt(col, value));
67
+ break;
68
+ case "lte":
69
+ conditions.push(lte(col, value));
70
+ break;
71
+ case "like":
72
+ conditions.push(like(col, `%${value}%`));
73
+ break;
74
+ case "ilike":
75
+ conditions.push(ilike(col, `%${value}%`));
76
+ break;
77
+ }
78
+ }
79
+ continue;
80
+ }
81
+ }
82
+
83
+ // Parse sort
84
+ if (params.sort) {
85
+ const sortFields = params.sort.split(",");
86
+ for (const field of sortFields) {
87
+ const descending = field.startsWith("-");
88
+ const colName = descending ? field.slice(1) : field;
89
+ const col = findColumn(schema, colName);
90
+ if (col) {
91
+ orderClauses.push(descending ? desc(col) : asc(col));
92
+ }
93
+ }
94
+ }
95
+
96
+ return {
97
+ where: conditions.length > 0 ? and(...conditions) : undefined,
98
+ orderBy: orderClauses,
99
+ limit,
100
+ offset,
101
+ };
102
+ }
103
+
104
+ function findColumn(schema: TableDef, name: string) {
105
+ return (schema.drizzle as any)[name] ?? null;
106
+ }
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { rejectControlChars, isSafeIdentifier } from "./sanitize.js";
3
+
4
+ describe("rejectControlChars", () => {
5
+ it("accepts normal text", () => {
6
+ expect(() => rejectControlChars('{"name":"Cash","amount":100}')).not.toThrow();
7
+ });
8
+
9
+ it("accepts whitespace characters (tab, newline, carriage return)", () => {
10
+ expect(() => rejectControlChars('{\n\t"name": "Cash"\r\n}')).not.toThrow();
11
+ });
12
+
13
+ it("rejects null byte", () => {
14
+ expect(() => rejectControlChars('{"name":"Cash\x00"}')).toThrow("control characters");
15
+ });
16
+
17
+ it("rejects bell character", () => {
18
+ expect(() => rejectControlChars('{"name":"Cash\x07"}')).toThrow("control characters");
19
+ });
20
+
21
+ it("rejects backspace", () => {
22
+ expect(() => rejectControlChars('{"name":"Cash\x08"}')).toThrow("control characters");
23
+ });
24
+
25
+ it("rejects form feed within restricted range", () => {
26
+ expect(() => rejectControlChars('{"name":"Cash\x0e"}')).toThrow("control characters");
27
+ });
28
+
29
+ it("throws ValidationError", () => {
30
+ expect(() => rejectControlChars("bad\x00")).toThrow("Validation failed");
31
+ });
32
+ });
33
+
34
+ describe("isSafeIdentifier", () => {
35
+ it("accepts valid identifiers", () => {
36
+ expect(isSafeIdentifier("accounts")).toBe(true);
37
+ expect(isSafeIdentifier("_private")).toBe(true);
38
+ expect(isSafeIdentifier("order_items")).toBe(true);
39
+ expect(isSafeIdentifier("AccountType")).toBe(true);
40
+ });
41
+
42
+ it("rejects names with special characters", () => {
43
+ expect(isSafeIdentifier("users; DROP TABLE")).toBe(false);
44
+ expect(isSafeIdentifier("my-table")).toBe(false);
45
+ expect(isSafeIdentifier("my table")).toBe(false);
46
+ expect(isSafeIdentifier("name?")).toBe(false);
47
+ expect(isSafeIdentifier("col'name")).toBe(false);
48
+ });
49
+
50
+ it("rejects names starting with numbers", () => {
51
+ expect(isSafeIdentifier("1table")).toBe(false);
52
+ });
53
+
54
+ it("rejects empty string", () => {
55
+ expect(isSafeIdentifier("")).toBe(false);
56
+ });
57
+ });
@@ -0,0 +1,25 @@
1
+ import { ValidationError } from "../db/errors.js";
2
+
3
+ /**
4
+ * Reject strings containing control characters (below ASCII 0x20)
5
+ * except for the whitespace characters that JSON legitimately uses:
6
+ * \t (0x09), \n (0x0a), \r (0x0d).
7
+ *
8
+ * AI agents sometimes produce invisible control characters in output.
9
+ * These can cause subtle data corruption when inserted into the database.
10
+ */
11
+ export function rejectControlChars(text: string): void {
12
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(text)) {
13
+ throw new ValidationError([
14
+ { field: "", message: "Value contains control characters" },
15
+ ]);
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Check whether a name is a safe SQL identifier:
21
+ * starts with a letter or underscore, followed by alphanumeric or underscores.
22
+ */
23
+ export function isSafeIdentifier(name: string): boolean {
24
+ return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name);
25
+ }
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
3
+ import { table } from "../schema/table.js";
4
+ import { savePipeline, insertRow, updateRow } from "./save-pipeline.js";
5
+ import { ValidationError } from "../db/errors.js";
6
+ import { createTestDb } from "../testing/test-utils.js";
7
+
8
+ const accountsTable = sqliteTable("accounts", {
9
+ id: integer("id").primaryKey({ autoIncrement: true }),
10
+ name: text("name").notNull(),
11
+ type: text("type").notNull(),
12
+ balance: integer("balance"),
13
+ });
14
+
15
+ const accounts = table({
16
+ drizzle: accountsTable,
17
+ meta: {
18
+ selects: [
19
+ {
20
+ type: "select",
21
+ column: "type",
22
+ options: ["asset", "liability", "equity", "revenue", "expense"],
23
+ },
24
+ ],
25
+ },
26
+ });
27
+
28
+ describe("save-pipeline (integration)", () => {
29
+ let db: any;
30
+
31
+ beforeEach(async () => {
32
+ const testDb = createTestDb();
33
+ db = testDb.db;
34
+ // Create the accounts table
35
+ testDb.sqlite.exec(`
36
+ CREATE TABLE IF NOT EXISTS accounts (
37
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
38
+ name TEXT NOT NULL,
39
+ type TEXT NOT NULL,
40
+ balance INTEGER
41
+ )
42
+ `);
43
+ });
44
+
45
+ it("inserts a valid record", async () => {
46
+ const result = await savePipeline(accounts, db, {
47
+ name: "Cash",
48
+ type: "asset",
49
+ });
50
+
51
+ expect(result.id).toBe(1);
52
+ expect(result.name).toBe("Cash");
53
+ expect(result.type).toBe("asset");
54
+ });
55
+
56
+ it("rejects invalid records", async () => {
57
+ await expect(
58
+ savePipeline(accounts, db, { name: "Bad", type: "invalid_type" }),
59
+ ).rejects.toThrow(ValidationError);
60
+ });
61
+
62
+ it("updates an existing record", async () => {
63
+ const inserted = await insertRow(accounts, db, {
64
+ name: "Cash",
65
+ type: "asset",
66
+ });
67
+
68
+ const updated = await savePipeline(
69
+ accounts,
70
+ db,
71
+ { name: "Cash on Hand", type: "asset" },
72
+ inserted.id as number,
73
+ );
74
+
75
+ expect(updated.name).toBe("Cash on Hand");
76
+ expect(updated.id).toBe(inserted.id);
77
+ });
78
+
79
+ it("rejects control characters in string values", async () => {
80
+ await expect(
81
+ savePipeline(accounts, db, { name: "Cash\x00", type: "asset" }),
82
+ ).rejects.toThrow(ValidationError);
83
+
84
+ try {
85
+ await savePipeline(accounts, db, { name: "Cash\x07", type: "asset" });
86
+ } catch (e: any) {
87
+ expect(e).toBeInstanceOf(ValidationError);
88
+ expect(e.errors[0].field).toBe("name");
89
+ expect(e.errors[0].message).toContain("control characters");
90
+ }
91
+ });
92
+
93
+ it("rejects unknown columns", async () => {
94
+ await expect(
95
+ savePipeline(accounts, db, {
96
+ name: "Cash",
97
+ type: "asset",
98
+ bogus: "should fail",
99
+ }),
100
+ ).rejects.toThrow(ValidationError);
101
+ });
102
+
103
+ it("insertRow and updateRow work directly", async () => {
104
+ const row = await insertRow(accounts, db, {
105
+ name: "Revenue",
106
+ type: "revenue",
107
+ });
108
+ expect(row.id).toBeDefined();
109
+
110
+ const updated = await updateRow(accounts, db, row.id as number, {
111
+ name: "Sales Revenue",
112
+ });
113
+ expect(updated.name).toBe("Sales Revenue");
114
+ });
115
+ });
@@ -0,0 +1,93 @@
1
+ import { eq } from "drizzle-orm";
2
+ import { getTableConfig } from "drizzle-orm/sqlite-core";
3
+ import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
4
+ import type { TableDef } from "../schema/table.js";
5
+ import { validate } from "./validate.js";
6
+ import { ValidationError } from "../db/errors.js";
7
+ import { rejectControlChars } from "./sanitize.js";
8
+
9
+ /**
10
+ * Insert a new row using Drizzle's table API.
11
+ */
12
+ export async function insertRow(
13
+ schema: TableDef,
14
+ db: BetterSQLite3Database,
15
+ record: Record<string, unknown>,
16
+ ): Promise<Record<string, unknown>> {
17
+ const result = await db
18
+ .insert(schema.drizzle)
19
+ .values(record as any)
20
+ .returning();
21
+
22
+ return result[0] as Record<string, unknown>;
23
+ }
24
+
25
+ /**
26
+ * Update an existing row using Drizzle's table API.
27
+ */
28
+ export async function updateRow(
29
+ schema: TableDef,
30
+ db: BetterSQLite3Database,
31
+ id: number | string,
32
+ record: Record<string, unknown>,
33
+ ): Promise<Record<string, unknown>> {
34
+ const config = getTableConfig(schema.drizzle);
35
+ const pkCol = config.columns.find((c) => c.primary);
36
+ if (!pkCol) throw new Error(`No primary key found on table ${config.name}`);
37
+
38
+ const drizzleCol = (schema.drizzle as any)[pkCol.name];
39
+
40
+ const result = await db
41
+ .update(schema.drizzle)
42
+ .set(record as any)
43
+ .where(eq(drizzleCol, id))
44
+ .returning();
45
+
46
+ if (result.length === 0) {
47
+ throw new Error(`Record with id ${id} not found`);
48
+ }
49
+
50
+ return result[0] as Record<string, unknown>;
51
+ }
52
+
53
+ /**
54
+ * The save pipeline: validate → insert or update.
55
+ * If `id` is provided, it updates; otherwise it inserts.
56
+ * Respects `schema.meta.save` custom override if provided.
57
+ */
58
+ export async function savePipeline(
59
+ schema: TableDef,
60
+ db: BetterSQLite3Database,
61
+ record: Record<string, unknown>,
62
+ id?: number | string,
63
+ ): Promise<Record<string, unknown>> {
64
+ // Step 0: Reject control characters in string values
65
+ for (const [key, value] of Object.entries(record)) {
66
+ if (typeof value === "string") {
67
+ try {
68
+ rejectControlChars(value);
69
+ } catch {
70
+ throw new ValidationError([
71
+ { field: key, message: "Value contains control characters" },
72
+ ]);
73
+ }
74
+ }
75
+ }
76
+
77
+ // Step 1: Validate
78
+ const errors = validate(schema, record);
79
+ if (errors.length > 0) {
80
+ throw new ValidationError(errors);
81
+ }
82
+
83
+ // Step 2: Custom save or default insert/update
84
+ if (schema.meta.save) {
85
+ return schema.meta.save(record, db);
86
+ }
87
+
88
+ if (id != null) {
89
+ return updateRow(schema, db, id, record);
90
+ } else {
91
+ return insertRow(schema, db, record);
92
+ }
93
+ }
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
3
+ import { z } from "zod";
4
+ import { table, timestamp } from "../schema/table.js";
5
+ import { validate, buildZodSchema } from "./validate.js";
6
+
7
+ describe("buildZodSchema()", () => {
8
+ it("infers string fields as required", () => {
9
+ const schema = table({
10
+ drizzle: sqliteTable("test_strings", {
11
+ id: integer("id").primaryKey({ autoIncrement: true }),
12
+ name: text("name").notNull(),
13
+ }),
14
+ });
15
+
16
+ const zodSchema = buildZodSchema(schema);
17
+ expect(zodSchema.safeParse({ name: "hello" }).success).toBe(true);
18
+ expect(zodSchema.safeParse({}).success).toBe(false);
19
+ expect(zodSchema.safeParse({ name: 123 }).success).toBe(false);
20
+ });
21
+
22
+ it("makes nullable columns nullable", () => {
23
+ const schema = table({
24
+ drizzle: sqliteTable("test_nullable", {
25
+ id: integer("id").primaryKey({ autoIncrement: true }),
26
+ notes: text("notes"),
27
+ }),
28
+ });
29
+
30
+ const zodSchema = buildZodSchema(schema);
31
+ expect(zodSchema.safeParse({ notes: null }).success).toBe(true);
32
+ expect(zodSchema.safeParse({ notes: "hello" }).success).toBe(true);
33
+ });
34
+
35
+ it("makes columns with defaults optional", () => {
36
+ const schema = table({
37
+ drizzle: sqliteTable("test_defaults", {
38
+ id: integer("id").primaryKey({ autoIncrement: true }),
39
+ created_at: timestamp("created_at").notNull().$defaultFn(() => new Date().toISOString()),
40
+ }),
41
+ });
42
+
43
+ const zodSchema = buildZodSchema(schema);
44
+ // created_at has a default, so it should be optional
45
+ expect(zodSchema.safeParse({}).success).toBe(true);
46
+ });
47
+
48
+ it("validates select options", () => {
49
+ const schema = table({
50
+ drizzle: sqliteTable("test_selects", {
51
+ id: integer("id").primaryKey({ autoIncrement: true }),
52
+ status: text("status").notNull(),
53
+ }),
54
+ meta: {
55
+ selects: [
56
+ { type: "select", column: "status", options: ["active", "inactive"] },
57
+ ],
58
+ },
59
+ });
60
+
61
+ const zodSchema = buildZodSchema(schema);
62
+ expect(zodSchema.safeParse({ status: "active" }).success).toBe(true);
63
+ expect(zodSchema.safeParse({ status: "invalid" }).success).toBe(false);
64
+ });
65
+
66
+ it("uses user-provided validation schema", () => {
67
+ const customSchema = z.object({
68
+ name: z.string().min(3),
69
+ });
70
+
71
+ const schema = table({
72
+ drizzle: sqliteTable("test_custom", {
73
+ id: integer("id").primaryKey({ autoIncrement: true }),
74
+ name: text("name").notNull(),
75
+ }),
76
+ meta: { validation: customSchema },
77
+ });
78
+
79
+ const zodSchema = buildZodSchema(schema);
80
+ expect(zodSchema.safeParse({ name: "ab" }).success).toBe(false);
81
+ expect(zodSchema.safeParse({ name: "abc" }).success).toBe(true);
82
+ });
83
+ });
84
+
85
+ describe("validate()", () => {
86
+ it("returns empty array for valid records", () => {
87
+ const schema = table({
88
+ drizzle: sqliteTable("test_valid", {
89
+ id: integer("id").primaryKey({ autoIncrement: true }),
90
+ name: text("name").notNull(),
91
+ }),
92
+ });
93
+
94
+ expect(validate(schema, { name: "hello" })).toEqual([]);
95
+ });
96
+
97
+ it("returns field errors for invalid records", () => {
98
+ const schema = table({
99
+ drizzle: sqliteTable("test_invalid", {
100
+ id: integer("id").primaryKey({ autoIncrement: true }),
101
+ name: text("name").notNull(),
102
+ count: integer("count").notNull(),
103
+ }),
104
+ });
105
+
106
+ const errors = validate(schema, {});
107
+ expect(errors.length).toBeGreaterThan(0);
108
+ expect(errors.some((e) => e.field === "name")).toBe(true);
109
+ });
110
+ });
@@ -0,0 +1,98 @@
1
+ import { z } from "zod";
2
+ import { getTableConfig } from "drizzle-orm/sqlite-core";
3
+ import { normalizeDataType } from "../schema/normalize-datatype.js";
4
+ import type { TableDef } from "../schema/table.js";
5
+
6
+ /**
7
+ * Build a Zod schema from a Drizzle table definition.
8
+ * Infers types from column metadata. User-provided validation overrides this.
9
+ */
10
+ export function buildZodSchema(
11
+ schema: TableDef,
12
+ ): z.ZodObject<Record<string, z.ZodTypeAny>> {
13
+ // If user provided a custom validation schema, use it
14
+ if (schema.meta.validation) {
15
+ return schema.meta.validation;
16
+ }
17
+
18
+ const config = getTableConfig(schema.drizzle);
19
+ const shape: Record<string, z.ZodTypeAny> = {};
20
+
21
+ for (const col of config.columns) {
22
+ // Skip auto-generated columns (serial/identity primary keys, defaults)
23
+ if (col.primary && col.hasDefault) continue;
24
+ // Skip columns with defaults for building the "create" schema
25
+ // They become optional
26
+ const hasDefault = col.hasDefault;
27
+
28
+ // Drizzle's internal dataType varies by dialect (e.g. Pg string-mode timestamps
29
+ // report "string"). normalizeDataType() provides a stable value for validation.
30
+ const effectiveDataType = normalizeDataType(col);
31
+
32
+ let fieldSchema: z.ZodTypeAny;
33
+
34
+ switch (effectiveDataType) {
35
+ case "number":
36
+ fieldSchema = z.number();
37
+ break;
38
+ case "boolean":
39
+ fieldSchema = z.boolean();
40
+ break;
41
+ case "date":
42
+ fieldSchema = z.union([z.date(), z.string().datetime({ offset: true })]);
43
+ break;
44
+ case "string":
45
+ default:
46
+ fieldSchema = z.string();
47
+ break;
48
+ }
49
+
50
+ // Validate select options if defined
51
+ if (schema.meta.selects) {
52
+ const selectMeta = schema.meta.selects.find(
53
+ (s) => s.column === col.name,
54
+ );
55
+ if (selectMeta) {
56
+ fieldSchema = z.enum(selectMeta.options as [string, ...string[]]);
57
+ }
58
+ }
59
+
60
+ // Make nullable + optional if column allows null
61
+ if (!col.notNull) {
62
+ fieldSchema = fieldSchema.nullable().optional();
63
+ }
64
+
65
+ // Make optional if column has a default
66
+ if (hasDefault && col.notNull) {
67
+ fieldSchema = fieldSchema.optional();
68
+ }
69
+
70
+ shape[col.name] = fieldSchema;
71
+ }
72
+
73
+ return z.object(shape).strict();
74
+ }
75
+
76
+ export interface ValidationErrorDetail {
77
+ field: string;
78
+ message: string;
79
+ }
80
+
81
+ /**
82
+ * Validate a record against a table schema.
83
+ * Returns an array of errors (empty if valid).
84
+ */
85
+ export function validate(
86
+ schema: TableDef,
87
+ record: Record<string, unknown>,
88
+ ): ValidationErrorDetail[] {
89
+ const zodSchema = buildZodSchema(schema);
90
+ const result = zodSchema.safeParse(record);
91
+
92
+ if (result.success) return [];
93
+
94
+ return result.error.issues.map((issue) => ({
95
+ field: issue.path.join("."),
96
+ message: issue.message,
97
+ }));
98
+ }
@@ -0,0 +1,20 @@
1
+ export class ValidationError extends Error {
2
+ public readonly errors: Array<{ field: string; message: string }>;
3
+
4
+ constructor(errors: Array<{ field: string; message: string }>) {
5
+ const msg = errors.map((e) => `${e.field}: ${e.message}`).join(", ");
6
+ super(`Validation failed: ${msg}`);
7
+ this.name = "ValidationError";
8
+ this.errors = errors;
9
+ }
10
+ }
11
+
12
+ export class ActionError extends Error {
13
+ public readonly code: string;
14
+
15
+ constructor(message: string, code = "ACTION_ERROR") {
16
+ super(message);
17
+ this.name = "ActionError";
18
+ this.code = code;
19
+ }
20
+ }
@@ -0,0 +1,63 @@
1
+ import winston from "winston";
2
+ import type { MiddlewareHandler } from "hono";
3
+
4
+ const { combine, timestamp, printf, json } = winston.format;
5
+
6
+ /**
7
+ * Human-readable format for development.
8
+ *
9
+ * Output: `2026-03-11 14:32:01 info [runtime] Booting project project=playground`
10
+ */
11
+ const devFormat = printf(({ level, message, module, timestamp, ...rest }) => {
12
+ const mod = module ? ` [${module}]` : "";
13
+ const extras = Object.keys(rest).length > 0
14
+ ? " " + Object.entries(rest).map(([k, v]) => `${k}=${typeof v === "object" ? JSON.stringify(v) : v}`).join(" ")
15
+ : "";
16
+ return `${timestamp} ${level}${mod} ${message}${extras}`;
17
+ });
18
+
19
+ /**
20
+ * Create a Winston logger instance.
21
+ *
22
+ * Reads from environment:
23
+ * - `LOG_FORMAT` — `"json"` for structured JSON, anything else for human-readable (default)
24
+ * - `LOG_LEVEL` — Winston level threshold. Default: `"debug"`
25
+ */
26
+ export function createLogger() {
27
+ const isJson = process.env.LOG_FORMAT === "json";
28
+ const level = process.env.LOG_LEVEL ?? "debug";
29
+
30
+ return winston.createLogger({
31
+ level,
32
+ format: combine(
33
+ timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
34
+ isJson ? json() : devFormat,
35
+ ),
36
+ transports: [new winston.transports.Console()],
37
+ });
38
+ }
39
+
40
+ /** Singleton logger instance. Modules use `logger.child({ module: "name" })`. */
41
+ export const logger = createLogger();
42
+
43
+ /**
44
+ * Hono middleware that replaces `hono/logger`.
45
+ *
46
+ * Logs each request/response at the `http` level with method, path, status, and duration.
47
+ * Does NOT read the request body — that's the handler's job.
48
+ */
49
+ export function requestLogger(): MiddlewareHandler {
50
+ const log = logger.child({ module: "http" });
51
+
52
+ return async (c, next) => {
53
+ const start = Date.now();
54
+ await next();
55
+ const duration = Date.now() - start;
56
+ log.http("request", {
57
+ method: c.req.method,
58
+ path: c.req.path,
59
+ status: c.res.status,
60
+ duration: `${duration}ms`,
61
+ });
62
+ };
63
+ }