stagent 0.6.3 → 0.8.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 +226 -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 -1
- package/src/app/api/chat/entities/search/route.ts +12 -3
- package/src/app/api/projects/[id]/route.ts +37 -0
- package/src/app/api/projects/__tests__/delete-project.test.ts +12 -0
- 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/globals.css +14 -0
- 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/book/book-reader.tsx +62 -9
- package/src/components/book/content-blocks.tsx +6 -1
- package/src/components/chat/chat-table-result.tsx +139 -0
- package/src/components/documents/document-browser.tsx +1 -1
- package/src/components/projects/project-form-sheet.tsx +3 -27
- package/src/components/schedules/schedule-form.tsx +5 -27
- 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 +214 -11
- 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-row-sheet.tsx +271 -0
- package/src/components/tables/table-spreadsheet.tsx +394 -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-create-panel.tsx +5 -31
- package/src/components/tasks/task-edit-dialog.tsx +5 -27
- package/src/components/workflows/workflow-form-view.tsx +11 -35
- package/src/components/workflows/workflow-status-view.tsx +1 -1
- package/src/instrumentation.ts +3 -0
- 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/builtins/document-writer/SKILL.md +23 -0
- package/src/lib/agents/profiles/builtins/technical-writer/SKILL.md +10 -0
- package/src/lib/agents/profiles/builtins/technical-writer/profile.yaml +1 -1
- package/src/lib/agents/runtime/anthropic-direct.ts +29 -0
- package/src/lib/agents/runtime/openai-direct.ts +29 -0
- package/src/lib/book/chapter-generator.ts +81 -5
- package/src/lib/book/chapter-mapping.ts +58 -24
- package/src/lib/book/content.ts +83 -47
- package/src/lib/book/markdown-parser.ts +1 -1
- package/src/lib/book/reading-paths.ts +8 -8
- package/src/lib/book/types.ts +1 -1
- package/src/lib/book/update-detector.ts +4 -1
- package/src/lib/chat/stagent-tools.ts +2 -0
- package/src/lib/chat/tool-catalog.ts +34 -0
- package/src/lib/chat/tools/table-tools.ts +955 -0
- package/src/lib/chat/tools/workflow-tools.ts +9 -1
- 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 +45 -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 +227 -0
- package/src/lib/db/index.ts +9 -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 +368 -0
- 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/stagent-paths.ts +20 -0
- package/src/lib/workflows/types.ts +1 -1
- package/tsconfig.json +3 -1
- /package/docs/features/{playbook.md → user-guide.md} +0 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build markdown context for tables linked to tasks/workflows.
|
|
3
|
+
* Mirrors the document context-builder pattern.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { db } from "@/lib/db";
|
|
7
|
+
import {
|
|
8
|
+
taskTableInputs,
|
|
9
|
+
workflowTableInputs,
|
|
10
|
+
userTables,
|
|
11
|
+
userTableRows,
|
|
12
|
+
} from "@/lib/db/schema";
|
|
13
|
+
import { eq, inArray } from "drizzle-orm";
|
|
14
|
+
import type { ColumnDef } from "./types";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build table context for a task — queries task_table_inputs junction,
|
|
18
|
+
* returns formatted schema + sample rows as markdown.
|
|
19
|
+
*/
|
|
20
|
+
export async function buildTableContext(taskId: string): Promise<string> {
|
|
21
|
+
const links = db
|
|
22
|
+
.select({ tableId: taskTableInputs.tableId })
|
|
23
|
+
.from(taskTableInputs)
|
|
24
|
+
.where(eq(taskTableInputs.taskId, taskId))
|
|
25
|
+
.all();
|
|
26
|
+
|
|
27
|
+
if (links.length === 0) return "";
|
|
28
|
+
|
|
29
|
+
const tableIds = links.map((l) => l.tableId);
|
|
30
|
+
const tables = db
|
|
31
|
+
.select()
|
|
32
|
+
.from(userTables)
|
|
33
|
+
.where(inArray(userTables.id, tableIds))
|
|
34
|
+
.all();
|
|
35
|
+
|
|
36
|
+
if (tables.length === 0) return "";
|
|
37
|
+
|
|
38
|
+
const sections: string[] = [];
|
|
39
|
+
|
|
40
|
+
for (const table of tables) {
|
|
41
|
+
let columns: ColumnDef[] = [];
|
|
42
|
+
try {
|
|
43
|
+
columns = JSON.parse(table.columnSchema) as ColumnDef[];
|
|
44
|
+
} catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Fetch up to 5 sample rows
|
|
49
|
+
const sampleRows = db
|
|
50
|
+
.select()
|
|
51
|
+
.from(userTableRows)
|
|
52
|
+
.where(eq(userTableRows.tableId, table.id))
|
|
53
|
+
.limit(5)
|
|
54
|
+
.all();
|
|
55
|
+
|
|
56
|
+
const lines: string[] = [];
|
|
57
|
+
lines.push(`### Table: ${table.name}`);
|
|
58
|
+
if (table.description) lines.push(table.description);
|
|
59
|
+
lines.push(`Rows: ${table.rowCount} | Columns: ${columns.length}`);
|
|
60
|
+
lines.push("");
|
|
61
|
+
|
|
62
|
+
// Schema as markdown table
|
|
63
|
+
lines.push("| Column | Type | Required |");
|
|
64
|
+
lines.push("|--------|------|----------|");
|
|
65
|
+
for (const col of columns) {
|
|
66
|
+
lines.push(`| ${col.displayName} | ${col.dataType} | ${col.required ? "Yes" : "No"} |`);
|
|
67
|
+
}
|
|
68
|
+
lines.push("");
|
|
69
|
+
|
|
70
|
+
// Sample data as markdown table
|
|
71
|
+
if (sampleRows.length > 0 && columns.length > 0) {
|
|
72
|
+
const displayCols = columns.slice(0, 6); // Limit width
|
|
73
|
+
lines.push("**Sample data:**");
|
|
74
|
+
lines.push("| " + displayCols.map((c) => c.displayName).join(" | ") + " |");
|
|
75
|
+
lines.push("| " + displayCols.map(() => "---").join(" | ") + " |");
|
|
76
|
+
|
|
77
|
+
for (const row of sampleRows) {
|
|
78
|
+
let data: Record<string, unknown> = {};
|
|
79
|
+
try {
|
|
80
|
+
data = JSON.parse(row.data) as Record<string, unknown>;
|
|
81
|
+
} catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const cells = displayCols.map((c) => {
|
|
85
|
+
const val = data[c.name];
|
|
86
|
+
if (val == null || val === "") return "—";
|
|
87
|
+
const str = String(val);
|
|
88
|
+
return str.length > 40 ? str.slice(0, 37) + "..." : str;
|
|
89
|
+
});
|
|
90
|
+
lines.push("| " + cells.join(" | ") + " |");
|
|
91
|
+
}
|
|
92
|
+
lines.push("");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
sections.push(lines.join("\n"));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (sections.length === 0) return "";
|
|
99
|
+
|
|
100
|
+
return "## Linked Tables\n\n" + sections.join("\n---\n\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build table context for a workflow — aggregates table context
|
|
105
|
+
* for all linked tables.
|
|
106
|
+
*/
|
|
107
|
+
export async function buildWorkflowTableContext(
|
|
108
|
+
workflowId: string
|
|
109
|
+
): Promise<string> {
|
|
110
|
+
const links = db
|
|
111
|
+
.select({ tableId: workflowTableInputs.tableId })
|
|
112
|
+
.from(workflowTableInputs)
|
|
113
|
+
.where(eq(workflowTableInputs.workflowId, workflowId))
|
|
114
|
+
.all();
|
|
115
|
+
|
|
116
|
+
if (links.length === 0) return "";
|
|
117
|
+
|
|
118
|
+
const tableIds = links.map((l) => l.tableId);
|
|
119
|
+
const tables = db
|
|
120
|
+
.select()
|
|
121
|
+
.from(userTables)
|
|
122
|
+
.where(inArray(userTables.id, tableIds))
|
|
123
|
+
.all();
|
|
124
|
+
|
|
125
|
+
if (tables.length === 0) return "";
|
|
126
|
+
|
|
127
|
+
const lines: string[] = ["## Workflow Tables\n"];
|
|
128
|
+
for (const table of tables) {
|
|
129
|
+
let columns: ColumnDef[] = [];
|
|
130
|
+
try {
|
|
131
|
+
columns = JSON.parse(table.columnSchema) as ColumnDef[];
|
|
132
|
+
} catch {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
lines.push(`- **${table.name}**: ${columns.length} columns, ${table.rowCount} rows`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return lines.join("\n");
|
|
139
|
+
}
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe AST-based formula evaluator for computed columns.
|
|
3
|
+
*
|
|
4
|
+
* SECURITY: Pure interpretation — no eval(), Function(), or prototype access.
|
|
5
|
+
* Only allowlisted operators and functions are executable.
|
|
6
|
+
*
|
|
7
|
+
* Syntax:
|
|
8
|
+
* {{column_name}} — column reference
|
|
9
|
+
* + - * / % — arithmetic operators
|
|
10
|
+
* == != > >= < <= — comparison operators
|
|
11
|
+
* concat(a, b) — text concatenation
|
|
12
|
+
* if(cond, then, else) — conditional
|
|
13
|
+
* sum(col), avg(col), min(col), max(col), count(col) — aggregates
|
|
14
|
+
* daysBetween(date1, date2), today() — date functions
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ── AST Node Types ───────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
type ASTNode =
|
|
20
|
+
| { type: "number"; value: number }
|
|
21
|
+
| { type: "string"; value: string }
|
|
22
|
+
| { type: "boolean"; value: boolean }
|
|
23
|
+
| { type: "column_ref"; name: string }
|
|
24
|
+
| { type: "binary"; op: string; left: ASTNode; right: ASTNode }
|
|
25
|
+
| { type: "unary"; op: string; operand: ASTNode }
|
|
26
|
+
| { type: "call"; name: string; args: ASTNode[] };
|
|
27
|
+
|
|
28
|
+
// ── Tokenizer ────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
type Token =
|
|
31
|
+
| { type: "number"; value: number }
|
|
32
|
+
| { type: "string"; value: string }
|
|
33
|
+
| { type: "column_ref"; name: string }
|
|
34
|
+
| { type: "ident"; name: string }
|
|
35
|
+
| { type: "op"; value: string }
|
|
36
|
+
| { type: "paren"; value: "(" | ")" }
|
|
37
|
+
| { type: "comma" };
|
|
38
|
+
|
|
39
|
+
function tokenize(formula: string): Token[] {
|
|
40
|
+
const tokens: Token[] = [];
|
|
41
|
+
let i = 0;
|
|
42
|
+
|
|
43
|
+
while (i < formula.length) {
|
|
44
|
+
// Skip whitespace
|
|
45
|
+
if (/\s/.test(formula[i])) { i++; continue; }
|
|
46
|
+
|
|
47
|
+
// Column reference: {{name}}
|
|
48
|
+
if (formula[i] === "{" && formula[i + 1] === "{") {
|
|
49
|
+
i += 2;
|
|
50
|
+
let name = "";
|
|
51
|
+
while (i < formula.length && !(formula[i] === "}" && formula[i + 1] === "}")) {
|
|
52
|
+
name += formula[i++];
|
|
53
|
+
}
|
|
54
|
+
i += 2; // skip }}
|
|
55
|
+
tokens.push({ type: "column_ref", name: name.trim() });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Number
|
|
60
|
+
if (/\d/.test(formula[i]) || (formula[i] === "." && i + 1 < formula.length && /\d/.test(formula[i + 1]))) {
|
|
61
|
+
let num = "";
|
|
62
|
+
while (i < formula.length && /[\d.]/.test(formula[i])) num += formula[i++];
|
|
63
|
+
tokens.push({ type: "number", value: parseFloat(num) });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// String literal
|
|
68
|
+
if (formula[i] === '"' || formula[i] === "'") {
|
|
69
|
+
const quote = formula[i++];
|
|
70
|
+
let str = "";
|
|
71
|
+
while (i < formula.length && formula[i] !== quote) str += formula[i++];
|
|
72
|
+
i++; // skip closing quote
|
|
73
|
+
tokens.push({ type: "string", value: str });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Operators (2-char first)
|
|
78
|
+
const twoChar = formula.slice(i, i + 2);
|
|
79
|
+
if (["==", "!=", ">=", "<="].includes(twoChar)) {
|
|
80
|
+
tokens.push({ type: "op", value: twoChar });
|
|
81
|
+
i += 2;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Single-char operators
|
|
86
|
+
if ("+-*/%><!".includes(formula[i])) {
|
|
87
|
+
tokens.push({ type: "op", value: formula[i++] });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Parentheses
|
|
92
|
+
if (formula[i] === "(" || formula[i] === ")") {
|
|
93
|
+
tokens.push({ type: "paren", value: formula[i++] as "(" | ")" });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Comma
|
|
98
|
+
if (formula[i] === ",") {
|
|
99
|
+
tokens.push({ type: "comma" });
|
|
100
|
+
i++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Identifiers (function names, booleans)
|
|
105
|
+
if (/[a-zA-Z_]/.test(formula[i])) {
|
|
106
|
+
let ident = "";
|
|
107
|
+
while (i < formula.length && /[a-zA-Z0-9_]/.test(formula[i])) ident += formula[i++];
|
|
108
|
+
|
|
109
|
+
if (ident === "true") tokens.push({ type: "string", value: "true" });
|
|
110
|
+
else if (ident === "false") tokens.push({ type: "string", value: "false" });
|
|
111
|
+
else tokens.push({ type: "ident", name: ident });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
throw new Error(`Unexpected character: ${formula[i]} at position ${i}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return tokens;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Parser (recursive descent) ───────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
class Parser {
|
|
124
|
+
private tokens: Token[];
|
|
125
|
+
private pos = 0;
|
|
126
|
+
|
|
127
|
+
constructor(tokens: Token[]) {
|
|
128
|
+
this.tokens = tokens;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
parse(): ASTNode {
|
|
132
|
+
const node = this.parseExpression();
|
|
133
|
+
if (this.pos < this.tokens.length) {
|
|
134
|
+
throw new Error(`Unexpected token at position ${this.pos}`);
|
|
135
|
+
}
|
|
136
|
+
return node;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private peek(): Token | undefined {
|
|
140
|
+
return this.tokens[this.pos];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private advance(): Token {
|
|
144
|
+
return this.tokens[this.pos++];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private parseExpression(): ASTNode {
|
|
148
|
+
return this.parseComparison();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private parseComparison(): ASTNode {
|
|
152
|
+
let left = this.parseAddSub();
|
|
153
|
+
|
|
154
|
+
while (this.peek()?.type === "op" && ["==", "!=", ">", ">=", "<", "<="].includes((this.peek() as { value: string }).value)) {
|
|
155
|
+
const op = (this.advance() as { value: string }).value;
|
|
156
|
+
const right = this.parseAddSub();
|
|
157
|
+
left = { type: "binary", op, left, right };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return left;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private parseAddSub(): ASTNode {
|
|
164
|
+
let left = this.parseMulDiv();
|
|
165
|
+
|
|
166
|
+
while (this.peek()?.type === "op" && ["+", "-"].includes((this.peek() as { value: string }).value)) {
|
|
167
|
+
const op = (this.advance() as { value: string }).value;
|
|
168
|
+
const right = this.parseMulDiv();
|
|
169
|
+
left = { type: "binary", op, left, right };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return left;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private parseMulDiv(): ASTNode {
|
|
176
|
+
let left = this.parseUnary();
|
|
177
|
+
|
|
178
|
+
while (this.peek()?.type === "op" && ["*", "/", "%"].includes((this.peek() as { value: string }).value)) {
|
|
179
|
+
const op = (this.advance() as { value: string }).value;
|
|
180
|
+
const right = this.parseUnary();
|
|
181
|
+
left = { type: "binary", op, left, right };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return left;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private parseUnary(): ASTNode {
|
|
188
|
+
if (this.peek()?.type === "op" && (this.peek() as { value: string }).value === "-") {
|
|
189
|
+
this.advance();
|
|
190
|
+
const operand = this.parsePrimary();
|
|
191
|
+
return { type: "unary", op: "-", operand };
|
|
192
|
+
}
|
|
193
|
+
return this.parsePrimary();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private parsePrimary(): ASTNode {
|
|
197
|
+
const token = this.peek();
|
|
198
|
+
if (!token) throw new Error("Unexpected end of formula");
|
|
199
|
+
|
|
200
|
+
if (token.type === "number") {
|
|
201
|
+
this.advance();
|
|
202
|
+
return { type: "number", value: token.value };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (token.type === "string") {
|
|
206
|
+
this.advance();
|
|
207
|
+
return { type: "string", value: token.value };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (token.type === "column_ref") {
|
|
211
|
+
this.advance();
|
|
212
|
+
return { type: "column_ref", name: token.name };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (token.type === "ident") {
|
|
216
|
+
this.advance();
|
|
217
|
+
if (this.peek()?.type === "paren" && (this.peek() as { value: string }).value === "(") {
|
|
218
|
+
this.advance(); // consume (
|
|
219
|
+
const args: ASTNode[] = [];
|
|
220
|
+
while (!(this.peek()?.type === "paren" && (this.peek() as { value: string }).value === ")")) {
|
|
221
|
+
if (args.length > 0) {
|
|
222
|
+
if (this.peek()?.type !== "comma") throw new Error("Expected comma");
|
|
223
|
+
this.advance();
|
|
224
|
+
}
|
|
225
|
+
args.push(this.parseExpression());
|
|
226
|
+
}
|
|
227
|
+
this.advance(); // consume )
|
|
228
|
+
return { type: "call", name: token.name, args };
|
|
229
|
+
}
|
|
230
|
+
return { type: "column_ref", name: token.name };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (token.type === "paren" && token.value === "(") {
|
|
234
|
+
this.advance();
|
|
235
|
+
const expr = this.parseExpression();
|
|
236
|
+
if (!(this.peek()?.type === "paren" && (this.peek() as { value: string }).value === ")")) {
|
|
237
|
+
throw new Error("Expected closing parenthesis");
|
|
238
|
+
}
|
|
239
|
+
this.advance();
|
|
240
|
+
return expr;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
throw new Error(`Unexpected token: ${JSON.stringify(token)}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Evaluator ────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
const ALLOWED_FUNCTIONS = new Set([
|
|
250
|
+
"sum", "avg", "min", "max", "count",
|
|
251
|
+
"daysBetween", "today", "concat", "if",
|
|
252
|
+
"abs", "round", "floor", "ceil",
|
|
253
|
+
]);
|
|
254
|
+
|
|
255
|
+
interface EvalContext {
|
|
256
|
+
row: Record<string, unknown>;
|
|
257
|
+
allRows?: Record<string, unknown>[];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function evaluate(node: ASTNode, ctx: EvalContext): unknown {
|
|
261
|
+
switch (node.type) {
|
|
262
|
+
case "number":
|
|
263
|
+
return node.value;
|
|
264
|
+
case "string":
|
|
265
|
+
return node.value;
|
|
266
|
+
case "boolean":
|
|
267
|
+
return node.value;
|
|
268
|
+
|
|
269
|
+
case "column_ref": {
|
|
270
|
+
return ctx.row[node.name] ?? null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
case "unary": {
|
|
274
|
+
const operand = evaluate(node.operand, ctx);
|
|
275
|
+
if (node.op === "-") return -(Number(operand) || 0);
|
|
276
|
+
return operand;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
case "binary": {
|
|
280
|
+
const left = evaluate(node.left, ctx);
|
|
281
|
+
const right = evaluate(node.right, ctx);
|
|
282
|
+
|
|
283
|
+
switch (node.op) {
|
|
284
|
+
case "+": {
|
|
285
|
+
if (typeof left === "string" || typeof right === "string") {
|
|
286
|
+
return String(left ?? "") + String(right ?? "");
|
|
287
|
+
}
|
|
288
|
+
return (Number(left) || 0) + (Number(right) || 0);
|
|
289
|
+
}
|
|
290
|
+
case "-": return (Number(left) || 0) - (Number(right) || 0);
|
|
291
|
+
case "*": return (Number(left) || 0) * (Number(right) || 0);
|
|
292
|
+
case "/": {
|
|
293
|
+
const divisor = Number(right) || 0;
|
|
294
|
+
return divisor === 0 ? null : (Number(left) || 0) / divisor;
|
|
295
|
+
}
|
|
296
|
+
case "%": return (Number(left) || 0) % (Number(right) || 1);
|
|
297
|
+
case "==": return left == right; // eslint-disable-line eqeqeq
|
|
298
|
+
case "!=": return left != right; // eslint-disable-line eqeqeq
|
|
299
|
+
case ">": return Number(left) > Number(right);
|
|
300
|
+
case ">=": return Number(left) >= Number(right);
|
|
301
|
+
case "<": return Number(left) < Number(right);
|
|
302
|
+
case "<=": return Number(left) <= Number(right);
|
|
303
|
+
default: throw new Error(`Unknown operator: ${node.op}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
case "call": {
|
|
308
|
+
if (!ALLOWED_FUNCTIONS.has(node.name)) {
|
|
309
|
+
throw new Error(`Unknown function: ${node.name}`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
switch (node.name) {
|
|
313
|
+
case "if": {
|
|
314
|
+
const condition = evaluate(node.args[0], ctx);
|
|
315
|
+
return condition ? evaluate(node.args[1], ctx) : evaluate(node.args[2], ctx);
|
|
316
|
+
}
|
|
317
|
+
case "concat":
|
|
318
|
+
return node.args.map((a) => String(evaluate(a, ctx) ?? "")).join("");
|
|
319
|
+
case "today":
|
|
320
|
+
return new Date().toISOString().split("T")[0];
|
|
321
|
+
case "daysBetween": {
|
|
322
|
+
const d1 = new Date(String(evaluate(node.args[0], ctx)));
|
|
323
|
+
const d2 = new Date(String(evaluate(node.args[1], ctx)));
|
|
324
|
+
return Math.floor(Math.abs(d2.getTime() - d1.getTime()) / (1000 * 60 * 60 * 24));
|
|
325
|
+
}
|
|
326
|
+
case "abs": return Math.abs(Number(evaluate(node.args[0], ctx)) || 0);
|
|
327
|
+
case "round": return Math.round(Number(evaluate(node.args[0], ctx)) || 0);
|
|
328
|
+
case "floor": return Math.floor(Number(evaluate(node.args[0], ctx)) || 0);
|
|
329
|
+
case "ceil": return Math.ceil(Number(evaluate(node.args[0], ctx)) || 0);
|
|
330
|
+
|
|
331
|
+
// Aggregate functions
|
|
332
|
+
case "sum": case "avg": case "min": case "max": case "count": {
|
|
333
|
+
if (!ctx.allRows || ctx.allRows.length === 0) return null;
|
|
334
|
+
const colName = node.args[0].type === "column_ref"
|
|
335
|
+
? node.args[0].name
|
|
336
|
+
: String(evaluate(node.args[0], ctx));
|
|
337
|
+
const values = ctx.allRows
|
|
338
|
+
.map((r) => Number(r[colName]))
|
|
339
|
+
.filter((v) => !isNaN(v));
|
|
340
|
+
if (values.length === 0) return null;
|
|
341
|
+
switch (node.name) {
|
|
342
|
+
case "sum": return values.reduce((a, b) => a + b, 0);
|
|
343
|
+
case "avg": return values.reduce((a, b) => a + b, 0) / values.length;
|
|
344
|
+
case "min": return Math.min(...values);
|
|
345
|
+
case "max": return Math.max(...values);
|
|
346
|
+
case "count": return values.length;
|
|
347
|
+
}
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
default:
|
|
352
|
+
throw new Error(`Unimplemented function: ${node.name}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── Public API ───────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
export function parseFormula(formula: string): ASTNode {
|
|
361
|
+
const tokens = tokenize(formula);
|
|
362
|
+
return new Parser(tokens).parse();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function evaluateFormula(
|
|
366
|
+
formula: string,
|
|
367
|
+
row: Record<string, unknown>,
|
|
368
|
+
allRows?: Record<string, unknown>[]
|
|
369
|
+
): unknown {
|
|
370
|
+
try {
|
|
371
|
+
const ast = parseFormula(formula);
|
|
372
|
+
return evaluate(ast, { row, allRows });
|
|
373
|
+
} catch {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function extractDependencies(formula: string): string[] {
|
|
379
|
+
const deps: string[] = [];
|
|
380
|
+
const re = /\{\{(\w+)\}\}/g;
|
|
381
|
+
let match;
|
|
382
|
+
while ((match = re.exec(formula)) !== null) {
|
|
383
|
+
deps.push(match[1]);
|
|
384
|
+
}
|
|
385
|
+
return [...new Set(deps)];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function hasCyclicDependencies(
|
|
389
|
+
columns: Array<{ name: string; dependencies?: string[] }>
|
|
390
|
+
): boolean {
|
|
391
|
+
const graph = new Map<string, string[]>();
|
|
392
|
+
for (const col of columns) {
|
|
393
|
+
graph.set(col.name, col.dependencies ?? []);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const visited = new Set<string>();
|
|
397
|
+
const inStack = new Set<string>();
|
|
398
|
+
|
|
399
|
+
function dfs(node: string): boolean {
|
|
400
|
+
if (inStack.has(node)) return true;
|
|
401
|
+
if (visited.has(node)) return false;
|
|
402
|
+
visited.add(node);
|
|
403
|
+
inStack.add(node);
|
|
404
|
+
for (const dep of graph.get(node) ?? []) {
|
|
405
|
+
if (dfs(dep)) return true;
|
|
406
|
+
}
|
|
407
|
+
inStack.delete(node);
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
for (const col of columns) {
|
|
412
|
+
if (dfs(col.name)) return true;
|
|
413
|
+
}
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
@@ -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
|
+
}
|