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