@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.
- package/package.json +40 -0
- package/src/actions/action.test.ts +108 -0
- package/src/actions/action.ts +60 -0
- package/src/actions/loader.ts +47 -0
- package/src/api/actions.ts +124 -0
- package/src/api/meta-mutations.ts +922 -0
- package/src/api/meta.ts +222 -0
- package/src/api/reports.ts +98 -0
- package/src/api/server.ts +24 -0
- package/src/api/tables.ts +108 -0
- package/src/api/views.ts +44 -0
- package/src/boot.ts +206 -0
- package/src/cli/ai-commands.ts +220 -0
- package/src/cli/check.ts +169 -0
- package/src/cli/cli-utils.test.ts +313 -0
- package/src/cli/describe.test.ts +151 -0
- package/src/cli/describe.ts +88 -0
- package/src/cli/emit-result.test.ts +160 -0
- package/src/cli/format.ts +150 -0
- package/src/cli/http-client.ts +55 -0
- package/src/cli/index.ts +162 -0
- package/src/cli/init.ts +35 -0
- package/src/cli/project-context.ts +38 -0
- package/src/cli/request.ts +146 -0
- package/src/cli/routes.ts +418 -0
- package/src/cli/rows-insert-master-detail.test.ts +124 -0
- package/src/cli/rows-insert-master-detail.ts +186 -0
- package/src/cli/rows-insert.test.ts +137 -0
- package/src/cli/rows-insert.ts +97 -0
- package/src/cli/serve-single.ts +49 -0
- package/src/create-project.ts +81 -0
- package/src/data/count.ts +62 -0
- package/src/data/crud.test.ts +188 -0
- package/src/data/crud.ts +242 -0
- package/src/data/lookup.test.ts +96 -0
- package/src/data/lookup.ts +104 -0
- package/src/data/query-parser.test.ts +67 -0
- package/src/data/query-parser.ts +106 -0
- package/src/data/sanitize.test.ts +57 -0
- package/src/data/sanitize.ts +25 -0
- package/src/data/save-pipeline.test.ts +115 -0
- package/src/data/save-pipeline.ts +93 -0
- package/src/data/validate.test.ts +110 -0
- package/src/data/validate.ts +98 -0
- package/src/db/errors.ts +20 -0
- package/src/db/logger.ts +63 -0
- package/src/db/sqlite-connection.test.ts +59 -0
- package/src/db/sqlite-connection.ts +79 -0
- package/src/index.ts +111 -0
- package/src/integration/api-actions.test.ts +60 -0
- package/src/integration/api-global.test.ts +21 -0
- package/src/integration/api-meta.test.ts +252 -0
- package/src/integration/api-reports.test.ts +77 -0
- package/src/integration/api-tables.test.ts +238 -0
- package/src/integration/api-views.test.ts +39 -0
- package/src/integration/cli-routes.test.ts +167 -0
- package/src/integration/fixtures/actions/create-account.ts +23 -0
- package/src/integration/fixtures/reports/account-list.ts +25 -0
- package/src/integration/fixtures/schema/accounts.ts +21 -0
- package/src/integration/fixtures/schema/audit-log.ts +19 -0
- package/src/integration/fixtures/schema/journal-entries.ts +20 -0
- package/src/integration/fixtures/views/dashboard.tsx +4 -0
- package/src/integration/fixtures/views/settings.tsx +3 -0
- package/src/integration/setup.ts +72 -0
- package/src/introspect/db-helpers.ts +109 -0
- package/src/introspect/describe-all.test.ts +73 -0
- package/src/introspect/describe-all.ts +80 -0
- package/src/introspect/describe.test.ts +65 -0
- package/src/introspect/describe.ts +184 -0
- package/src/introspect/exec.test.ts +103 -0
- package/src/introspect/exec.ts +57 -0
- package/src/introspect/indexes.test.ts +41 -0
- package/src/introspect/indexes.ts +95 -0
- package/src/introspect/inference.ts +98 -0
- package/src/introspect/list-tables.test.ts +40 -0
- package/src/introspect/list-tables.ts +62 -0
- package/src/introspect/query.test.ts +77 -0
- package/src/introspect/query.ts +47 -0
- package/src/introspect/sample.test.ts +67 -0
- package/src/introspect/sample.ts +50 -0
- package/src/introspect/sql-safety.ts +76 -0
- package/src/introspect/sqlite/db-helpers.test.ts +79 -0
- package/src/introspect/sqlite/db-helpers.ts +56 -0
- package/src/introspect/sqlite/describe-all.ts +21 -0
- package/src/introspect/sqlite/describe.test.ts +160 -0
- package/src/introspect/sqlite/describe.ts +185 -0
- package/src/introspect/sqlite/exec.ts +57 -0
- package/src/introspect/sqlite/indexes.test.ts +60 -0
- package/src/introspect/sqlite/indexes.ts +96 -0
- package/src/introspect/sqlite/list-tables.test.ts +100 -0
- package/src/introspect/sqlite/list-tables.ts +67 -0
- package/src/introspect/sqlite/query.ts +49 -0
- package/src/introspect/sqlite/sample.ts +50 -0
- package/src/introspect/table-rename.test.ts +235 -0
- package/src/introspect/table-rename.ts +115 -0
- package/src/introspect/types.ts +95 -0
- package/src/reports/check.test.ts +499 -0
- package/src/reports/check.ts +208 -0
- package/src/reports/engine.test.ts +1465 -0
- package/src/reports/engine.ts +678 -0
- package/src/reports/loader.ts +55 -0
- package/src/reports/report.ts +308 -0
- package/src/reports/sql-bind.ts +161 -0
- package/src/reports/sqlite-bind.test.ts +98 -0
- package/src/reports/sqlite-bind.ts +58 -0
- package/src/reports/sqlite-sql-client.ts +42 -0
- package/src/runtime.ts +3 -0
- package/src/schema/check.ts +90 -0
- package/src/schema/ddl.test.ts +210 -0
- package/src/schema/ddl.ts +180 -0
- package/src/schema/dynamic-builder.ts +297 -0
- package/src/schema/extract.test.ts +261 -0
- package/src/schema/extract.ts +285 -0
- package/src/schema/loader.test.ts +31 -0
- package/src/schema/loader.ts +60 -0
- package/src/schema/metadata-io.test.ts +261 -0
- package/src/schema/metadata-io.ts +161 -0
- package/src/schema/metadata-tables.test.ts +737 -0
- package/src/schema/metadata-tables.ts +341 -0
- package/src/schema/migrate.ts +195 -0
- package/src/schema/normalize-datatype.test.ts +58 -0
- package/src/schema/normalize-datatype.ts +99 -0
- package/src/schema/registry.test.ts +174 -0
- package/src/schema/registry.ts +139 -0
- package/src/schema/reserved.ts +227 -0
- package/src/schema/table.ts +135 -0
- package/src/test-fixtures/schema/accounts.ts +24 -0
- package/src/test-fixtures/schema/not-a-table.ts +6 -0
- package/src/testing/test-utils.ts +44 -0
- package/src/views/loader.test.ts +70 -0
- package/src/views/loader.ts +38 -0
- package/src/views/view.test.ts +121 -0
- package/src/views/view.ts +16 -0
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sapporta/server",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"author": "Jasim A Basheer <jasim@nuabase.com>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "src/index.ts",
|
|
8
|
+
"types": "src/index.ts",
|
|
9
|
+
"files": ["src/", "package.json"],
|
|
10
|
+
"publishConfig": { "access": "public" },
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./src/index.ts",
|
|
13
|
+
"./table": "./src/schema/table.ts",
|
|
14
|
+
"./report": "./src/reports/report.ts",
|
|
15
|
+
"./action": "./src/actions/action.ts",
|
|
16
|
+
"./errors": "./src/db/errors.ts",
|
|
17
|
+
"./view": "./src/views/view.ts",
|
|
18
|
+
"./boot": "./src/boot.ts",
|
|
19
|
+
"./runtime": "./src/runtime.ts",
|
|
20
|
+
"./*": "./src/*.ts"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"typecheck": "tsc --noEmit"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@hono/node-server": "^1.14.1",
|
|
27
|
+
"better-sqlite3": "^11.x",
|
|
28
|
+
"commander": "^14.0.3",
|
|
29
|
+
"drizzle-orm": "^0.38.4",
|
|
30
|
+
"hono": "^4.7.4",
|
|
31
|
+
"nuabase": "^2.3.0",
|
|
32
|
+
"winston": "^3.17.0",
|
|
33
|
+
"zod": "^4.3.6"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/better-sqlite3": "^7.x",
|
|
37
|
+
"drizzle-kit": "^0.31.9",
|
|
38
|
+
"typescript": "^5.7.3"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
|
5
|
+
import { action } from "./action.js";
|
|
6
|
+
import { actionApi } from "../api/actions.js";
|
|
7
|
+
import { createTestDb } from "../testing/test-utils.js";
|
|
8
|
+
import { table } from "../schema/table.js";
|
|
9
|
+
|
|
10
|
+
const itemsTable = sqliteTable("items", {
|
|
11
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
12
|
+
name: text("name").notNull(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const items = table({ drizzle: itemsTable });
|
|
16
|
+
|
|
17
|
+
describe("Actions system", () => {
|
|
18
|
+
let app: Hono;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
const { db, sqlite } = createTestDb();
|
|
22
|
+
sqlite.exec(`
|
|
23
|
+
CREATE TABLE IF NOT EXISTS items (
|
|
24
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
25
|
+
name TEXT NOT NULL
|
|
26
|
+
)
|
|
27
|
+
`);
|
|
28
|
+
|
|
29
|
+
const createItem = action({
|
|
30
|
+
name: "create_item",
|
|
31
|
+
input: z.object({ name: z.string().min(1) }),
|
|
32
|
+
// SQLite transactions are synchronous — run() must NOT be async.
|
|
33
|
+
// Drizzle's better-sqlite3 operations return values directly.
|
|
34
|
+
run: ({ input, db: txDb }) => {
|
|
35
|
+
const result = txDb
|
|
36
|
+
.insert(items.drizzle)
|
|
37
|
+
.values({ name: input.name })
|
|
38
|
+
.returning()
|
|
39
|
+
.all();
|
|
40
|
+
return result[0];
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
app = new Hono();
|
|
45
|
+
app.route("/actions", actionApi([createItem], db as any));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("POST /actions/:name executes the action", async () => {
|
|
49
|
+
const res = await app.request("/actions/create_item", {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "Content-Type": "application/json" },
|
|
52
|
+
body: JSON.stringify({ name: "Widget" }),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(res.status).toBe(200);
|
|
56
|
+
const json = await res.json();
|
|
57
|
+
expect(json.data.name).toBe("Widget");
|
|
58
|
+
expect(json.data.id).toBeDefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("validates input", async () => {
|
|
62
|
+
const res = await app.request("/actions/create_item", {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: { "Content-Type": "application/json" },
|
|
65
|
+
body: JSON.stringify({ name: "" }),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(res.status).toBe(422);
|
|
69
|
+
const json = await res.json();
|
|
70
|
+
expect(json.error).toBe("Validation failed");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("validates missing fields", async () => {
|
|
74
|
+
const res = await app.request("/actions/create_item", {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { "Content-Type": "application/json" },
|
|
77
|
+
body: JSON.stringify({}),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(res.status).toBe(422);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("action() definition", () => {
|
|
85
|
+
it("creates an ActionInstance", () => {
|
|
86
|
+
const act = action({
|
|
87
|
+
name: "test_action",
|
|
88
|
+
input: z.object({ value: z.number() }),
|
|
89
|
+
run: ({ input }) => input.value * 2,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(act.name).toBe("test_action");
|
|
93
|
+
expect(typeof act.run).toBe("function");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("supports afterCommit callbacks", () => {
|
|
97
|
+
const callbacks: string[] = [];
|
|
98
|
+
|
|
99
|
+
const act = action({
|
|
100
|
+
name: "with_callback",
|
|
101
|
+
input: z.object({}),
|
|
102
|
+
run: () => "done",
|
|
103
|
+
afterCommit: [(result) => { callbacks.push(result as string); }],
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(act.afterCommit).toHaveLength(1);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
|
3
|
+
|
|
4
|
+
export interface ActionConfig<TInput extends z.ZodTypeAny, TOutput> {
|
|
5
|
+
/** Action name — used as the URL path */
|
|
6
|
+
name: string;
|
|
7
|
+
/** Human-readable label (used in UI) */
|
|
8
|
+
label?: string;
|
|
9
|
+
/** Zod schema for input validation */
|
|
10
|
+
input: TInput;
|
|
11
|
+
/**
|
|
12
|
+
* The action handler — receives validated input and a db transaction.
|
|
13
|
+
*
|
|
14
|
+
* Runs inside a SQLite transaction (synchronous). The handler MUST be
|
|
15
|
+
* synchronous — better-sqlite3 transactions reject async callbacks.
|
|
16
|
+
* Drizzle's SQLite operations return values directly (no await needed).
|
|
17
|
+
*/
|
|
18
|
+
run: (ctx: {
|
|
19
|
+
input: z.infer<TInput>;
|
|
20
|
+
db: BetterSQLite3Database;
|
|
21
|
+
}) => TOutput;
|
|
22
|
+
/** Callbacks to run after the transaction commits */
|
|
23
|
+
afterCommit?: ((result: TOutput) => void | Promise<void>)[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ActionInstance<TInput extends z.ZodTypeAny, TOutput> {
|
|
27
|
+
name: string;
|
|
28
|
+
label?: string;
|
|
29
|
+
input: TInput;
|
|
30
|
+
run: ActionConfig<TInput, TOutput>["run"];
|
|
31
|
+
afterCommit?: ActionConfig<TInput, TOutput>["afterCommit"];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Define an action. Actions are transactional operations exposed as API endpoints.
|
|
36
|
+
*
|
|
37
|
+
* Usage:
|
|
38
|
+
* ```ts
|
|
39
|
+
* export default action({
|
|
40
|
+
* name: "create_account",
|
|
41
|
+
* input: z.object({ name: z.string(), type: z.string() }),
|
|
42
|
+
* run: ({ input, db }) => {
|
|
43
|
+
* // Drizzle SQLite operations are synchronous — no await needed.
|
|
44
|
+
* const result = db.insert(accounts).values(input).returning().all();
|
|
45
|
+
* return result[0];
|
|
46
|
+
* },
|
|
47
|
+
* });
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export function action<TInput extends z.ZodTypeAny, TOutput>(
|
|
51
|
+
config: ActionConfig<TInput, TOutput>,
|
|
52
|
+
): ActionInstance<TInput, TOutput> {
|
|
53
|
+
return {
|
|
54
|
+
name: config.name,
|
|
55
|
+
label: config.label,
|
|
56
|
+
input: config.input,
|
|
57
|
+
run: config.run,
|
|
58
|
+
afterCommit: config.afterCommit,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { resolve, join } from "node:path";
|
|
3
|
+
import type { ActionInstance } from "./action.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check if a value looks like an ActionInstance (duck-typing).
|
|
7
|
+
*/
|
|
8
|
+
function isAction(val: unknown): val is ActionInstance<any, any> {
|
|
9
|
+
if (typeof val !== "object" || val === null) return false;
|
|
10
|
+
const obj = val as Record<string, unknown>;
|
|
11
|
+
return typeof obj.name === "string" && typeof obj.run === "function";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Load all actions from a directory.
|
|
16
|
+
* Each .ts file should default-export an action().
|
|
17
|
+
*/
|
|
18
|
+
export async function loadActions(
|
|
19
|
+
dir: string,
|
|
20
|
+
): Promise<ActionInstance<any, any>[]> {
|
|
21
|
+
const absDir = resolve(dir);
|
|
22
|
+
const files = await readdir(absDir);
|
|
23
|
+
const actions: ActionInstance<any, any>[] = [];
|
|
24
|
+
|
|
25
|
+
for (const file of files) {
|
|
26
|
+
if (!file.endsWith(".ts") && !file.endsWith(".js")) continue;
|
|
27
|
+
if (file.endsWith(".test.ts") || file.endsWith(".test.js")) continue;
|
|
28
|
+
|
|
29
|
+
const filePath = join(absDir, file);
|
|
30
|
+
const mod = await import(filePath);
|
|
31
|
+
|
|
32
|
+
// Check default export first
|
|
33
|
+
if (mod.default && isAction(mod.default)) {
|
|
34
|
+
actions.push(mod.default);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Check named exports
|
|
39
|
+
for (const key of Object.keys(mod)) {
|
|
40
|
+
if (isAction(mod[key])) {
|
|
41
|
+
actions.push(mod[key]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return actions;
|
|
47
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import type { ActionInstance } from "../actions/action.js";
|
|
5
|
+
import { ActionError } from "../db/errors.js";
|
|
6
|
+
import { logger } from "../db/logger.js";
|
|
7
|
+
|
|
8
|
+
const log = logger.child({ module: "actions" });
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Convert a Zod schema to JSON Schema using Zod v4's built-in converter.
|
|
12
|
+
* Returns undefined if conversion fails.
|
|
13
|
+
*/
|
|
14
|
+
function zodToJsonSchema(schema: z.ZodTypeAny): Record<string, unknown> | undefined {
|
|
15
|
+
try {
|
|
16
|
+
return z.toJSONSchema(schema) as Record<string, unknown>;
|
|
17
|
+
} catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create the /actions API sub-app. Mounted at `/p/{slug}/actions` by the runtime.
|
|
24
|
+
*
|
|
25
|
+
* - GET / → list all actions with input JSON Schema
|
|
26
|
+
* - POST /:name → execute an action (validated, transactional)
|
|
27
|
+
*
|
|
28
|
+
* Uses a single parametric /:name route backed by a Map lookup.
|
|
29
|
+
*
|
|
30
|
+
* ## Transaction semantics
|
|
31
|
+
*
|
|
32
|
+
* Each action's `run` function executes inside a single Drizzle transaction.
|
|
33
|
+
* If `run` throws, the transaction is rolled back automatically. The `afterCommit`
|
|
34
|
+
* callbacks run AFTER the transaction has committed — they're for side effects
|
|
35
|
+
* only (sending emails, logging, webhooks). Failures in afterCommit do NOT
|
|
36
|
+
* roll back the transaction.
|
|
37
|
+
*
|
|
38
|
+
* ## Error handling
|
|
39
|
+
*
|
|
40
|
+
* - Zod validation failure → 422 with field-level error details
|
|
41
|
+
* - ActionError (thrown by business logic) → 400 with error message and code
|
|
42
|
+
* - Unhandled errors → bubble up to Hono's error handler (500)
|
|
43
|
+
*
|
|
44
|
+
* ActionError is the intentional "business rule violated" signal. Unhandled
|
|
45
|
+
* errors are bugs. This distinction is important: the frontend shows ActionError
|
|
46
|
+
* messages to the user but treats unhandled errors as unexpected failures.
|
|
47
|
+
*/
|
|
48
|
+
export function actionApi(
|
|
49
|
+
actions: ActionInstance<any, any>[],
|
|
50
|
+
db: BetterSQLite3Database,
|
|
51
|
+
): Hono {
|
|
52
|
+
const actionMap = new Map(actions.map((a) => [a.name, a]));
|
|
53
|
+
const app = new Hono();
|
|
54
|
+
|
|
55
|
+
app.get("/", (c) => {
|
|
56
|
+
return c.json({
|
|
57
|
+
actions: actions.map((act) => ({
|
|
58
|
+
name: act.name,
|
|
59
|
+
label: act.label,
|
|
60
|
+
inputSchema: zodToJsonSchema(act.input),
|
|
61
|
+
})),
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
app.post("/:name", async (c) => {
|
|
66
|
+
const act = actionMap.get(c.req.param("name"));
|
|
67
|
+
if (!act) return c.notFound();
|
|
68
|
+
|
|
69
|
+
const body = await c.req.json();
|
|
70
|
+
log.debug("Action request", { action: act.name, body });
|
|
71
|
+
|
|
72
|
+
// Validate input
|
|
73
|
+
const parsed = act.input.safeParse(body);
|
|
74
|
+
if (!parsed.success) {
|
|
75
|
+
const issues = parsed.error.issues.map(
|
|
76
|
+
(i: { path: string[]; message: string }) => ({
|
|
77
|
+
field: i.path.join("."),
|
|
78
|
+
message: i.message,
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
log.warn("Action validation failed", { action: act.name, issues });
|
|
82
|
+
return c.json(
|
|
83
|
+
{
|
|
84
|
+
error: "Validation failed",
|
|
85
|
+
details: issues,
|
|
86
|
+
},
|
|
87
|
+
422,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
// Run inside a Drizzle SQLite transaction. If run() throws, the
|
|
93
|
+
// transaction rolls back and no afterCommit callbacks execute.
|
|
94
|
+
//
|
|
95
|
+
// IMPORTANT: SQLite transactions are SYNCHRONOUS — the callback
|
|
96
|
+
// cannot be async or return a Promise. This means action run()
|
|
97
|
+
// functions must be synchronous when using Drizzle operations.
|
|
98
|
+
// The `tx` object is a transactional Drizzle client — all queries
|
|
99
|
+
// through it share a single SQLite transaction.
|
|
100
|
+
const result = db.transaction((tx) => {
|
|
101
|
+
return act.run({ input: parsed.data, db: tx as any });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// afterCommit runs AFTER the transaction has committed successfully.
|
|
105
|
+
// Use for side effects (email, audit log, webhooks) that should NOT
|
|
106
|
+
// roll back the transaction if they fail. Callbacks run sequentially.
|
|
107
|
+
if (act.afterCommit) {
|
|
108
|
+
for (const cb of act.afterCommit) {
|
|
109
|
+
await cb(result);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return c.json({ data: result });
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (err instanceof ActionError) {
|
|
116
|
+
log.warn("Action error", { action: act.name, code: err.code, message: err.message });
|
|
117
|
+
return c.json({ error: err.message, code: err.code }, 400);
|
|
118
|
+
}
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return app;
|
|
124
|
+
}
|