stagent 0.6.2 → 0.7.0

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 (176) hide show
  1. package/README.md +21 -2
  2. package/dist/cli.js +272 -1
  3. package/docs/.coverage-gaps.json +66 -16
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/dashboard-kanban.md +13 -7
  6. package/docs/features/settings.md +15 -3
  7. package/docs/features/tables.md +122 -0
  8. package/docs/index.md +3 -2
  9. package/docs/journeys/developer.md +26 -16
  10. package/docs/journeys/personal-use.md +23 -9
  11. package/docs/journeys/power-user.md +40 -14
  12. package/docs/journeys/work-use.md +43 -15
  13. package/docs/manifest.json +27 -17
  14. package/package.json +3 -2
  15. package/src/app/api/chat/entities/search/route.ts +12 -3
  16. package/src/app/api/documents/[id]/route.ts +5 -1
  17. package/src/app/api/documents/[id]/versions/route.ts +53 -0
  18. package/src/app/api/documents/route.ts +5 -1
  19. package/src/app/api/projects/[id]/documents/route.ts +124 -0
  20. package/src/app/api/projects/[id]/route.ts +72 -3
  21. package/src/app/api/projects/__tests__/delete-project.test.ts +13 -0
  22. package/src/app/api/schedules/route.ts +19 -1
  23. package/src/app/api/snapshots/[id]/restore/route.ts +62 -0
  24. package/src/app/api/snapshots/[id]/route.ts +44 -0
  25. package/src/app/api/snapshots/route.ts +54 -0
  26. package/src/app/api/snapshots/settings/route.ts +67 -0
  27. package/src/app/api/tables/[id]/charts/[chartId]/route.ts +89 -0
  28. package/src/app/api/tables/[id]/charts/route.ts +72 -0
  29. package/src/app/api/tables/[id]/columns/route.ts +70 -0
  30. package/src/app/api/tables/[id]/export/route.ts +94 -0
  31. package/src/app/api/tables/[id]/history/route.ts +15 -0
  32. package/src/app/api/tables/[id]/import/route.ts +111 -0
  33. package/src/app/api/tables/[id]/route.ts +86 -0
  34. package/src/app/api/tables/[id]/rows/[rowId]/history/route.ts +32 -0
  35. package/src/app/api/tables/[id]/rows/[rowId]/route.ts +51 -0
  36. package/src/app/api/tables/[id]/rows/route.ts +101 -0
  37. package/src/app/api/tables/[id]/triggers/[triggerId]/route.ts +65 -0
  38. package/src/app/api/tables/[id]/triggers/route.ts +122 -0
  39. package/src/app/api/tables/route.ts +65 -0
  40. package/src/app/api/tables/templates/route.ts +92 -0
  41. package/src/app/api/tasks/[id]/route.ts +37 -2
  42. package/src/app/api/tasks/[id]/siblings/route.ts +48 -0
  43. package/src/app/api/tasks/route.ts +8 -9
  44. package/src/app/api/workflows/[id]/documents/route.ts +209 -0
  45. package/src/app/api/workflows/[id]/execute/route.ts +6 -2
  46. package/src/app/api/workflows/[id]/route.ts +16 -3
  47. package/src/app/api/workflows/[id]/status/route.ts +18 -2
  48. package/src/app/api/workflows/route.ts +13 -2
  49. package/src/app/documents/page.tsx +5 -1
  50. package/src/app/layout.tsx +0 -1
  51. package/src/app/manifest.ts +3 -3
  52. package/src/app/projects/[id]/page.tsx +62 -2
  53. package/src/app/settings/page.tsx +2 -0
  54. package/src/app/tables/[id]/page.tsx +67 -0
  55. package/src/app/tables/page.tsx +21 -0
  56. package/src/app/tables/templates/page.tsx +19 -0
  57. package/src/components/chat/chat-table-result.tsx +139 -0
  58. package/src/components/documents/document-browser.tsx +1 -1
  59. package/src/components/documents/document-chip-bar.tsx +17 -1
  60. package/src/components/documents/document-detail-view.tsx +51 -0
  61. package/src/components/documents/document-grid.tsx +5 -0
  62. package/src/components/documents/document-table.tsx +4 -0
  63. package/src/components/documents/types.ts +3 -0
  64. package/src/components/projects/project-form-sheet.tsx +109 -2
  65. package/src/components/schedules/schedule-form.tsx +91 -1
  66. package/src/components/settings/data-management-section.tsx +17 -12
  67. package/src/components/settings/database-snapshots-section.tsx +469 -0
  68. package/src/components/shared/app-sidebar.tsx +2 -0
  69. package/src/components/shared/document-picker-sheet.tsx +486 -0
  70. package/src/components/tables/table-browser.tsx +234 -0
  71. package/src/components/tables/table-cell-editor.tsx +226 -0
  72. package/src/components/tables/table-chart-builder.tsx +288 -0
  73. package/src/components/tables/table-chart-view.tsx +146 -0
  74. package/src/components/tables/table-column-header.tsx +103 -0
  75. package/src/components/tables/table-column-sheet.tsx +331 -0
  76. package/src/components/tables/table-create-sheet.tsx +240 -0
  77. package/src/components/tables/table-detail-sheet.tsx +144 -0
  78. package/src/components/tables/table-detail-tabs.tsx +278 -0
  79. package/src/components/tables/table-grid.tsx +61 -0
  80. package/src/components/tables/table-history-tab.tsx +148 -0
  81. package/src/components/tables/table-import-wizard.tsx +542 -0
  82. package/src/components/tables/table-list-table.tsx +95 -0
  83. package/src/components/tables/table-relation-combobox.tsx +217 -0
  84. package/src/components/tables/table-spreadsheet.tsx +499 -0
  85. package/src/components/tables/table-template-gallery.tsx +162 -0
  86. package/src/components/tables/table-template-preview.tsx +219 -0
  87. package/src/components/tables/table-toolbar.tsx +79 -0
  88. package/src/components/tables/table-triggers-tab.tsx +446 -0
  89. package/src/components/tables/types.ts +6 -0
  90. package/src/components/tables/use-spreadsheet-keys.ts +171 -0
  91. package/src/components/tables/utils.ts +29 -0
  92. package/src/components/tasks/task-card.tsx +8 -1
  93. package/src/components/tasks/task-create-panel.tsx +111 -14
  94. package/src/components/tasks/task-detail-view.tsx +47 -0
  95. package/src/components/tasks/task-edit-dialog.tsx +103 -2
  96. package/src/components/workflows/workflow-form-view.tsx +207 -7
  97. package/src/components/workflows/workflow-kanban-card.tsx +8 -1
  98. package/src/components/workflows/workflow-list.tsx +90 -45
  99. package/src/components/workflows/workflow-status-view.tsx +168 -23
  100. package/src/instrumentation.ts +3 -0
  101. package/src/lib/__tests__/npx-process-cwd.test.ts +17 -2
  102. package/src/lib/agents/__tests__/claude-agent.test.ts +5 -1
  103. package/src/lib/agents/claude-agent.ts +3 -1
  104. package/src/lib/agents/profiles/registry.ts +6 -3
  105. package/src/lib/agents/runtime/anthropic-direct.ts +29 -0
  106. package/src/lib/agents/runtime/openai-direct.ts +29 -0
  107. package/src/lib/book/__tests__/chapter-slugs.test.ts +80 -0
  108. package/src/lib/book/chapter-generator.ts +4 -19
  109. package/src/lib/book/chapter-mapping.ts +17 -0
  110. package/src/lib/book/content.ts +5 -16
  111. package/src/lib/book/update-detector.ts +3 -16
  112. package/src/lib/chat/engine.ts +1 -0
  113. package/src/lib/chat/stagent-tools.ts +2 -0
  114. package/src/lib/chat/system-prompt.ts +9 -1
  115. package/src/lib/chat/tool-catalog.ts +35 -0
  116. package/src/lib/chat/tools/settings-tools.ts +109 -0
  117. package/src/lib/chat/tools/table-tools.ts +955 -0
  118. package/src/lib/chat/tools/workflow-tools.ts +145 -2
  119. package/src/lib/constants/table-status.ts +68 -0
  120. package/src/lib/data/__tests__/clear.test.ts +1 -1
  121. package/src/lib/data/clear.ts +57 -0
  122. package/src/lib/data/seed-data/__tests__/profiles.test.ts +28 -23
  123. package/src/lib/data/seed-data/conversations.ts +350 -42
  124. package/src/lib/data/seed-data/documents.ts +564 -591
  125. package/src/lib/data/seed-data/learned-context.ts +101 -22
  126. package/src/lib/data/seed-data/notifications.ts +344 -70
  127. package/src/lib/data/seed-data/profile-test-results.ts +92 -11
  128. package/src/lib/data/seed-data/profiles.ts +144 -46
  129. package/src/lib/data/seed-data/projects.ts +50 -18
  130. package/src/lib/data/seed-data/repo-imports.ts +28 -13
  131. package/src/lib/data/seed-data/schedules.ts +208 -41
  132. package/src/lib/data/seed-data/table-templates.ts +234 -0
  133. package/src/lib/data/seed-data/tasks.ts +614 -116
  134. package/src/lib/data/seed-data/usage-ledger.ts +182 -103
  135. package/src/lib/data/seed-data/user-tables.ts +203 -0
  136. package/src/lib/data/seed-data/views.ts +52 -7
  137. package/src/lib/data/seed-data/workflows.ts +231 -84
  138. package/src/lib/data/seed.ts +55 -14
  139. package/src/lib/data/tables.ts +417 -0
  140. package/src/lib/db/bootstrap.ts +275 -0
  141. package/src/lib/db/index.ts +9 -0
  142. package/src/lib/db/migrations/0016_add_workflow_document_inputs.sql +13 -0
  143. package/src/lib/db/migrations/0017_add_document_picker_tables.sql +25 -0
  144. package/src/lib/db/migrations/0018_add_workflow_run_number.sql +2 -0
  145. package/src/lib/db/migrations/0019_add_tables_feature.sql +160 -0
  146. package/src/lib/db/migrations/0020_add_table_triggers.sql +19 -0
  147. package/src/lib/db/migrations/0021_add_row_history.sql +15 -0
  148. package/src/lib/db/schema.ts +445 -0
  149. package/src/lib/docs/reader.ts +2 -3
  150. package/src/lib/documents/context-builder.ts +75 -2
  151. package/src/lib/documents/document-resolver.ts +119 -0
  152. package/src/lib/documents/processors/spreadsheet.ts +2 -1
  153. package/src/lib/schedules/scheduler.ts +31 -1
  154. package/src/lib/snapshots/auto-backup.ts +132 -0
  155. package/src/lib/snapshots/retention.ts +64 -0
  156. package/src/lib/snapshots/snapshot-manager.ts +429 -0
  157. package/src/lib/tables/computed.ts +61 -0
  158. package/src/lib/tables/context-builder.ts +139 -0
  159. package/src/lib/tables/formula-engine.ts +415 -0
  160. package/src/lib/tables/history.ts +115 -0
  161. package/src/lib/tables/import.ts +343 -0
  162. package/src/lib/tables/query-builder.ts +152 -0
  163. package/src/lib/tables/trigger-evaluator.ts +146 -0
  164. package/src/lib/tables/types.ts +141 -0
  165. package/src/lib/tables/validation.ts +119 -0
  166. package/src/lib/utils/app-root.ts +20 -0
  167. package/src/lib/utils/stagent-paths.ts +20 -0
  168. package/src/lib/validators/__tests__/task.test.ts +43 -10
  169. package/src/lib/validators/task.ts +7 -1
  170. package/src/lib/workflows/blueprints/registry.ts +3 -3
  171. package/src/lib/workflows/engine.ts +24 -8
  172. package/src/lib/workflows/types.ts +14 -0
  173. package/tsconfig.json +3 -1
  174. package/public/icon.svg +0 -13
  175. package/src/components/tasks/file-upload.tsx +0 -120
  176. /package/docs/features/{playbook.md → user-guide.md} +0 -0
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Row version history — snapshots previous state before mutations.
3
+ */
4
+
5
+ import { randomUUID } from "crypto";
6
+ import { db } from "@/lib/db";
7
+ import { userTableRowHistory, userTableRows } from "@/lib/db/schema";
8
+ import { eq, desc } from "drizzle-orm";
9
+
10
+ /**
11
+ * Snapshot a row's current data before updating it.
12
+ * Called from row mutation code paths.
13
+ */
14
+ export function snapshotBeforeUpdate(
15
+ rowId: string,
16
+ tableId: string,
17
+ previousData: string,
18
+ changedBy: string = "user"
19
+ ): void {
20
+ db.insert(userTableRowHistory)
21
+ .values({
22
+ id: randomUUID(),
23
+ rowId,
24
+ tableId,
25
+ previousData,
26
+ changedBy,
27
+ changeType: "update",
28
+ createdAt: new Date(),
29
+ })
30
+ .run();
31
+ }
32
+
33
+ /**
34
+ * Snapshot a row's data before deleting it.
35
+ */
36
+ export function snapshotBeforeDelete(
37
+ rowId: string,
38
+ tableId: string,
39
+ previousData: string,
40
+ changedBy: string = "user"
41
+ ): void {
42
+ db.insert(userTableRowHistory)
43
+ .values({
44
+ id: randomUUID(),
45
+ rowId,
46
+ tableId,
47
+ previousData,
48
+ changedBy,
49
+ changeType: "delete",
50
+ createdAt: new Date(),
51
+ })
52
+ .run();
53
+ }
54
+
55
+ /**
56
+ * Get version history for a specific row.
57
+ */
58
+ export function getRowHistory(rowId: string, limit: number = 50) {
59
+ return db
60
+ .select()
61
+ .from(userTableRowHistory)
62
+ .where(eq(userTableRowHistory.rowId, rowId))
63
+ .orderBy(desc(userTableRowHistory.createdAt))
64
+ .limit(limit)
65
+ .all();
66
+ }
67
+
68
+ /**
69
+ * Get recent history for an entire table.
70
+ */
71
+ export function getTableHistory(tableId: string, limit: number = 100) {
72
+ return db
73
+ .select()
74
+ .from(userTableRowHistory)
75
+ .where(eq(userTableRowHistory.tableId, tableId))
76
+ .orderBy(desc(userTableRowHistory.createdAt))
77
+ .limit(limit)
78
+ .all();
79
+ }
80
+
81
+ /**
82
+ * Rollback a row to a previous version.
83
+ * Restores the previousData from a history entry.
84
+ */
85
+ export function rollbackRow(historyEntryId: string): boolean {
86
+ const entry = db
87
+ .select()
88
+ .from(userTableRowHistory)
89
+ .where(eq(userTableRowHistory.id, historyEntryId))
90
+ .get();
91
+
92
+ if (!entry) return false;
93
+
94
+ // Snapshot current state before rollback
95
+ const currentRow = db
96
+ .select()
97
+ .from(userTableRows)
98
+ .where(eq(userTableRows.id, entry.rowId))
99
+ .get();
100
+
101
+ if (currentRow) {
102
+ snapshotBeforeUpdate(entry.rowId, entry.tableId, currentRow.data, "rollback");
103
+ }
104
+
105
+ // Restore the previous data
106
+ db.update(userTableRows)
107
+ .set({
108
+ data: entry.previousData,
109
+ updatedAt: new Date(),
110
+ })
111
+ .where(eq(userTableRows.id, entry.rowId))
112
+ .run();
113
+
114
+ return true;
115
+ }
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Table import module — extract structured data from documents,
3
+ * infer column types, and batch-import rows.
4
+ */
5
+
6
+ import { readFile } from "fs/promises";
7
+ import { randomUUID } from "crypto";
8
+ import { db } from "@/lib/db";
9
+ import {
10
+ documents,
11
+ userTableImports,
12
+ tableDocumentInputs,
13
+ } from "@/lib/db/schema";
14
+ import { eq } from "drizzle-orm";
15
+ import { addRows } from "@/lib/data/tables";
16
+ import type { ColumnDataType } from "@/lib/constants/table-status";
17
+
18
+ // ── Type inference patterns ──────────────────────────────────────────
19
+
20
+ const EMAIL_RE = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
21
+ const URL_RE = /^https?:\/\/.+/;
22
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}/;
23
+ const US_DATE_RE = /^\d{1,2}\/\d{1,2}\/\d{2,4}$/;
24
+ const EU_DATE_RE = /^\d{1,2}\.\d{1,2}\.\d{2,4}$/;
25
+ const BOOLEAN_VALUES = new Set(["true", "false", "yes", "no", "1", "0"]);
26
+ const CURRENCY_RE = /^[$€£¥]?\s?[\d,]+\.?\d*$/;
27
+ const PERCENT_RE = /^[\d.]+%$/;
28
+
29
+ interface ExtractedData {
30
+ headers: string[];
31
+ rows: Record<string, string>[];
32
+ }
33
+
34
+ interface InferredColumn {
35
+ name: string;
36
+ displayName: string;
37
+ dataType: ColumnDataType;
38
+ config?: Record<string, unknown>;
39
+ }
40
+
41
+ interface ImportResult {
42
+ importId: string;
43
+ rowsImported: number;
44
+ rowsSkipped: number;
45
+ errors: Array<{ row: number; error: string }>;
46
+ }
47
+
48
+ // ── Extract structured data from a document ──────────────────────────
49
+
50
+ export async function extractStructuredData(
51
+ documentId: string
52
+ ): Promise<ExtractedData> {
53
+ const doc = db
54
+ .select()
55
+ .from(documents)
56
+ .where(eq(documents.id, documentId))
57
+ .get();
58
+
59
+ if (!doc) throw new Error("Document not found");
60
+
61
+ const mime = doc.mimeType;
62
+ const buffer = await readFile(doc.storagePath);
63
+
64
+ if (
65
+ mime === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
66
+ mime === "application/vnd.ms-excel"
67
+ ) {
68
+ return extractFromExcel(buffer);
69
+ }
70
+
71
+ if (mime === "text/csv" || mime === "text/tab-separated-values") {
72
+ const delimiter = mime === "text/tab-separated-values" ? "\t" : ",";
73
+ return extractFromCsv(buffer.toString("utf-8"), delimiter);
74
+ }
75
+
76
+ throw new Error(`Unsupported mime type for import: ${mime}`);
77
+ }
78
+
79
+ async function extractFromExcel(buffer: Buffer): Promise<ExtractedData> {
80
+ const ExcelJS = await import("exceljs");
81
+ const workbook = new ExcelJS.Workbook();
82
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
83
+ await workbook.xlsx.load(buffer as any);
84
+
85
+ const worksheet = workbook.worksheets[0];
86
+ if (!worksheet) throw new Error("No worksheets found");
87
+
88
+ const headers: string[] = [];
89
+ const rows: Record<string, string>[] = [];
90
+
91
+ worksheet.eachRow((row, rowNumber) => {
92
+ const values = (row.values as (string | number | null | undefined)[]).slice(1);
93
+
94
+ if (rowNumber === 1) {
95
+ // First row = headers
96
+ for (const v of values) {
97
+ headers.push(String(v ?? `col_${headers.length}`).trim());
98
+ }
99
+ } else {
100
+ const rowData: Record<string, string> = {};
101
+ values.forEach((v, i) => {
102
+ if (i < headers.length) {
103
+ rowData[headers[i]] = v == null ? "" : String(v);
104
+ }
105
+ });
106
+ rows.push(rowData);
107
+ }
108
+ });
109
+
110
+ return { headers, rows };
111
+ }
112
+
113
+ function extractFromCsv(
114
+ content: string,
115
+ delimiter: string
116
+ ): ExtractedData {
117
+ const lines = content.split(/\r?\n/).filter((l) => l.trim());
118
+ if (lines.length === 0) throw new Error("Empty file");
119
+
120
+ // Simple CSV parser (handles basic cases, not quoted fields with newlines)
121
+ function parseLine(line: string): string[] {
122
+ const result: string[] = [];
123
+ let current = "";
124
+ let inQuotes = false;
125
+
126
+ for (let i = 0; i < line.length; i++) {
127
+ const ch = line[i];
128
+ if (ch === '"' && !inQuotes) {
129
+ inQuotes = true;
130
+ } else if (ch === '"' && inQuotes) {
131
+ if (i + 1 < line.length && line[i + 1] === '"') {
132
+ current += '"';
133
+ i++;
134
+ } else {
135
+ inQuotes = false;
136
+ }
137
+ } else if (ch === delimiter && !inQuotes) {
138
+ result.push(current.trim());
139
+ current = "";
140
+ } else {
141
+ current += ch;
142
+ }
143
+ }
144
+ result.push(current.trim());
145
+ return result;
146
+ }
147
+
148
+ const headers = parseLine(lines[0]);
149
+ const rows: Record<string, string>[] = [];
150
+
151
+ for (let i = 1; i < lines.length; i++) {
152
+ const values = parseLine(lines[i]);
153
+ const rowData: Record<string, string> = {};
154
+ headers.forEach((h, j) => {
155
+ rowData[h] = values[j] ?? "";
156
+ });
157
+ rows.push(rowData);
158
+ }
159
+
160
+ return { headers, rows };
161
+ }
162
+
163
+ // ── Infer column types from sample data ──────────────────────────────
164
+
165
+ export function inferColumnTypes(
166
+ headers: string[],
167
+ sampleRows: Record<string, string>[]
168
+ ): InferredColumn[] {
169
+ return headers.map((header) => {
170
+ const values = sampleRows
171
+ .map((r) => r[header])
172
+ .filter((v) => v != null && v !== "");
173
+
174
+ if (values.length === 0) {
175
+ return makeColumn(header, "text");
176
+ }
177
+
178
+ // Check patterns across all non-empty values
179
+ const allEmail = values.every((v) => EMAIL_RE.test(v));
180
+ if (allEmail) return makeColumn(header, "email");
181
+
182
+ const allUrl = values.every((v) => URL_RE.test(v));
183
+ if (allUrl) return makeColumn(header, "url");
184
+
185
+ const allBoolean = values.every((v) => BOOLEAN_VALUES.has(v.toLowerCase()));
186
+ if (allBoolean) return makeColumn(header, "boolean");
187
+
188
+ const allDate = values.every(
189
+ (v) => ISO_DATE_RE.test(v) || US_DATE_RE.test(v) || EU_DATE_RE.test(v)
190
+ );
191
+ if (allDate) return makeColumn(header, "date");
192
+
193
+ const allNumber = values.every(
194
+ (v) => CURRENCY_RE.test(v.replace(/,/g, "")) || PERCENT_RE.test(v) || !isNaN(Number(v))
195
+ );
196
+ if (allNumber) return makeColumn(header, "number");
197
+
198
+ // Check for select: small set of repeated values
199
+ const unique = new Set(values);
200
+ if (unique.size <= 10 && unique.size < values.length * 0.5) {
201
+ return makeColumn(header, "select", {
202
+ options: Array.from(unique).sort(),
203
+ });
204
+ }
205
+
206
+ return makeColumn(header, "text");
207
+ });
208
+ }
209
+
210
+ function makeColumn(
211
+ header: string,
212
+ dataType: ColumnDataType,
213
+ config?: Record<string, unknown>
214
+ ): InferredColumn {
215
+ const name = header
216
+ .toLowerCase()
217
+ .replace(/[^a-z0-9]+/g, "_")
218
+ .replace(/^_|_$/g, "")
219
+ || `col_${Math.random().toString(36).slice(2, 6)}`;
220
+
221
+ return {
222
+ name,
223
+ displayName: header,
224
+ dataType,
225
+ config,
226
+ };
227
+ }
228
+
229
+ // ── Coerce values to target types ────────────────────────────────────
230
+
231
+ function coerceValue(value: string, dataType: ColumnDataType): unknown {
232
+ if (value === "") return null;
233
+
234
+ switch (dataType) {
235
+ case "number": {
236
+ const cleaned = value.replace(/[$€£¥,%\s]/g, "").replace(/,/g, "");
237
+ const num = Number(cleaned);
238
+ return isNaN(num) ? null : num;
239
+ }
240
+ case "boolean": {
241
+ const lower = value.toLowerCase();
242
+ return lower === "true" || lower === "yes" || lower === "1";
243
+ }
244
+ case "date": {
245
+ // Try to normalize to ISO format
246
+ if (ISO_DATE_RE.test(value)) return value;
247
+ // US format: MM/DD/YYYY
248
+ const usMatch = value.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/);
249
+ if (usMatch) {
250
+ const year = usMatch[3].length === 2 ? `20${usMatch[3]}` : usMatch[3];
251
+ return `${year}-${usMatch[1].padStart(2, "0")}-${usMatch[2].padStart(2, "0")}`;
252
+ }
253
+ // EU format: DD.MM.YYYY
254
+ const euMatch = value.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2,4})$/);
255
+ if (euMatch) {
256
+ const year = euMatch[3].length === 2 ? `20${euMatch[3]}` : euMatch[3];
257
+ return `${year}-${euMatch[2].padStart(2, "0")}-${euMatch[1].padStart(2, "0")}`;
258
+ }
259
+ return value;
260
+ }
261
+ default:
262
+ return value;
263
+ }
264
+ }
265
+
266
+ // ── Batch import rows ────────────────────────────────────────────────
267
+
268
+ export async function importRows(
269
+ tableId: string,
270
+ rawRows: Record<string, string>[],
271
+ columnMapping: InferredColumn[]
272
+ ): Promise<ImportResult> {
273
+ const importId = randomUUID();
274
+ const errors: Array<{ row: number; error: string }> = [];
275
+ let rowsImported = 0;
276
+
277
+ // Process in chunks of 100
278
+ const CHUNK_SIZE = 100;
279
+ for (let i = 0; i < rawRows.length; i += CHUNK_SIZE) {
280
+ const chunk = rawRows.slice(i, i + CHUNK_SIZE);
281
+ const validRows: Array<{ data: Record<string, unknown> }> = [];
282
+
283
+ for (let j = 0; j < chunk.length; j++) {
284
+ const rawRow = chunk[j];
285
+ const rowNum = i + j + 2; // +2 for 1-indexed + header row
286
+
287
+ try {
288
+ const data: Record<string, unknown> = {};
289
+ for (const col of columnMapping) {
290
+ const rawValue = rawRow[col.displayName] ?? "";
291
+ data[col.name] = coerceValue(rawValue, col.dataType);
292
+ }
293
+ validRows.push({ data });
294
+ } catch (err) {
295
+ errors.push({
296
+ row: rowNum,
297
+ error: err instanceof Error ? err.message : "Unknown error",
298
+ });
299
+ }
300
+ }
301
+
302
+ if (validRows.length > 0) {
303
+ await addRows(tableId, validRows);
304
+ rowsImported += validRows.length;
305
+ }
306
+ }
307
+
308
+ return { importId, rowsImported, rowsSkipped: errors.length, errors };
309
+ }
310
+
311
+ // ── Create import audit record ───────────────────────────────────────
312
+
313
+ export async function createImportRecord(
314
+ tableId: string,
315
+ documentId: string,
316
+ result: ImportResult
317
+ ): Promise<void> {
318
+ const now = new Date();
319
+
320
+ // Create audit record
321
+ await db.insert(userTableImports).values({
322
+ id: result.importId,
323
+ tableId,
324
+ documentId,
325
+ rowCount: result.rowsImported,
326
+ errorCount: result.rowsSkipped,
327
+ errors: result.errors.length > 0 ? JSON.stringify(result.errors) : null,
328
+ status: result.rowsSkipped > 0 && result.rowsImported === 0 ? "failed" : "completed",
329
+ createdAt: now,
330
+ });
331
+
332
+ // Link document to table
333
+ try {
334
+ await db.insert(tableDocumentInputs).values({
335
+ id: randomUUID(),
336
+ tableId,
337
+ documentId,
338
+ createdAt: now,
339
+ });
340
+ } catch {
341
+ // Unique constraint — already linked
342
+ }
343
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Query builder for user-defined table rows.
3
+ *
4
+ * Translates structured FilterSpec/SortSpec objects into SQL fragments
5
+ * using json_extract() to query the JSON `data` column.
6
+ *
7
+ * SECURITY: Column names are validated against the table's known column
8
+ * schema before being interpolated into json_extract paths. Never pass
9
+ * raw user input as column names without validation.
10
+ */
11
+
12
+ import { sql, type SQL } from "drizzle-orm";
13
+ import type { FilterSpec, SortSpec, ColumnDef } from "./types";
14
+
15
+ /** Characters allowed in column names — reject anything else */
16
+ const SAFE_COLUMN_NAME = /^[a-zA-Z_][a-zA-Z0-9_]{0,63}$/;
17
+
18
+ /**
19
+ * Validate that a column name exists in the table's schema and is safe
20
+ * for interpolation into json_extract paths.
21
+ */
22
+ function validateColumnName(
23
+ columnName: string,
24
+ validColumns: Set<string>
25
+ ): void {
26
+ if (!SAFE_COLUMN_NAME.test(columnName)) {
27
+ throw new Error(`Invalid column name: ${columnName}`);
28
+ }
29
+ if (!validColumns.has(columnName)) {
30
+ throw new Error(`Unknown column: ${columnName}`);
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Build a json_extract SQL fragment for a column.
36
+ * Output: json_extract(data, '$.columnName')
37
+ */
38
+ function jsonCol(columnName: string): SQL {
39
+ // Column name is already validated — safe to interpolate into the path string
40
+ return sql.raw(`json_extract(data, '$.${columnName}')`);
41
+ }
42
+
43
+ /**
44
+ * Build a WHERE clause fragment from a single filter spec.
45
+ */
46
+ function buildFilterClause(
47
+ filter: FilterSpec,
48
+ validColumns: Set<string>
49
+ ): SQL {
50
+ validateColumnName(filter.column, validColumns);
51
+ const col = jsonCol(filter.column);
52
+
53
+ switch (filter.operator) {
54
+ case "eq":
55
+ return sql`${col} = ${filter.value}`;
56
+ case "neq":
57
+ return sql`${col} != ${filter.value}`;
58
+ case "gt":
59
+ return sql`${col} > ${filter.value}`;
60
+ case "gte":
61
+ return sql`${col} >= ${filter.value}`;
62
+ case "lt":
63
+ return sql`${col} < ${filter.value}`;
64
+ case "lte":
65
+ return sql`${col} <= ${filter.value}`;
66
+ case "contains":
67
+ return sql`${col} LIKE ${"%" + String(filter.value) + "%"}`;
68
+ case "starts_with":
69
+ return sql`${col} LIKE ${String(filter.value) + "%"}`;
70
+ case "in": {
71
+ const values = Array.isArray(filter.value) ? filter.value : [String(filter.value)];
72
+ if (values.length === 0) {
73
+ return sql`0 = 1`; // empty IN → always false
74
+ }
75
+ // Build parameterized IN clause
76
+ const placeholders = values.map((v) => sql`${v}`);
77
+ return sql`${col} IN (${sql.join(placeholders, sql`, `)})`;
78
+ }
79
+ case "is_empty":
80
+ return sql`(${col} IS NULL OR ${col} = '')`;
81
+ case "is_not_empty":
82
+ return sql`(${col} IS NOT NULL AND ${col} != '')`;
83
+ default:
84
+ throw new Error(`Unknown filter operator: ${filter.operator}`);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Build a complete WHERE clause from multiple filters (AND-joined).
90
+ */
91
+ export function buildWhereClause(
92
+ filters: FilterSpec[],
93
+ validColumns: Set<string>
94
+ ): SQL | undefined {
95
+ if (filters.length === 0) return undefined;
96
+
97
+ const clauses = filters.map((f) => buildFilterClause(f, validColumns));
98
+ return sql.join(clauses, sql` AND `);
99
+ }
100
+
101
+ /**
102
+ * Build an ORDER BY clause from sort specs.
103
+ */
104
+ export function buildOrderClause(
105
+ sorts: SortSpec[],
106
+ validColumns: Set<string>
107
+ ): SQL | undefined {
108
+ if (sorts.length === 0) return undefined;
109
+
110
+ const clauses = sorts.map((s) => {
111
+ validateColumnName(s.column, validColumns);
112
+ const col = jsonCol(s.column);
113
+ return s.direction === "desc" ? sql`${col} DESC` : sql`${col} ASC`;
114
+ });
115
+
116
+ return sql.join(clauses, sql`, `);
117
+ }
118
+
119
+ /**
120
+ * Extract the set of valid column names from a column schema.
121
+ */
122
+ export function getValidColumns(columnSchema: ColumnDef[]): Set<string> {
123
+ return new Set(columnSchema.map((c) => c.name));
124
+ }
125
+
126
+ /**
127
+ * Build a full query SQL fragment for row filtering, sorting, and pagination.
128
+ * Returns the WHERE, ORDER BY, LIMIT, and OFFSET fragments.
129
+ */
130
+ export function buildRowQuery(
131
+ columnSchema: ColumnDef[],
132
+ options: {
133
+ filters?: FilterSpec[];
134
+ sorts?: SortSpec[];
135
+ limit?: number;
136
+ offset?: number;
137
+ }
138
+ ): {
139
+ where: SQL | undefined;
140
+ orderBy: SQL | undefined;
141
+ limit: number;
142
+ offset: number;
143
+ } {
144
+ const validColumns = getValidColumns(columnSchema);
145
+
146
+ return {
147
+ where: options.filters ? buildWhereClause(options.filters, validColumns) : undefined,
148
+ orderBy: options.sorts ? buildOrderClause(options.sorts, validColumns) : undefined,
149
+ limit: options.limit ?? 100,
150
+ offset: options.offset ?? 0,
151
+ };
152
+ }