@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,188 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
|
4
|
+
import { table } from "../schema/table.js";
|
|
5
|
+
import { crud } from "./crud.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("CRUD sub-app", () => {
|
|
29
|
+
let app: Hono;
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
const { db, sqlite } = createTestDb();
|
|
33
|
+
sqlite.exec(`
|
|
34
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
35
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
36
|
+
name TEXT NOT NULL,
|
|
37
|
+
type TEXT NOT NULL,
|
|
38
|
+
balance INTEGER
|
|
39
|
+
)
|
|
40
|
+
`);
|
|
41
|
+
|
|
42
|
+
app = new Hono();
|
|
43
|
+
app.route("/api/accounts", crud(accounts, db));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("POST / creates a record", async () => {
|
|
47
|
+
const res = await app.request("/api/accounts", {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: { "Content-Type": "application/json" },
|
|
50
|
+
body: JSON.stringify({ name: "Cash", type: "asset" }),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(res.status).toBe(201);
|
|
54
|
+
const json = await res.json();
|
|
55
|
+
expect(json.data.name).toBe("Cash");
|
|
56
|
+
expect(json.data.type).toBe("asset");
|
|
57
|
+
expect(json.data.id).toBe(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("POST / validates input", async () => {
|
|
61
|
+
const res = await app.request("/api/accounts", {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { "Content-Type": "application/json" },
|
|
64
|
+
body: JSON.stringify({ name: "Bad", type: "invalid" }),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(res.status).toBe(422);
|
|
68
|
+
const json = await res.json();
|
|
69
|
+
expect(json.error).toBe("Validation failed");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("GET / lists records with pagination", async () => {
|
|
73
|
+
// Insert two records
|
|
74
|
+
await app.request("/api/accounts", {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { "Content-Type": "application/json" },
|
|
77
|
+
body: JSON.stringify({ name: "Cash", type: "asset" }),
|
|
78
|
+
});
|
|
79
|
+
await app.request("/api/accounts", {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "Content-Type": "application/json" },
|
|
82
|
+
body: JSON.stringify({ name: "Revenue", type: "revenue" }),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const res = await app.request("/api/accounts");
|
|
86
|
+
expect(res.status).toBe(200);
|
|
87
|
+
const json = await res.json();
|
|
88
|
+
expect(json.data).toHaveLength(2);
|
|
89
|
+
expect(json.meta.total).toBe(2);
|
|
90
|
+
expect(json.meta.page).toBe(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("GET /:id returns single record", async () => {
|
|
94
|
+
await app.request("/api/accounts", {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { "Content-Type": "application/json" },
|
|
97
|
+
body: JSON.stringify({ name: "Cash", type: "asset" }),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const res = await app.request("/api/accounts/1");
|
|
101
|
+
expect(res.status).toBe(200);
|
|
102
|
+
const json = await res.json();
|
|
103
|
+
expect(json.data.name).toBe("Cash");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("GET /:id returns 404 for missing record", async () => {
|
|
107
|
+
const res = await app.request("/api/accounts/999");
|
|
108
|
+
expect(res.status).toBe(404);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("PUT /:id updates a record", async () => {
|
|
112
|
+
await app.request("/api/accounts", {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: { "Content-Type": "application/json" },
|
|
115
|
+
body: JSON.stringify({ name: "Cash", type: "asset" }),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const res = await app.request("/api/accounts/1", {
|
|
119
|
+
method: "PUT",
|
|
120
|
+
headers: { "Content-Type": "application/json" },
|
|
121
|
+
body: JSON.stringify({ name: "Cash on Hand", type: "asset" }),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(res.status).toBe(200);
|
|
125
|
+
const json = await res.json();
|
|
126
|
+
expect(json.data.name).toBe("Cash on Hand");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("DELETE /:id removes a record", async () => {
|
|
130
|
+
await app.request("/api/accounts", {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: { "Content-Type": "application/json" },
|
|
133
|
+
body: JSON.stringify({ name: "Cash", type: "asset" }),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const res = await app.request("/api/accounts/1", { method: "DELETE" });
|
|
137
|
+
expect(res.status).toBe(200);
|
|
138
|
+
|
|
139
|
+
const check = await app.request("/api/accounts/1");
|
|
140
|
+
expect(check.status).toBe(404);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("DELETE /:id returns 404 for missing record", async () => {
|
|
144
|
+
const res = await app.request("/api/accounts/999", { method: "DELETE" });
|
|
145
|
+
expect(res.status).toBe(404);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("CRUD immutable table", () => {
|
|
150
|
+
let app: Hono;
|
|
151
|
+
|
|
152
|
+
beforeEach(async () => {
|
|
153
|
+
const { db, sqlite } = createTestDb();
|
|
154
|
+
sqlite.exec(`
|
|
155
|
+
CREATE TABLE IF NOT EXISTS ledger (
|
|
156
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
157
|
+
description TEXT NOT NULL
|
|
158
|
+
)
|
|
159
|
+
`);
|
|
160
|
+
|
|
161
|
+
const ledgerTable = sqliteTable("ledger", {
|
|
162
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
163
|
+
description: text("description").notNull(),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const ledger = table({
|
|
167
|
+
drizzle: ledgerTable,
|
|
168
|
+
meta: { immutable: true },
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
app = new Hono();
|
|
172
|
+
app.route("/api/ledger", crud(ledger, db));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("blocks PUT on immutable table", async () => {
|
|
176
|
+
const res = await app.request("/api/ledger/1", {
|
|
177
|
+
method: "PUT",
|
|
178
|
+
headers: { "Content-Type": "application/json" },
|
|
179
|
+
body: JSON.stringify({ description: "updated" }),
|
|
180
|
+
});
|
|
181
|
+
expect(res.status).toBe(403);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("blocks DELETE on immutable table", async () => {
|
|
185
|
+
const res = await app.request("/api/ledger/1", { method: "DELETE" });
|
|
186
|
+
expect(res.status).toBe(403);
|
|
187
|
+
});
|
|
188
|
+
});
|
package/src/data/crud.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
import { asc, eq, sql } from "drizzle-orm";
|
|
4
|
+
import { getTableConfig } from "drizzle-orm/sqlite-core";
|
|
5
|
+
import type { TableDef } from "../schema/table.js";
|
|
6
|
+
import type { SchemaRegistry } from "../schema/registry.js";
|
|
7
|
+
import { savePipeline } from "./save-pipeline.js";
|
|
8
|
+
import { parseQuery } from "./query-parser.js";
|
|
9
|
+
import { ValidationError } from "../db/errors.js";
|
|
10
|
+
import { logger } from "../db/logger.js";
|
|
11
|
+
|
|
12
|
+
const log = logger.child({ module: "crud" });
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Standalone handler functions for CRUD operations.
|
|
16
|
+
//
|
|
17
|
+
// Called by the tables API router (tables-api.ts) which resolves the schema
|
|
18
|
+
// from the registry on each request. Each handler computes config/pkCol per-call
|
|
19
|
+
// rather than closing over them — the overhead is negligible (synchronous
|
|
20
|
+
// property lookups on small arrays).
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/** Resolve primary key column and its Drizzle reference, or return a 500 error. */
|
|
24
|
+
function resolvePk(schema: TableDef, c: Context) {
|
|
25
|
+
const config = getTableConfig(schema.drizzle);
|
|
26
|
+
const pkCol = config.columns.find((col) => col.primary);
|
|
27
|
+
if (!pkCol) {
|
|
28
|
+
return { ok: false as const, response: c.json({ error: "No primary key" }, 500) };
|
|
29
|
+
}
|
|
30
|
+
const drizzlePk = (schema.drizzle as any)[pkCol.name];
|
|
31
|
+
return { ok: true as const, pkCol, drizzlePk };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** GET / — list with filters, sort, pagination. */
|
|
35
|
+
export async function handleList(schema: TableDef, db: any, c: Context): Promise<Response> {
|
|
36
|
+
const pk = resolvePk(schema, c);
|
|
37
|
+
if (!pk.ok) return pk.response;
|
|
38
|
+
|
|
39
|
+
const params = Object.fromEntries(
|
|
40
|
+
new URL(c.req.url).searchParams.entries(),
|
|
41
|
+
);
|
|
42
|
+
const query = parseQuery(params, schema);
|
|
43
|
+
|
|
44
|
+
let q = db.select().from(schema.drizzle);
|
|
45
|
+
if (query.where) {
|
|
46
|
+
q = q.where(query.where);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Count total rows matching filters (before pagination)
|
|
50
|
+
const countResult = await db
|
|
51
|
+
.select({ count: sql<number>`count(*)` })
|
|
52
|
+
.from(schema.drizzle)
|
|
53
|
+
.where(query.where ?? sql`TRUE`);
|
|
54
|
+
const total = Number(countResult[0].count);
|
|
55
|
+
|
|
56
|
+
if (query.orderBy.length > 0) {
|
|
57
|
+
q = q.orderBy(...query.orderBy);
|
|
58
|
+
} else {
|
|
59
|
+
// Default to PK ascending so row order is stable across re-fetches.
|
|
60
|
+
// Without this, the DB may return rows in arbitrary order which shifts
|
|
61
|
+
// after UPDATEs, causing the grid to visually reorder rows on every
|
|
62
|
+
// inline cell edit.
|
|
63
|
+
q = q.orderBy(asc(pk.drizzlePk));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
q = q.limit(query.limit).offset(query.offset);
|
|
67
|
+
const rows = await q;
|
|
68
|
+
|
|
69
|
+
return c.json({
|
|
70
|
+
data: rows,
|
|
71
|
+
meta: {
|
|
72
|
+
total,
|
|
73
|
+
page: Math.floor(query.offset / query.limit) + 1,
|
|
74
|
+
limit: query.limit,
|
|
75
|
+
pages: Math.ceil(total / query.limit),
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** GET /:id — single row by primary key. */
|
|
81
|
+
export async function handleGet(schema: TableDef, db: any, c: Context, id: string): Promise<Response> {
|
|
82
|
+
const pk = resolvePk(schema, c);
|
|
83
|
+
if (!pk.ok) return pk.response;
|
|
84
|
+
|
|
85
|
+
const rows = await db
|
|
86
|
+
.select()
|
|
87
|
+
.from(schema.drizzle)
|
|
88
|
+
.where(eq(pk.drizzlePk, Number(id)));
|
|
89
|
+
|
|
90
|
+
if (rows.length === 0) {
|
|
91
|
+
return c.json({ error: "Not found" }, 404);
|
|
92
|
+
}
|
|
93
|
+
return c.json({ data: rows[0] });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** POST / — create one or more records via the save pipeline.
|
|
97
|
+
* Accepts a single object or an array of objects.
|
|
98
|
+
* When the body includes a `$details` field, treats it as a master-detail insert
|
|
99
|
+
* (requires registry to resolve the detail table's schema). */
|
|
100
|
+
export async function handleCreate(
|
|
101
|
+
schema: TableDef,
|
|
102
|
+
db: any,
|
|
103
|
+
c: Context,
|
|
104
|
+
opts?: { registry?: SchemaRegistry },
|
|
105
|
+
): Promise<Response> {
|
|
106
|
+
const body = await c.req.json();
|
|
107
|
+
log.debug("Create request", { table: schema.sqlName, body });
|
|
108
|
+
try {
|
|
109
|
+
// Master-detail insert: body has $details field
|
|
110
|
+
if (body.$details && !Array.isArray(body)) {
|
|
111
|
+
if (!opts?.registry) {
|
|
112
|
+
return c.json({ error: "Master-detail inserts require a schema registry" }, 500);
|
|
113
|
+
}
|
|
114
|
+
return await handleMasterDetailCreate(schema, db, c, body, opts.registry);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Batch or single insert
|
|
118
|
+
const records = Array.isArray(body) ? body : [body];
|
|
119
|
+
const results = [];
|
|
120
|
+
for (const record of records) {
|
|
121
|
+
results.push(await savePipeline(schema, db, record));
|
|
122
|
+
}
|
|
123
|
+
return c.json({ data: results.length === 1 ? results[0] : results }, 201);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (err instanceof ValidationError) {
|
|
126
|
+
log.warn("Create validation failed", { table: schema.sqlName, errors: err.errors });
|
|
127
|
+
return c.json({ error: "Validation failed", details: err.errors }, 422);
|
|
128
|
+
}
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Master-detail insert: insert master record, then detail records with FK backfill.
|
|
134
|
+
* Everything runs inside a single Drizzle transaction. */
|
|
135
|
+
async function handleMasterDetailCreate(
|
|
136
|
+
masterSchema: TableDef,
|
|
137
|
+
db: any,
|
|
138
|
+
c: Context,
|
|
139
|
+
body: Record<string, unknown>,
|
|
140
|
+
registry: SchemaRegistry,
|
|
141
|
+
): Promise<Response> {
|
|
142
|
+
const { $details, ...masterData } = body;
|
|
143
|
+
const details = $details as { table: string; fk: string; rows: Record<string, unknown>[] };
|
|
144
|
+
|
|
145
|
+
if (!details.table || !details.fk || !Array.isArray(details.rows)) {
|
|
146
|
+
return c.json(
|
|
147
|
+
{ error: "$details must have: table (string), fk (string), rows (array)" },
|
|
148
|
+
400,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Resolve detail table schema from registry
|
|
153
|
+
const detailEntry = registry.get(details.table);
|
|
154
|
+
if (!detailEntry) {
|
|
155
|
+
return c.json({ error: `Detail table "${details.table}" not found` }, 404);
|
|
156
|
+
}
|
|
157
|
+
const detailSchema = detailEntry.def;
|
|
158
|
+
|
|
159
|
+
// Resolve master PK column name
|
|
160
|
+
const masterConfig = getTableConfig(masterSchema.drizzle);
|
|
161
|
+
const masterPkCol = masterConfig.columns.find((col) => col.primary);
|
|
162
|
+
if (!masterPkCol) {
|
|
163
|
+
return c.json({ error: "Master table has no primary key" }, 500);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Run everything in a transaction
|
|
167
|
+
const result = await db.transaction(async (tx: any) => {
|
|
168
|
+
// Insert master record
|
|
169
|
+
const masterResult = await savePipeline(masterSchema, tx, masterData as Record<string, unknown>);
|
|
170
|
+
const masterPkValue = (masterResult as any)[masterPkCol.name];
|
|
171
|
+
|
|
172
|
+
// Insert detail records with FK backfill
|
|
173
|
+
const detailResults = [];
|
|
174
|
+
for (const row of details.rows) {
|
|
175
|
+
const detailRow = { ...row, [details.fk]: masterPkValue };
|
|
176
|
+
detailResults.push(await savePipeline(detailSchema, tx, detailRow));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { master: masterResult, details: detailResults };
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
return c.json({ data: result }, 201);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** PUT /:id — update an existing record. Rejects if table is immutable. */
|
|
186
|
+
export async function handleUpdate(schema: TableDef, db: any, c: Context, id: string): Promise<Response> {
|
|
187
|
+
if (schema.meta.immutable) {
|
|
188
|
+
return c.json({ error: "Records in this table are immutable" }, 403);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const body = await c.req.json();
|
|
192
|
+
log.debug("Update request", { table: schema.sqlName, id, body });
|
|
193
|
+
try {
|
|
194
|
+
const result = await savePipeline(schema, db, body, Number(id));
|
|
195
|
+
return c.json({ data: result });
|
|
196
|
+
} catch (err) {
|
|
197
|
+
if (err instanceof ValidationError) {
|
|
198
|
+
log.warn("Update validation failed", { table: schema.sqlName, id, errors: err.errors });
|
|
199
|
+
return c.json({ error: "Validation failed", details: err.errors }, 422);
|
|
200
|
+
}
|
|
201
|
+
if (err instanceof Error && err.message.includes("not found")) {
|
|
202
|
+
return c.json({ error: "Not found" }, 404);
|
|
203
|
+
}
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** DELETE /:id — delete a record. Rejects if table is immutable. */
|
|
209
|
+
export async function handleDelete(schema: TableDef, db: any, c: Context, id: string): Promise<Response> {
|
|
210
|
+
if (schema.meta.immutable) {
|
|
211
|
+
return c.json({ error: "Records in this table are immutable" }, 403);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const pk = resolvePk(schema, c);
|
|
215
|
+
if (!pk.ok) return pk.response;
|
|
216
|
+
|
|
217
|
+
const deleted = await db
|
|
218
|
+
.delete(schema.drizzle)
|
|
219
|
+
.where(eq(pk.drizzlePk, Number(id)))
|
|
220
|
+
.returning();
|
|
221
|
+
|
|
222
|
+
if (deleted.length === 0) {
|
|
223
|
+
return c.json({ error: "Not found" }, 404);
|
|
224
|
+
}
|
|
225
|
+
return c.json({ data: deleted[0] });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Create a CRUD sub-app for a table schema (convenience wrapper for tests).
|
|
230
|
+
* Production routing uses the dynamic router which calls handlers directly.
|
|
231
|
+
*/
|
|
232
|
+
export function crud(schema: TableDef, db: any) {
|
|
233
|
+
const app = new Hono();
|
|
234
|
+
|
|
235
|
+
app.get("/", (c) => handleList(schema, db, c));
|
|
236
|
+
app.get("/:id", (c) => handleGet(schema, db, c, c.req.param("id")));
|
|
237
|
+
app.post("/", (c) => handleCreate(schema, db, c));
|
|
238
|
+
app.put("/:id", (c) => handleUpdate(schema, db, c, c.req.param("id")));
|
|
239
|
+
app.delete("/:id", (c) => handleDelete(schema, db, c, c.req.param("id")));
|
|
240
|
+
|
|
241
|
+
return app;
|
|
242
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
|
4
|
+
import { table } from "../schema/table.js";
|
|
5
|
+
import { lookupEndpoint, findDisplayColumn } from "./lookup.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
|
+
});
|
|
13
|
+
|
|
14
|
+
const accounts = table({
|
|
15
|
+
drizzle: accountsTable,
|
|
16
|
+
meta: { label: "Accounts" },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("findDisplayColumn", () => {
|
|
20
|
+
it("returns first text non-PK column", () => {
|
|
21
|
+
expect(findDisplayColumn(accounts)).toBe("name");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("skips FK columns", () => {
|
|
25
|
+
const invoicesTable = sqliteTable("invoices", {
|
|
26
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
27
|
+
account_id: integer("account_id")
|
|
28
|
+
.notNull()
|
|
29
|
+
.references(() => accountsTable.id),
|
|
30
|
+
description: text("description").notNull(),
|
|
31
|
+
});
|
|
32
|
+
const invoices = table({ drizzle: invoicesTable, meta: {} });
|
|
33
|
+
expect(findDisplayColumn(invoices)).toBe("description");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns null when no text column exists", () => {
|
|
37
|
+
const numbersTable = sqliteTable("numbers", {
|
|
38
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
39
|
+
value: integer("value").notNull(),
|
|
40
|
+
});
|
|
41
|
+
const numbers = table({ drizzle: numbersTable, meta: {} });
|
|
42
|
+
expect(findDisplayColumn(numbers)).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("GET /_lookup", () => {
|
|
47
|
+
let app: Hono;
|
|
48
|
+
|
|
49
|
+
beforeEach(async () => {
|
|
50
|
+
const { db, sqlite } = createTestDb();
|
|
51
|
+
sqlite.exec(`
|
|
52
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
53
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
54
|
+
name TEXT NOT NULL,
|
|
55
|
+
type TEXT NOT NULL
|
|
56
|
+
)
|
|
57
|
+
`);
|
|
58
|
+
// Insert test data
|
|
59
|
+
sqlite.exec(`
|
|
60
|
+
INSERT INTO accounts (name, type) VALUES ('Cash', 'asset');
|
|
61
|
+
INSERT INTO accounts (name, type) VALUES ('Revenue', 'revenue');
|
|
62
|
+
INSERT INTO accounts (name, type) VALUES ('Rent', 'expense');
|
|
63
|
+
`);
|
|
64
|
+
|
|
65
|
+
app = new Hono();
|
|
66
|
+
app.route("/api/accounts/_lookup", lookupEndpoint(accounts, db));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns display values for given IDs", async () => {
|
|
70
|
+
const res = await app.request("/api/accounts/_lookup?ids=1,2");
|
|
71
|
+
expect(res.status).toBe(200);
|
|
72
|
+
const json = await res.json();
|
|
73
|
+
expect(json.data).toEqual({ "1": "Cash", "2": "Revenue" });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns all display values when ids param is absent", async () => {
|
|
77
|
+
const res = await app.request("/api/accounts/_lookup");
|
|
78
|
+
expect(res.status).toBe(200);
|
|
79
|
+
const json = await res.json();
|
|
80
|
+
expect(json.data).toEqual({ "1": "Cash", "2": "Revenue", "3": "Rent" });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("returns empty object for empty ids", async () => {
|
|
84
|
+
const res = await app.request("/api/accounts/_lookup?ids=");
|
|
85
|
+
expect(res.status).toBe(200);
|
|
86
|
+
const json = await res.json();
|
|
87
|
+
expect(json.data).toEqual({});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("skips IDs that do not exist", async () => {
|
|
91
|
+
const res = await app.request("/api/accounts/_lookup?ids=1,999");
|
|
92
|
+
expect(res.status).toBe(200);
|
|
93
|
+
const json = await res.json();
|
|
94
|
+
expect(json.data).toEqual({ "1": "Cash" });
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
import { inArray } from "drizzle-orm";
|
|
4
|
+
import { getTableConfig } from "drizzle-orm/sqlite-core";
|
|
5
|
+
import type { TableDef } from "../schema/table.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Find the "display" column for a table — the column whose value is shown
|
|
9
|
+
* as the human-readable label in FK dropdowns and lookup responses.
|
|
10
|
+
*
|
|
11
|
+
* Checks `meta.displayColumn` first (explicit override), then falls back
|
|
12
|
+
* to a heuristic: first text column that is neither the PK nor a FK.
|
|
13
|
+
*/
|
|
14
|
+
export function findDisplayColumn(schema: TableDef): string | null {
|
|
15
|
+
if (schema.meta.displayColumn) {
|
|
16
|
+
return schema.meta.displayColumn;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Heuristic: first text column that's not PK or FK
|
|
20
|
+
const config = getTableConfig(schema.drizzle);
|
|
21
|
+
|
|
22
|
+
const fkColumns = new Set<string>();
|
|
23
|
+
for (const fk of config.foreignKeys) {
|
|
24
|
+
const ref = fk.reference();
|
|
25
|
+
if (ref.columns[0]) fkColumns.add(ref.columns[0].name);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for (const col of config.columns) {
|
|
29
|
+
if (col.primary) continue;
|
|
30
|
+
if (fkColumns.has(col.name)) continue;
|
|
31
|
+
if (col.dataType === "string") return col.name;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Handle a lookup request: GET /?ids=1,2,3
|
|
39
|
+
* Returns { data: { "1": "Display Value", ... } }
|
|
40
|
+
* Without `ids`, returns all display values (for FK dropdowns).
|
|
41
|
+
*/
|
|
42
|
+
export async function handleLookup(schema: TableDef, db: any, c: Context): Promise<Response> {
|
|
43
|
+
const config = getTableConfig(schema.drizzle);
|
|
44
|
+
const pkCol = config.columns.find((col) => col.primary);
|
|
45
|
+
|
|
46
|
+
if (!pkCol) {
|
|
47
|
+
return c.json({ error: `Table ${config.name} has no primary key` }, 500);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const drizzlePk = (schema.drizzle as any)[pkCol.name];
|
|
51
|
+
const displayColName = findDisplayColumn(schema);
|
|
52
|
+
|
|
53
|
+
const idsParam = c.req.query("ids");
|
|
54
|
+
if (idsParam === undefined) {
|
|
55
|
+
// No ids filter — return all display values
|
|
56
|
+
const rows = await db.select().from(schema.drizzle);
|
|
57
|
+
const result: Record<string, string> = {};
|
|
58
|
+
for (const row of rows) {
|
|
59
|
+
const id = String(row[pkCol.name]);
|
|
60
|
+
if (displayColName && row[displayColName] != null) {
|
|
61
|
+
result[id] = String(row[displayColName]);
|
|
62
|
+
} else {
|
|
63
|
+
result[id] = id;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return c.json({ data: result });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ids = idsParam
|
|
70
|
+
.split(",")
|
|
71
|
+
.map((s) => s.trim())
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
.map(Number)
|
|
74
|
+
.filter((n) => !isNaN(n));
|
|
75
|
+
|
|
76
|
+
if (ids.length === 0) {
|
|
77
|
+
return c.json({ data: {} });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const rows = await db
|
|
81
|
+
.select()
|
|
82
|
+
.from(schema.drizzle)
|
|
83
|
+
.where(inArray(drizzlePk, ids));
|
|
84
|
+
|
|
85
|
+
const result: Record<string, string> = {};
|
|
86
|
+
for (const row of rows) {
|
|
87
|
+
const id = String(row[pkCol.name]);
|
|
88
|
+
if (displayColName && row[displayColName] != null) {
|
|
89
|
+
result[id] = String(row[displayColName]);
|
|
90
|
+
} else {
|
|
91
|
+
// Fallback to PK as display
|
|
92
|
+
result[id] = id;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return c.json({ data: result });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Create a lookup sub-app (convenience wrapper for tests). */
|
|
100
|
+
export function lookupEndpoint(schema: TableDef, db: any) {
|
|
101
|
+
const app = new Hono();
|
|
102
|
+
app.get("/", (c) => handleLookup(schema, db, c));
|
|
103
|
+
return app;
|
|
104
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
|
3
|
+
import { timestamp } from "../schema/table.js";
|
|
4
|
+
import { table } from "../schema/table.js";
|
|
5
|
+
import { parseQuery } from "./query-parser.js";
|
|
6
|
+
|
|
7
|
+
const ordersTable = sqliteTable("orders", {
|
|
8
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
9
|
+
customer: text("customer").notNull(),
|
|
10
|
+
amount: integer("amount").notNull(),
|
|
11
|
+
status: text("status").notNull(),
|
|
12
|
+
created_at: timestamp("created_at").notNull(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const orders = table({ drizzle: ordersTable });
|
|
16
|
+
|
|
17
|
+
describe("parseQuery()", () => {
|
|
18
|
+
it("defaults to page 1, limit 50", () => {
|
|
19
|
+
const q = parseQuery({}, orders);
|
|
20
|
+
expect(q.limit).toBe(50);
|
|
21
|
+
expect(q.offset).toBe(0);
|
|
22
|
+
expect(q.where).toBeUndefined();
|
|
23
|
+
expect(q.orderBy).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("parses page and limit", () => {
|
|
27
|
+
const q = parseQuery({ page: "3", limit: "25" }, orders);
|
|
28
|
+
expect(q.limit).toBe(25);
|
|
29
|
+
expect(q.offset).toBe(50); // (3-1) * 25
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("caps limit at 1000", () => {
|
|
33
|
+
const q = parseQuery({ limit: "5000" }, orders);
|
|
34
|
+
expect(q.limit).toBe(1000);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("parses simple filter", () => {
|
|
38
|
+
const q = parseQuery({ "filter[status]": "paid" }, orders);
|
|
39
|
+
expect(q.where).toBeDefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("parses operator filters", () => {
|
|
43
|
+
const q = parseQuery({ "filter[amount][gt]": "100" }, orders);
|
|
44
|
+
expect(q.where).toBeDefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("parses sort ascending", () => {
|
|
48
|
+
const q = parseQuery({ sort: "customer" }, orders);
|
|
49
|
+
expect(q.orderBy).toHaveLength(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("parses sort descending", () => {
|
|
53
|
+
const q = parseQuery({ sort: "-created_at" }, orders);
|
|
54
|
+
expect(q.orderBy).toHaveLength(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("parses multiple sort fields", () => {
|
|
58
|
+
const q = parseQuery({ sort: "-amount,customer" }, orders);
|
|
59
|
+
expect(q.orderBy).toHaveLength(2);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("ignores unknown columns", () => {
|
|
63
|
+
const q = parseQuery({ "filter[unknown]": "val", sort: "unknown" }, orders);
|
|
64
|
+
expect(q.where).toBeUndefined();
|
|
65
|
+
expect(q.orderBy).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
});
|