@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,186 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { SqlClient, OperationResult } from "../introspect/types.js";
|
|
3
|
+
import { OperationError, ErrorCode } from "../introspect/types.js";
|
|
4
|
+
import { rejectDangerousSQL } from "../introspect/sql-safety.js";
|
|
5
|
+
import { buildInsertQuery, assertTableExists, getTableColumns } from "../introspect/db-helpers.js";
|
|
6
|
+
import { formatTable } from "./format.js";
|
|
7
|
+
import { validateTableName, validateColumnNames, rejectControlChars } from "../introspect/sql-safety.js";
|
|
8
|
+
|
|
9
|
+
export const rowsInsertMasterDetailInput = z.object({
|
|
10
|
+
masterTable: z.string().describe("Master table name"),
|
|
11
|
+
masterData: z.string().describe("JSON object for master record"),
|
|
12
|
+
detailTable: z.string().describe("Detail table name"),
|
|
13
|
+
detailData: z.string().describe("JSON array of detail records"),
|
|
14
|
+
detailFk: z.string().describe("FK column name on detail table (e.g. 'order_id')"),
|
|
15
|
+
dryRun: z.boolean().optional().describe("Validate without executing"),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Insert a master record and its detail records atomically.
|
|
20
|
+
* The master record's ID is backfilled into each detail record's FK column.
|
|
21
|
+
*
|
|
22
|
+
* Flags:
|
|
23
|
+
* --master-table Master table name
|
|
24
|
+
* --master-data JSON object for master record
|
|
25
|
+
* --detail-table Detail table name
|
|
26
|
+
* --detail-data JSON array of detail records (without FK column)
|
|
27
|
+
* --detail-fk FK column name on detail table (e.g. "order_id")
|
|
28
|
+
* --dry-run Validate without executing
|
|
29
|
+
*
|
|
30
|
+
* Returns detail rows as primary data, with the master row in meta.
|
|
31
|
+
* Both master and detail inserts happen inside a single transaction.
|
|
32
|
+
*/
|
|
33
|
+
export async function rowsInsertMasterDetail(
|
|
34
|
+
sql: SqlClient,
|
|
35
|
+
flags: Record<string, string>,
|
|
36
|
+
): Promise<OperationResult> {
|
|
37
|
+
const masterTable = flags["master-table"];
|
|
38
|
+
const masterDataJson = flags["master-data"];
|
|
39
|
+
const detailTable = flags["detail-table"];
|
|
40
|
+
const detailDataJson = flags["detail-data"];
|
|
41
|
+
const detailFk = flags["detail-fk"];
|
|
42
|
+
const dryRun = flags["dry-run"] === "true";
|
|
43
|
+
|
|
44
|
+
if (!masterTable || !masterDataJson || !detailTable || !detailDataJson || !detailFk) {
|
|
45
|
+
throw new OperationError(
|
|
46
|
+
"Usage: sapporta rows insert-master-detail " +
|
|
47
|
+
"--master-table T --master-data '{}' " +
|
|
48
|
+
"--detail-table T --detail-data '[{}]' " +
|
|
49
|
+
"--detail-fk column_name",
|
|
50
|
+
ErrorCode.MISSING_ARGUMENT,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Validate table names and FK column using shared identifier checks.
|
|
55
|
+
// validateTableName/validateColumnNames throw OperationError on invalid input.
|
|
56
|
+
validateTableName(masterTable);
|
|
57
|
+
validateTableName(detailTable);
|
|
58
|
+
validateColumnNames([detailFk]);
|
|
59
|
+
|
|
60
|
+
// Reject control characters before parsing -- agents sometimes produce
|
|
61
|
+
// invisible chars that would silently corrupt data in the database.
|
|
62
|
+
rejectControlChars(masterDataJson);
|
|
63
|
+
rejectControlChars(detailDataJson);
|
|
64
|
+
|
|
65
|
+
const masterData = JSON.parse(masterDataJson);
|
|
66
|
+
const detailRows = JSON.parse(detailDataJson);
|
|
67
|
+
|
|
68
|
+
if (!Array.isArray(detailRows)) {
|
|
69
|
+
throw new OperationError("--detail-data must be a JSON array", ErrorCode.INVALID_JSON);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Validate all column names upfront
|
|
73
|
+
validateColumnNames(Object.keys(masterData));
|
|
74
|
+
for (const detail of detailRows) {
|
|
75
|
+
validateColumnNames(Object.keys(detail));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (dryRun) {
|
|
79
|
+
return dryRunValidation(sql, masterTable, masterData, detailTable, detailRows, detailFk);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// All inserts happen in a single transaction.
|
|
83
|
+
// The master row's id is backfilled into each detail row's FK column.
|
|
84
|
+
const { masterRow, detailResults } = await sql.begin(async (tx) => {
|
|
85
|
+
// 1. Insert master record
|
|
86
|
+
const { query: masterQuery, values: masterValues } = buildInsertQuery(masterTable, masterData);
|
|
87
|
+
rejectDangerousSQL(masterQuery);
|
|
88
|
+
const [masterRow] = await tx.unsafe(masterQuery, masterValues);
|
|
89
|
+
const masterId = masterRow.id;
|
|
90
|
+
|
|
91
|
+
// 2. Insert detail records with FK backfill
|
|
92
|
+
const detailResults: any[] = [];
|
|
93
|
+
for (const detail of detailRows) {
|
|
94
|
+
const row = { ...detail, [detailFk]: masterId };
|
|
95
|
+
const { query, values } = buildInsertQuery(detailTable, row);
|
|
96
|
+
rejectDangerousSQL(query);
|
|
97
|
+
const inserted = await tx.unsafe(query, values);
|
|
98
|
+
detailResults.push(...inserted);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { masterRow, detailResults };
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Build table-mode output that matches the original two-section format
|
|
105
|
+
const additionalOutput =
|
|
106
|
+
`\nInserted ${detailResults.length} detail row(s) into ${detailTable}:\n` +
|
|
107
|
+
formatTable(detailResults);
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
ok: true,
|
|
111
|
+
data: [masterRow],
|
|
112
|
+
meta: {
|
|
113
|
+
message: `Inserted master row into ${masterTable} (id: ${masterRow.id}):`,
|
|
114
|
+
masterTable,
|
|
115
|
+
detailTable,
|
|
116
|
+
detailRows: detailResults,
|
|
117
|
+
detailCount: detailResults.length,
|
|
118
|
+
additionalOutput,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Dry-run validation for master-detail insert.
|
|
125
|
+
* Checks that both tables exist and all columns in the payloads are valid.
|
|
126
|
+
* Does NOT execute any inserts.
|
|
127
|
+
*
|
|
128
|
+
* Delegates to shared primitives (assertTableExists, getTableColumns)
|
|
129
|
+
* from cli-utils.ts. The FK column is validated separately since it's not
|
|
130
|
+
* in the detail payload but must exist in the detail table.
|
|
131
|
+
*/
|
|
132
|
+
async function dryRunValidation(
|
|
133
|
+
sql: SqlClient,
|
|
134
|
+
masterTable: string,
|
|
135
|
+
masterData: Record<string, unknown>,
|
|
136
|
+
detailTable: string,
|
|
137
|
+
detailRows: Record<string, unknown>[],
|
|
138
|
+
detailFk: string,
|
|
139
|
+
): Promise<OperationResult> {
|
|
140
|
+
// Validate both tables exist
|
|
141
|
+
await assertTableExists(sql as any, masterTable);
|
|
142
|
+
await assertTableExists(sql as any, detailTable);
|
|
143
|
+
|
|
144
|
+
// Validate master columns against actual schema
|
|
145
|
+
const masterDbCols = await getTableColumns(sql as any, masterTable);
|
|
146
|
+
const unknownMasterCols = Object.keys(masterData).filter((c) => !masterDbCols.has(c));
|
|
147
|
+
if (unknownMasterCols.length > 0) {
|
|
148
|
+
throw new OperationError(
|
|
149
|
+
`Unknown column(s) in '${masterTable}': ${unknownMasterCols.join(", ")}`,
|
|
150
|
+
ErrorCode.INVALID_COLUMN_NAME,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Validate detail columns (including FK column which isn't in the payload
|
|
155
|
+
// but must exist in the table for the backfill to work)
|
|
156
|
+
const detailDbCols = await getTableColumns(sql as any, detailTable);
|
|
157
|
+
if (!detailDbCols.has(detailFk)) {
|
|
158
|
+
throw new OperationError(
|
|
159
|
+
`FK column '${detailFk}' not found in '${detailTable}'`,
|
|
160
|
+
ErrorCode.INVALID_COLUMN_NAME,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const allDetailPayloadCols = [...new Set(detailRows.flatMap((r) => Object.keys(r)))];
|
|
164
|
+
const unknownDetailCols = allDetailPayloadCols.filter((c) => !detailDbCols.has(c));
|
|
165
|
+
if (unknownDetailCols.length > 0) {
|
|
166
|
+
throw new OperationError(
|
|
167
|
+
`Unknown column(s) in '${detailTable}': ${unknownDetailCols.join(", ")}`,
|
|
168
|
+
ErrorCode.INVALID_COLUMN_NAME,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
ok: true,
|
|
174
|
+
data: [masterData as Record<string, unknown>],
|
|
175
|
+
meta: {
|
|
176
|
+
message: `Dry run: 1 master row + ${detailRows.length} detail row(s) would be inserted`,
|
|
177
|
+
dryRun: true,
|
|
178
|
+
masterTable,
|
|
179
|
+
detailTable,
|
|
180
|
+
detailRows,
|
|
181
|
+
detailCount: detailRows.length,
|
|
182
|
+
validationPassed: true,
|
|
183
|
+
tableOutputHandled: true,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { rowsInsert } from "./rows-insert.js";
|
|
4
|
+
import type { SqlClient } from "../introspect/types.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create a SqlClient adapter from a better-sqlite3 Database.
|
|
8
|
+
*
|
|
9
|
+
* The returned object is both a SqlClient (for rowsInsert's type signature)
|
|
10
|
+
* and a Database.Database proxy (for the `sql as any` casts inside
|
|
11
|
+
* rowsInsert that pass it to synchronous db-helpers functions like
|
|
12
|
+
* validatePayloadColumns, assertTableExists, etc.).
|
|
13
|
+
*
|
|
14
|
+
* We achieve this by adding SqlClient methods directly onto the sqlite handle.
|
|
15
|
+
*/
|
|
16
|
+
function createTestSql(sqlite: Database.Database): SqlClient & Database.Database {
|
|
17
|
+
const extended = sqlite as any;
|
|
18
|
+
extended.unsafe = (query: string, params?: any[]) => {
|
|
19
|
+
return Promise.resolve(sqlite.prepare(query).all(...(params ?? [])));
|
|
20
|
+
};
|
|
21
|
+
extended.begin = async () => {
|
|
22
|
+
throw new Error("Not implemented");
|
|
23
|
+
};
|
|
24
|
+
extended.end = async () => {};
|
|
25
|
+
return extended;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("rows insert", () => {
|
|
29
|
+
let sqlite: Database.Database;
|
|
30
|
+
let sql: SqlClient;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
sqlite = new Database(":memory:");
|
|
34
|
+
sqlite.pragma("foreign_keys = ON");
|
|
35
|
+
sql = createTestSql(sqlite);
|
|
36
|
+
sqlite.exec(`
|
|
37
|
+
CREATE TABLE accounts (
|
|
38
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
39
|
+
name TEXT NOT NULL,
|
|
40
|
+
type TEXT NOT NULL
|
|
41
|
+
)
|
|
42
|
+
`);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
sqlite.close();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("inserts a single row", async () => {
|
|
50
|
+
const result = await rowsInsert(sql, "accounts", '{"name":"Cash","type":"asset"}');
|
|
51
|
+
|
|
52
|
+
expect(result.ok).toBe(true);
|
|
53
|
+
if (!result.ok) return;
|
|
54
|
+
expect(result.data).toHaveLength(1);
|
|
55
|
+
expect(result.data[0].name).toBe("Cash");
|
|
56
|
+
expect(result.data[0].type).toBe("asset");
|
|
57
|
+
|
|
58
|
+
// Verify in DB
|
|
59
|
+
const rows = sqlite.prepare("SELECT * FROM accounts").all();
|
|
60
|
+
expect(rows).toHaveLength(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("inserts multiple rows from array", async () => {
|
|
64
|
+
const result = await rowsInsert(
|
|
65
|
+
sql,
|
|
66
|
+
"accounts",
|
|
67
|
+
'[{"name":"Cash","type":"asset"},{"name":"Revenue","type":"revenue"}]',
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(result.ok).toBe(true);
|
|
71
|
+
if (!result.ok) return;
|
|
72
|
+
expect(result.data).toHaveLength(2);
|
|
73
|
+
|
|
74
|
+
const rows = sqlite.prepare("SELECT * FROM accounts").all();
|
|
75
|
+
expect(rows).toHaveLength(2);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns inserted row data", async () => {
|
|
79
|
+
const result = await rowsInsert(sql, "accounts", '{"name":"Cash","type":"asset"}');
|
|
80
|
+
|
|
81
|
+
expect(result.ok).toBe(true);
|
|
82
|
+
if (!result.ok) return;
|
|
83
|
+
expect(result.meta?.message).toContain("Inserted 1 row(s)");
|
|
84
|
+
expect(result.data[0].name).toBe("Cash");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("rejects invalid table names", async () => {
|
|
88
|
+
await expect(
|
|
89
|
+
rowsInsert(sql, "'; DROP TABLE accounts; --", '{"name":"x"}'),
|
|
90
|
+
).rejects.toThrow("Invalid table name");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("rejects invalid JSON", async () => {
|
|
94
|
+
await expect(
|
|
95
|
+
rowsInsert(sql, "accounts", "not json"),
|
|
96
|
+
).rejects.toThrow();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("rejects column names with injection characters", async () => {
|
|
100
|
+
await expect(
|
|
101
|
+
rowsInsert(sql, "accounts", '{"name; DROP TABLE accounts":"Cash","type":"asset"}'),
|
|
102
|
+
).rejects.toThrow("Invalid column name");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("rejects data containing control characters", async () => {
|
|
106
|
+
await expect(
|
|
107
|
+
rowsInsert(sql, "accounts", '{"name":"Cash\x00","type":"asset"}'),
|
|
108
|
+
).rejects.toThrow("control characters");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// -- Dry-run tests --
|
|
112
|
+
|
|
113
|
+
it("dry-run validates successfully without inserting", async () => {
|
|
114
|
+
const result = await rowsInsert(sql, "accounts", '{"name":"Cash","type":"asset"}', true);
|
|
115
|
+
|
|
116
|
+
expect(result.ok).toBe(true);
|
|
117
|
+
if (!result.ok) return;
|
|
118
|
+
expect(result.meta?.dryRun).toBe(true);
|
|
119
|
+
expect(result.meta?.validationPassed).toBe(true);
|
|
120
|
+
|
|
121
|
+
// Verify nothing was actually inserted
|
|
122
|
+
const rows = sqlite.prepare("SELECT * FROM accounts").all();
|
|
123
|
+
expect(rows).toHaveLength(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("dry-run catches nonexistent table", async () => {
|
|
127
|
+
await expect(
|
|
128
|
+
rowsInsert(sql, "nonexistent", '{"name":"Cash"}', true),
|
|
129
|
+
).rejects.toThrow("not found");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("dry-run catches unknown columns", async () => {
|
|
133
|
+
await expect(
|
|
134
|
+
rowsInsert(sql, "accounts", '{"name":"Cash","bogus_column":"x"}', true),
|
|
135
|
+
).rejects.toThrow("Unknown column");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { SqlClient, OperationResult } from "../introspect/types.js";
|
|
3
|
+
import { rejectDangerousSQL } from "../introspect/sql-safety.js";
|
|
4
|
+
import { buildInsertQuery, validatePayloadColumns } from "../introspect/db-helpers.js";
|
|
5
|
+
import { validateTableName, validateColumnNames, rejectControlChars } from "../introspect/sql-safety.js";
|
|
6
|
+
|
|
7
|
+
export const rowsInsertInput = z.object({
|
|
8
|
+
table: z.string().describe("Target table name"),
|
|
9
|
+
data: z.string().describe("JSON string: single object or array of objects"),
|
|
10
|
+
dryRun: z.boolean().optional().describe("Validate without executing"),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Insert one or more rows into a table.
|
|
15
|
+
* Data is provided as a JSON string (single object or array of objects).
|
|
16
|
+
* Returns the inserted rows (via RETURNING *).
|
|
17
|
+
*
|
|
18
|
+
* When dryRun is true, validates everything (table exists, columns exist,
|
|
19
|
+
* types are compatible) without executing the insert.
|
|
20
|
+
*/
|
|
21
|
+
export async function rowsInsert(
|
|
22
|
+
sql: SqlClient,
|
|
23
|
+
tableName: string,
|
|
24
|
+
dataJson: string,
|
|
25
|
+
dryRun: boolean = false,
|
|
26
|
+
): Promise<OperationResult> {
|
|
27
|
+
// Validate table name (delegates to shared identifier check in cli-utils)
|
|
28
|
+
validateTableName(tableName);
|
|
29
|
+
|
|
30
|
+
// Reject control characters before parsing -- agents sometimes produce
|
|
31
|
+
// invisible chars that would silently corrupt data in the database.
|
|
32
|
+
rejectControlChars(dataJson);
|
|
33
|
+
|
|
34
|
+
const data = JSON.parse(dataJson);
|
|
35
|
+
const rows = Array.isArray(data) ? data : [data];
|
|
36
|
+
|
|
37
|
+
if (rows.length === 0) {
|
|
38
|
+
return { ok: true, data: [], meta: { message: "No data to insert." } };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Validate all column names upfront
|
|
42
|
+
for (const row of rows) {
|
|
43
|
+
validateColumnNames(Object.keys(row));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (dryRun) {
|
|
47
|
+
return dryRunValidation(sql, tableName, rows);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const results: any[] = [];
|
|
51
|
+
for (const row of rows) {
|
|
52
|
+
const { query, values } = buildInsertQuery(tableName, row);
|
|
53
|
+
rejectDangerousSQL(query);
|
|
54
|
+
const inserted = await sql.unsafe(query, values);
|
|
55
|
+
results.push(...inserted);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
ok: true,
|
|
60
|
+
data: results,
|
|
61
|
+
meta: {
|
|
62
|
+
message: `Inserted ${results.length} row(s) into ${tableName}:`,
|
|
63
|
+
rowCount: results.length,
|
|
64
|
+
tableName,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Dry-run validation: checks that the table exists and all columns
|
|
71
|
+
* in the payload exist in the target table. Does NOT execute the insert.
|
|
72
|
+
*
|
|
73
|
+
* Delegates to shared primitives (assertTableExists, getTableColumns)
|
|
74
|
+
* via validatePayloadColumns in cli-utils.ts.
|
|
75
|
+
*/
|
|
76
|
+
async function dryRunValidation(
|
|
77
|
+
sql: SqlClient,
|
|
78
|
+
tableName: string,
|
|
79
|
+
rows: Record<string, unknown>[],
|
|
80
|
+
): Promise<OperationResult> {
|
|
81
|
+
// Collect all unique column names across all rows, then validate them
|
|
82
|
+
// against the actual table schema in one call.
|
|
83
|
+
const allPayloadColumns = [...new Set(rows.flatMap((r) => Object.keys(r)))];
|
|
84
|
+
await validatePayloadColumns(sql as any, tableName, allPayloadColumns);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
data: rows as Record<string, unknown>[],
|
|
89
|
+
meta: {
|
|
90
|
+
message: `Dry run: ${rows.length} row(s) would be inserted into ${tableName}`,
|
|
91
|
+
dryRun: true,
|
|
92
|
+
tableName,
|
|
93
|
+
rowCount: rows.length,
|
|
94
|
+
validationPassed: true,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { serve } from "@hono/node-server";
|
|
2
|
+
import { bootProject } from "../boot.js";
|
|
3
|
+
import { createApp } from "../api/server.js";
|
|
4
|
+
import { connectProject } from "../db/sqlite-connection.js";
|
|
5
|
+
import { parseFlags } from "./format.js";
|
|
6
|
+
import { logger } from "../db/logger.js";
|
|
7
|
+
|
|
8
|
+
const log = logger.child({ module: "serve" });
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Single-project server.
|
|
12
|
+
*
|
|
13
|
+
* Boots one project from a directory layout:
|
|
14
|
+
* projectDir/data/sqlite.db
|
|
15
|
+
* projectDir/code/src/{schema,actions,reports,views}/
|
|
16
|
+
*
|
|
17
|
+
* Routes are mounted at the root (no /p/:slug prefix).
|
|
18
|
+
*/
|
|
19
|
+
export async function serveSingle(args: string[]): Promise<void> {
|
|
20
|
+
const flags = parseFlags(args);
|
|
21
|
+
const port = flags.port ? parseInt(flags.port, 10) : undefined;
|
|
22
|
+
|
|
23
|
+
const projectDir = flags["sapporta-project-dir"] ?? process.env.SAPPORTA_PROJECT_DIR;
|
|
24
|
+
if (!projectDir) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
"SAPPORTA_PROJECT_DIR is required — set it to the project root directory",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const projectId = flags["project-id"] ?? process.env.PROJECT_ID ?? projectDir.split("/").pop()!;
|
|
31
|
+
const dataDir = flags["data-dir"] ?? process.env.SAPPORTA_DATA_DIR ?? `${projectDir}/data`;
|
|
32
|
+
const codeDir = flags["code-dir"] ?? process.env.SAPPORTA_CODE_DIR ?? `${projectDir}/code`;
|
|
33
|
+
const srcDir = `${codeDir}/src`;
|
|
34
|
+
const databasePath = flags["database-path"] ?? `${dataDir}/sqlite.db`;
|
|
35
|
+
|
|
36
|
+
const conn = connectProject(databasePath);
|
|
37
|
+
const projectCtx = await bootProject(
|
|
38
|
+
{ slug: projectId, databasePath, dir: srcDir },
|
|
39
|
+
conn,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const app = createApp();
|
|
43
|
+
app.route("/", projectCtx.subApp);
|
|
44
|
+
|
|
45
|
+
const finalPort = port ?? parseInt(process.env.PORT ?? "3000", 10);
|
|
46
|
+
serve({ fetch: app.fetch, port: finalPort }, (info: { port: number }) => {
|
|
47
|
+
log.info(`Sapporta running at http://localhost:${info.port}`, { port: info.port, project: projectId });
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
|
|
6
|
+
export interface CreateProjectOptions {
|
|
7
|
+
/** Absolute path to the directory where package.json will be created. */
|
|
8
|
+
dir: string;
|
|
9
|
+
/** Project name for package.json. Defaults to the directory name. */
|
|
10
|
+
name?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CreateProjectResult {
|
|
14
|
+
dir: string;
|
|
15
|
+
name: string;
|
|
16
|
+
packageJson: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a Sapporta code project: writes package.json and installs dependencies.
|
|
21
|
+
*
|
|
22
|
+
* This is the SDK function — no CLI arg parsing, no OperationResult wrapping.
|
|
23
|
+
* The CLI and control-plane API both call this.
|
|
24
|
+
*
|
|
25
|
+
* Throws if package.json already exists in the target directory.
|
|
26
|
+
*/
|
|
27
|
+
export function createProject(opts: CreateProjectOptions): CreateProjectResult {
|
|
28
|
+
const { dir } = opts;
|
|
29
|
+
const projectName = opts.name ?? dir.split("/").pop() ?? "sapporta-project";
|
|
30
|
+
|
|
31
|
+
const pkgPath = join(dir, "package.json");
|
|
32
|
+
if (existsSync(pkgPath)) {
|
|
33
|
+
throw new Error(`package.json already exists in ${dir}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Resolve @sapporta/server dependency specifier:
|
|
37
|
+
// - SAPPORTA_ROOT set (dev/source tree) → file: link
|
|
38
|
+
// - SAPPORTA_ROOT absent (published CLI) → use version from own package.json
|
|
39
|
+
const sapportaRoot = process.env.SAPPORTA_ROOT;
|
|
40
|
+
|
|
41
|
+
let coreSpec: string;
|
|
42
|
+
let corePkg: { dependencies: Record<string, string>; version?: string };
|
|
43
|
+
|
|
44
|
+
if (sapportaRoot) {
|
|
45
|
+
const corePkgPath = join(sapportaRoot, "packages", "core", "package.json");
|
|
46
|
+
corePkg = JSON.parse(readFileSync(corePkgPath, "utf-8"));
|
|
47
|
+
coreSpec = `file://${join(sapportaRoot, "packages", "core")}`;
|
|
48
|
+
} else {
|
|
49
|
+
const _require = createRequire(import.meta.url);
|
|
50
|
+
const corePkgPath = _require.resolve("@sapporta/server/package.json");
|
|
51
|
+
corePkg = JSON.parse(readFileSync(corePkgPath, "utf-8"));
|
|
52
|
+
coreSpec = `^${corePkg.version}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const drizzleVersion = corePkg.dependencies["drizzle-orm"];
|
|
56
|
+
const zodVersion = corePkg.dependencies["zod"];
|
|
57
|
+
|
|
58
|
+
const pkg = {
|
|
59
|
+
name: projectName,
|
|
60
|
+
private: true,
|
|
61
|
+
type: "module",
|
|
62
|
+
dependencies: {
|
|
63
|
+
"@sapporta/server": coreSpec,
|
|
64
|
+
"drizzle-orm": drizzleVersion,
|
|
65
|
+
"zod": zodVersion,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
70
|
+
|
|
71
|
+
// Prefer pnpm, fall back to npm
|
|
72
|
+
let pm = "npm";
|
|
73
|
+
try {
|
|
74
|
+
execSync("pnpm --version", { stdio: "ignore" });
|
|
75
|
+
pm = "pnpm";
|
|
76
|
+
} catch {}
|
|
77
|
+
|
|
78
|
+
execSync(`${pm} install`, { cwd: dir, stdio: "inherit" });
|
|
79
|
+
|
|
80
|
+
return { dir, name: projectName, packageJson: pkg };
|
|
81
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
import { sql, inArray } from "drizzle-orm";
|
|
4
|
+
import { getTableConfig } from "drizzle-orm/sqlite-core";
|
|
5
|
+
import type { TableDef } from "../schema/table.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Handle a count request: GET /?group_by=fk_col&ids=1,2,3
|
|
9
|
+
* Returns grouped counts: { data: { "1": 3, "2": 5 } }
|
|
10
|
+
* Used by the UI to show child record counts in parent grids.
|
|
11
|
+
*/
|
|
12
|
+
export async function handleCount(schema: TableDef, db: any, c: Context): Promise<Response> {
|
|
13
|
+
const groupBy = c.req.query("group_by");
|
|
14
|
+
const idsParam = c.req.query("ids");
|
|
15
|
+
|
|
16
|
+
if (!groupBy || !idsParam) {
|
|
17
|
+
return c.json({ data: {} });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Validate that the groupBy column exists
|
|
21
|
+
const config = getTableConfig(schema.drizzle);
|
|
22
|
+
const col = config.columns.find((col) => col.name === groupBy);
|
|
23
|
+
if (!col) {
|
|
24
|
+
return c.json({ error: `Column "${groupBy}" not found` }, 400);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ids = idsParam
|
|
28
|
+
.split(",")
|
|
29
|
+
.map((s) => s.trim())
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.map(Number)
|
|
32
|
+
.filter((n) => !isNaN(n));
|
|
33
|
+
|
|
34
|
+
if (ids.length === 0) {
|
|
35
|
+
return c.json({ data: {} });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const drizzleCol = (schema.drizzle as any)[groupBy];
|
|
39
|
+
|
|
40
|
+
const rows = await db
|
|
41
|
+
.select({
|
|
42
|
+
groupKey: drizzleCol,
|
|
43
|
+
count: sql<number>`count(*)`,
|
|
44
|
+
})
|
|
45
|
+
.from(schema.drizzle)
|
|
46
|
+
.where(inArray(drizzleCol, ids))
|
|
47
|
+
.groupBy(drizzleCol);
|
|
48
|
+
|
|
49
|
+
const result: Record<string, number> = {};
|
|
50
|
+
for (const row of rows) {
|
|
51
|
+
result[String(row.groupKey)] = row.count;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return c.json({ data: result });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Create a count sub-app (convenience wrapper for tests). */
|
|
58
|
+
export function countEndpoint(schema: TableDef, db: any) {
|
|
59
|
+
const app = new Hono();
|
|
60
|
+
app.get("/", (c) => handleCount(schema, db, c));
|
|
61
|
+
return app;
|
|
62
|
+
}
|