@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
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { drizzle, type BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
|
3
|
+
import type { ProjectDbConnection } from "../db/sqlite-connection.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create an in-memory SQLite database for testing.
|
|
7
|
+
*
|
|
8
|
+
* Returns both the raw better-sqlite3 handle and a Drizzle instance.
|
|
9
|
+
* This is the "unit test" variant — use it for testing individual functions
|
|
10
|
+
* that accept a Drizzle db parameter.
|
|
11
|
+
*
|
|
12
|
+
* Configures WAL mode and foreign keys to match production settings
|
|
13
|
+
* from sqlite-connection.ts.
|
|
14
|
+
*/
|
|
15
|
+
export function createTestDb(): {
|
|
16
|
+
sqlite: Database.Database;
|
|
17
|
+
db: BetterSQLite3Database;
|
|
18
|
+
} {
|
|
19
|
+
const sqlite = new Database(":memory:");
|
|
20
|
+
// Match production PRAGMA settings from sqlite-connection.ts.
|
|
21
|
+
// WAL mode isn't meaningful for :memory: but setting it keeps the
|
|
22
|
+
// configuration path consistent with production.
|
|
23
|
+
sqlite.pragma("journal_mode = WAL");
|
|
24
|
+
sqlite.pragma("foreign_keys = ON");
|
|
25
|
+
const db = drizzle(sqlite) as BetterSQLite3Database;
|
|
26
|
+
return { sqlite, db };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create an in-memory SQLite ProjectDbConnection for integration tests.
|
|
31
|
+
*
|
|
32
|
+
* The returned connection has the same ProjectDbConnection shape as
|
|
33
|
+
* production (sqlite + db), backed by an ephemeral in-memory database.
|
|
34
|
+
*/
|
|
35
|
+
export function createTestConnection(): {
|
|
36
|
+
conn: ProjectDbConnection;
|
|
37
|
+
teardown: () => void;
|
|
38
|
+
} {
|
|
39
|
+
const { sqlite, db } = createTestDb();
|
|
40
|
+
return {
|
|
41
|
+
conn: { sqlite, db },
|
|
42
|
+
teardown: () => sqlite.close(),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { loadViewMeta } from "./loader.js";
|
|
6
|
+
|
|
7
|
+
describe("loadViewMeta", () => {
|
|
8
|
+
let dir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
dir = await mkdtemp(join(tmpdir(), "sapporta-view-test-"));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await rm(dir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("derives name and label from .tsx filename", async () => {
|
|
19
|
+
await writeFile(join(dir, "dashboard.tsx"), "export default function() { return null; }");
|
|
20
|
+
|
|
21
|
+
const views = await loadViewMeta(dir);
|
|
22
|
+
expect(views).toHaveLength(1);
|
|
23
|
+
expect(views[0]).toEqual({ name: "dashboard", label: "Dashboard" });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("humanizes hyphenated filenames", async () => {
|
|
27
|
+
await writeFile(join(dir, "log-meal.tsx"), "export default function() { return null; }");
|
|
28
|
+
|
|
29
|
+
const views = await loadViewMeta(dir);
|
|
30
|
+
expect(views).toHaveLength(1);
|
|
31
|
+
expect(views[0]).toEqual({ name: "log-meal", label: "Log Meal" });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("skips non-.tsx files", async () => {
|
|
35
|
+
await writeFile(join(dir, "helpers.ts"), "export const x = 1;");
|
|
36
|
+
await writeFile(join(dir, "style.css"), "body {}");
|
|
37
|
+
|
|
38
|
+
const views = await loadViewMeta(dir);
|
|
39
|
+
expect(views).toHaveLength(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("skips test files", async () => {
|
|
43
|
+
await writeFile(join(dir, "my-view.test.ts"), "");
|
|
44
|
+
await writeFile(join(dir, "my-view.test.tsx"), "");
|
|
45
|
+
|
|
46
|
+
const views = await loadViewMeta(dir);
|
|
47
|
+
expect(views).toHaveLength(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("skips files starting with underscore", async () => {
|
|
51
|
+
await writeFile(join(dir, "_internal.tsx"), "");
|
|
52
|
+
|
|
53
|
+
const views = await loadViewMeta(dir);
|
|
54
|
+
expect(views).toHaveLength(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns empty array for empty directory", async () => {
|
|
58
|
+
const views = await loadViewMeta(dir);
|
|
59
|
+
expect(views).toHaveLength(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("loads multiple views from multiple files", async () => {
|
|
63
|
+
await writeFile(join(dir, "alpha.tsx"), "");
|
|
64
|
+
await writeFile(join(dir, "beta.tsx"), "");
|
|
65
|
+
|
|
66
|
+
const views = await loadViewMeta(dir);
|
|
67
|
+
expect(views).toHaveLength(2);
|
|
68
|
+
expect(views.map((v) => v.name).sort()).toEqual(["alpha", "beta"]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { ViewMeta } from "./view.js";
|
|
4
|
+
|
|
5
|
+
/** "log-meal" → "Log Meal" */
|
|
6
|
+
function humanize(slug: string): string {
|
|
7
|
+
return slug
|
|
8
|
+
.split("-")
|
|
9
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
10
|
+
.join(" ");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Load view metadata from a project's views/ directory.
|
|
15
|
+
*
|
|
16
|
+
* Metadata is derived entirely from filenames — no file imports needed.
|
|
17
|
+
* `log-meal.tsx` → { name: "log-meal", label: "Log Meal" }
|
|
18
|
+
*
|
|
19
|
+
* Files starting with `_` are treated as internal helpers and skipped.
|
|
20
|
+
* Test files (*.test.ts, *.test.tsx) are also skipped.
|
|
21
|
+
* Only `.tsx` files are considered views.
|
|
22
|
+
*/
|
|
23
|
+
export async function loadViewMeta(dir: string): Promise<ViewMeta[]> {
|
|
24
|
+
const absDir = resolve(dir);
|
|
25
|
+
const files = await readdir(absDir);
|
|
26
|
+
const views: ViewMeta[] = [];
|
|
27
|
+
|
|
28
|
+
for (const file of files) {
|
|
29
|
+
if (file.startsWith("_")) continue;
|
|
30
|
+
if (file.endsWith(".test.ts") || file.endsWith(".test.tsx")) continue;
|
|
31
|
+
if (!file.endsWith(".tsx")) continue;
|
|
32
|
+
|
|
33
|
+
const name = file.replace(/\.tsx$/, "");
|
|
34
|
+
views.push({ name, label: humanize(name) });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return views;
|
|
38
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import type { ViewMeta } from "./view.js";
|
|
5
|
+
import { viewApi } from "../api/views.js";
|
|
6
|
+
import { action } from "../actions/action.js";
|
|
7
|
+
import { actionApi } from "../api/actions.js";
|
|
8
|
+
|
|
9
|
+
describe("ViewMeta type", () => {
|
|
10
|
+
it("accepts a valid ViewMeta object", () => {
|
|
11
|
+
const meta: ViewMeta = {
|
|
12
|
+
name: "log-meal",
|
|
13
|
+
label: "Log Meal",
|
|
14
|
+
icon: "utensils",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
expect(meta.name).toBe("log-meal");
|
|
18
|
+
expect(meta.label).toBe("Log Meal");
|
|
19
|
+
expect(meta.icon).toBe("utensils");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("icon is optional", () => {
|
|
23
|
+
const meta: ViewMeta = {
|
|
24
|
+
name: "dashboard",
|
|
25
|
+
label: "Dashboard",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
expect(meta.icon).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("viewApi", () => {
|
|
33
|
+
const views: ViewMeta[] = [
|
|
34
|
+
{ name: "log-meal", label: "Log Meal", icon: "utensils" },
|
|
35
|
+
{ name: "dashboard", label: "Dashboard" },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
it("GET / lists views with metadata", async () => {
|
|
39
|
+
const app = new Hono();
|
|
40
|
+
app.route("/views", viewApi(views));
|
|
41
|
+
|
|
42
|
+
const res = await app.request("/views");
|
|
43
|
+
expect(res.status).toBe(200);
|
|
44
|
+
|
|
45
|
+
const json = await res.json();
|
|
46
|
+
expect(json.views).toHaveLength(2);
|
|
47
|
+
expect(json.views[0]).toEqual({ name: "log-meal", label: "Log Meal", icon: "utensils" });
|
|
48
|
+
expect(json.views[1]).toEqual({ name: "dashboard", label: "Dashboard", icon: undefined });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("GET /:name returns view metadata", async () => {
|
|
52
|
+
const app = new Hono();
|
|
53
|
+
app.route("/views", viewApi(views));
|
|
54
|
+
|
|
55
|
+
const res = await app.request("/views/log-meal");
|
|
56
|
+
expect(res.status).toBe(200);
|
|
57
|
+
|
|
58
|
+
const json = await res.json();
|
|
59
|
+
expect(json.name).toBe("log-meal");
|
|
60
|
+
expect(json.label).toBe("Log Meal");
|
|
61
|
+
expect(json.icon).toBe("utensils");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("GET /:name returns 404 for unknown view", async () => {
|
|
65
|
+
const app = new Hono();
|
|
66
|
+
app.route("/views", viewApi(views));
|
|
67
|
+
|
|
68
|
+
const res = await app.request("/views/nonexistent");
|
|
69
|
+
expect(res.status).toBe(404);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("GET /actions (action listing)", () => {
|
|
74
|
+
it("lists actions with JSON Schema from Zod", async () => {
|
|
75
|
+
const createItem = action({
|
|
76
|
+
name: "create_item",
|
|
77
|
+
label: "Create Item",
|
|
78
|
+
input: z.object({
|
|
79
|
+
name: z.string().min(1),
|
|
80
|
+
quantity: z.number().optional(),
|
|
81
|
+
}),
|
|
82
|
+
run: async ({ input }) => input,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const app = new Hono();
|
|
86
|
+
// Need a mock db for actionApi — just use null since GET doesn't need it
|
|
87
|
+
app.route("/actions", actionApi([createItem], null as any));
|
|
88
|
+
|
|
89
|
+
const res = await app.request("/actions");
|
|
90
|
+
expect(res.status).toBe(200);
|
|
91
|
+
|
|
92
|
+
const json = await res.json();
|
|
93
|
+
expect(json.actions).toHaveLength(1);
|
|
94
|
+
expect(json.actions[0].name).toBe("create_item");
|
|
95
|
+
expect(json.actions[0].label).toBe("Create Item");
|
|
96
|
+
|
|
97
|
+
// Verify JSON Schema structure
|
|
98
|
+
const schema = json.actions[0].inputSchema;
|
|
99
|
+
expect(schema.type).toBe("object");
|
|
100
|
+
expect(schema.properties.name).toBeDefined();
|
|
101
|
+
expect(schema.properties.name.type).toBe("string");
|
|
102
|
+
expect(schema.properties.quantity).toBeDefined();
|
|
103
|
+
expect(schema.required).toContain("name");
|
|
104
|
+
expect(schema.required).not.toContain("quantity");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("handles actions without label", async () => {
|
|
108
|
+
const act = action({
|
|
109
|
+
name: "simple",
|
|
110
|
+
input: z.object({ value: z.number() }),
|
|
111
|
+
run: async ({ input }) => input,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const app = new Hono();
|
|
115
|
+
app.route("/actions", actionApi([act], null as any));
|
|
116
|
+
|
|
117
|
+
const res = await app.request("/actions");
|
|
118
|
+
const json = await res.json();
|
|
119
|
+
expect(json.actions[0].label).toBeUndefined();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* View metadata — describes a custom screen defined as a React component
|
|
3
|
+
* in the project's `views/` directory.
|
|
4
|
+
*
|
|
5
|
+
* Metadata is derived from the filename: `log-meal.tsx` becomes
|
|
6
|
+
* `{ name: "log-meal", label: "Log Meal" }`. View files only need a
|
|
7
|
+
* default React component export.
|
|
8
|
+
*/
|
|
9
|
+
export interface ViewMeta {
|
|
10
|
+
/** URL-friendly name (used in routing, must match filename) */
|
|
11
|
+
name: string;
|
|
12
|
+
/** Human-readable label (shown in sidebar) */
|
|
13
|
+
label: string;
|
|
14
|
+
/** Lucide icon name (default: "layout-dashboard") */
|
|
15
|
+
icon?: string;
|
|
16
|
+
}
|