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.
- package/README.md +21 -2
- package/dist/cli.js +272 -1
- package/docs/.coverage-gaps.json +66 -16
- package/docs/.last-generated +1 -1
- package/docs/features/dashboard-kanban.md +13 -7
- package/docs/features/settings.md +15 -3
- package/docs/features/tables.md +122 -0
- package/docs/index.md +3 -2
- package/docs/journeys/developer.md +26 -16
- package/docs/journeys/personal-use.md +23 -9
- package/docs/journeys/power-user.md +40 -14
- package/docs/journeys/work-use.md +43 -15
- package/docs/manifest.json +27 -17
- package/package.json +3 -2
- package/src/app/api/chat/entities/search/route.ts +12 -3
- package/src/app/api/documents/[id]/route.ts +5 -1
- package/src/app/api/documents/[id]/versions/route.ts +53 -0
- package/src/app/api/documents/route.ts +5 -1
- package/src/app/api/projects/[id]/documents/route.ts +124 -0
- package/src/app/api/projects/[id]/route.ts +72 -3
- package/src/app/api/projects/__tests__/delete-project.test.ts +13 -0
- package/src/app/api/schedules/route.ts +19 -1
- package/src/app/api/snapshots/[id]/restore/route.ts +62 -0
- package/src/app/api/snapshots/[id]/route.ts +44 -0
- package/src/app/api/snapshots/route.ts +54 -0
- package/src/app/api/snapshots/settings/route.ts +67 -0
- package/src/app/api/tables/[id]/charts/[chartId]/route.ts +89 -0
- package/src/app/api/tables/[id]/charts/route.ts +72 -0
- package/src/app/api/tables/[id]/columns/route.ts +70 -0
- package/src/app/api/tables/[id]/export/route.ts +94 -0
- package/src/app/api/tables/[id]/history/route.ts +15 -0
- package/src/app/api/tables/[id]/import/route.ts +111 -0
- package/src/app/api/tables/[id]/route.ts +86 -0
- package/src/app/api/tables/[id]/rows/[rowId]/history/route.ts +32 -0
- package/src/app/api/tables/[id]/rows/[rowId]/route.ts +51 -0
- package/src/app/api/tables/[id]/rows/route.ts +101 -0
- package/src/app/api/tables/[id]/triggers/[triggerId]/route.ts +65 -0
- package/src/app/api/tables/[id]/triggers/route.ts +122 -0
- package/src/app/api/tables/route.ts +65 -0
- package/src/app/api/tables/templates/route.ts +92 -0
- package/src/app/api/tasks/[id]/route.ts +37 -2
- package/src/app/api/tasks/[id]/siblings/route.ts +48 -0
- package/src/app/api/tasks/route.ts +8 -9
- package/src/app/api/workflows/[id]/documents/route.ts +209 -0
- package/src/app/api/workflows/[id]/execute/route.ts +6 -2
- package/src/app/api/workflows/[id]/route.ts +16 -3
- package/src/app/api/workflows/[id]/status/route.ts +18 -2
- package/src/app/api/workflows/route.ts +13 -2
- package/src/app/documents/page.tsx +5 -1
- package/src/app/layout.tsx +0 -1
- package/src/app/manifest.ts +3 -3
- package/src/app/projects/[id]/page.tsx +62 -2
- package/src/app/settings/page.tsx +2 -0
- package/src/app/tables/[id]/page.tsx +67 -0
- package/src/app/tables/page.tsx +21 -0
- package/src/app/tables/templates/page.tsx +19 -0
- package/src/components/chat/chat-table-result.tsx +139 -0
- package/src/components/documents/document-browser.tsx +1 -1
- package/src/components/documents/document-chip-bar.tsx +17 -1
- package/src/components/documents/document-detail-view.tsx +51 -0
- package/src/components/documents/document-grid.tsx +5 -0
- package/src/components/documents/document-table.tsx +4 -0
- package/src/components/documents/types.ts +3 -0
- package/src/components/projects/project-form-sheet.tsx +109 -2
- package/src/components/schedules/schedule-form.tsx +91 -1
- package/src/components/settings/data-management-section.tsx +17 -12
- package/src/components/settings/database-snapshots-section.tsx +469 -0
- package/src/components/shared/app-sidebar.tsx +2 -0
- package/src/components/shared/document-picker-sheet.tsx +486 -0
- package/src/components/tables/table-browser.tsx +234 -0
- package/src/components/tables/table-cell-editor.tsx +226 -0
- package/src/components/tables/table-chart-builder.tsx +288 -0
- package/src/components/tables/table-chart-view.tsx +146 -0
- package/src/components/tables/table-column-header.tsx +103 -0
- package/src/components/tables/table-column-sheet.tsx +331 -0
- package/src/components/tables/table-create-sheet.tsx +240 -0
- package/src/components/tables/table-detail-sheet.tsx +144 -0
- package/src/components/tables/table-detail-tabs.tsx +278 -0
- package/src/components/tables/table-grid.tsx +61 -0
- package/src/components/tables/table-history-tab.tsx +148 -0
- package/src/components/tables/table-import-wizard.tsx +542 -0
- package/src/components/tables/table-list-table.tsx +95 -0
- package/src/components/tables/table-relation-combobox.tsx +217 -0
- package/src/components/tables/table-spreadsheet.tsx +499 -0
- package/src/components/tables/table-template-gallery.tsx +162 -0
- package/src/components/tables/table-template-preview.tsx +219 -0
- package/src/components/tables/table-toolbar.tsx +79 -0
- package/src/components/tables/table-triggers-tab.tsx +446 -0
- package/src/components/tables/types.ts +6 -0
- package/src/components/tables/use-spreadsheet-keys.ts +171 -0
- package/src/components/tables/utils.ts +29 -0
- package/src/components/tasks/task-card.tsx +8 -1
- package/src/components/tasks/task-create-panel.tsx +111 -14
- package/src/components/tasks/task-detail-view.tsx +47 -0
- package/src/components/tasks/task-edit-dialog.tsx +103 -2
- package/src/components/workflows/workflow-form-view.tsx +207 -7
- package/src/components/workflows/workflow-kanban-card.tsx +8 -1
- package/src/components/workflows/workflow-list.tsx +90 -45
- package/src/components/workflows/workflow-status-view.tsx +168 -23
- package/src/instrumentation.ts +3 -0
- package/src/lib/__tests__/npx-process-cwd.test.ts +17 -2
- package/src/lib/agents/__tests__/claude-agent.test.ts +5 -1
- package/src/lib/agents/claude-agent.ts +3 -1
- package/src/lib/agents/profiles/registry.ts +6 -3
- package/src/lib/agents/runtime/anthropic-direct.ts +29 -0
- package/src/lib/agents/runtime/openai-direct.ts +29 -0
- package/src/lib/book/__tests__/chapter-slugs.test.ts +80 -0
- package/src/lib/book/chapter-generator.ts +4 -19
- package/src/lib/book/chapter-mapping.ts +17 -0
- package/src/lib/book/content.ts +5 -16
- package/src/lib/book/update-detector.ts +3 -16
- package/src/lib/chat/engine.ts +1 -0
- package/src/lib/chat/stagent-tools.ts +2 -0
- package/src/lib/chat/system-prompt.ts +9 -1
- package/src/lib/chat/tool-catalog.ts +35 -0
- package/src/lib/chat/tools/settings-tools.ts +109 -0
- package/src/lib/chat/tools/table-tools.ts +955 -0
- package/src/lib/chat/tools/workflow-tools.ts +145 -2
- package/src/lib/constants/table-status.ts +68 -0
- package/src/lib/data/__tests__/clear.test.ts +1 -1
- package/src/lib/data/clear.ts +57 -0
- package/src/lib/data/seed-data/__tests__/profiles.test.ts +28 -23
- package/src/lib/data/seed-data/conversations.ts +350 -42
- package/src/lib/data/seed-data/documents.ts +564 -591
- package/src/lib/data/seed-data/learned-context.ts +101 -22
- package/src/lib/data/seed-data/notifications.ts +344 -70
- package/src/lib/data/seed-data/profile-test-results.ts +92 -11
- package/src/lib/data/seed-data/profiles.ts +144 -46
- package/src/lib/data/seed-data/projects.ts +50 -18
- package/src/lib/data/seed-data/repo-imports.ts +28 -13
- package/src/lib/data/seed-data/schedules.ts +208 -41
- package/src/lib/data/seed-data/table-templates.ts +234 -0
- package/src/lib/data/seed-data/tasks.ts +614 -116
- package/src/lib/data/seed-data/usage-ledger.ts +182 -103
- package/src/lib/data/seed-data/user-tables.ts +203 -0
- package/src/lib/data/seed-data/views.ts +52 -7
- package/src/lib/data/seed-data/workflows.ts +231 -84
- package/src/lib/data/seed.ts +55 -14
- package/src/lib/data/tables.ts +417 -0
- package/src/lib/db/bootstrap.ts +275 -0
- package/src/lib/db/index.ts +9 -0
- package/src/lib/db/migrations/0016_add_workflow_document_inputs.sql +13 -0
- package/src/lib/db/migrations/0017_add_document_picker_tables.sql +25 -0
- package/src/lib/db/migrations/0018_add_workflow_run_number.sql +2 -0
- package/src/lib/db/migrations/0019_add_tables_feature.sql +160 -0
- package/src/lib/db/migrations/0020_add_table_triggers.sql +19 -0
- package/src/lib/db/migrations/0021_add_row_history.sql +15 -0
- package/src/lib/db/schema.ts +445 -0
- package/src/lib/docs/reader.ts +2 -3
- package/src/lib/documents/context-builder.ts +75 -2
- package/src/lib/documents/document-resolver.ts +119 -0
- package/src/lib/documents/processors/spreadsheet.ts +2 -1
- package/src/lib/schedules/scheduler.ts +31 -1
- package/src/lib/snapshots/auto-backup.ts +132 -0
- package/src/lib/snapshots/retention.ts +64 -0
- package/src/lib/snapshots/snapshot-manager.ts +429 -0
- package/src/lib/tables/computed.ts +61 -0
- package/src/lib/tables/context-builder.ts +139 -0
- package/src/lib/tables/formula-engine.ts +415 -0
- package/src/lib/tables/history.ts +115 -0
- package/src/lib/tables/import.ts +343 -0
- package/src/lib/tables/query-builder.ts +152 -0
- package/src/lib/tables/trigger-evaluator.ts +146 -0
- package/src/lib/tables/types.ts +141 -0
- package/src/lib/tables/validation.ts +119 -0
- package/src/lib/utils/app-root.ts +20 -0
- package/src/lib/utils/stagent-paths.ts +20 -0
- package/src/lib/validators/__tests__/task.test.ts +43 -10
- package/src/lib/validators/task.ts +7 -1
- package/src/lib/workflows/blueprints/registry.ts +3 -3
- package/src/lib/workflows/engine.ts +24 -8
- package/src/lib/workflows/types.ts +14 -0
- package/tsconfig.json +3 -1
- package/public/icon.svg +0 -13
- package/src/components/tasks/file-upload.tsx +0 -120
- /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
|
+
}
|