@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
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
+ }