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.
Files changed (139) hide show
  1. package/README.md +21 -2
  2. package/dist/cli.js +226 -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 -1
  15. package/src/app/api/chat/entities/search/route.ts +12 -3
  16. package/src/app/api/projects/[id]/route.ts +37 -0
  17. package/src/app/api/projects/__tests__/delete-project.test.ts +12 -0
  18. package/src/app/api/snapshots/[id]/restore/route.ts +62 -0
  19. package/src/app/api/snapshots/[id]/route.ts +44 -0
  20. package/src/app/api/snapshots/route.ts +54 -0
  21. package/src/app/api/snapshots/settings/route.ts +67 -0
  22. package/src/app/api/tables/[id]/charts/[chartId]/route.ts +89 -0
  23. package/src/app/api/tables/[id]/charts/route.ts +72 -0
  24. package/src/app/api/tables/[id]/columns/route.ts +70 -0
  25. package/src/app/api/tables/[id]/export/route.ts +94 -0
  26. package/src/app/api/tables/[id]/history/route.ts +15 -0
  27. package/src/app/api/tables/[id]/import/route.ts +111 -0
  28. package/src/app/api/tables/[id]/route.ts +86 -0
  29. package/src/app/api/tables/[id]/rows/[rowId]/history/route.ts +32 -0
  30. package/src/app/api/tables/[id]/rows/[rowId]/route.ts +51 -0
  31. package/src/app/api/tables/[id]/rows/route.ts +101 -0
  32. package/src/app/api/tables/[id]/triggers/[triggerId]/route.ts +65 -0
  33. package/src/app/api/tables/[id]/triggers/route.ts +122 -0
  34. package/src/app/api/tables/route.ts +65 -0
  35. package/src/app/api/tables/templates/route.ts +92 -0
  36. package/src/app/globals.css +14 -0
  37. package/src/app/settings/page.tsx +2 -0
  38. package/src/app/tables/[id]/page.tsx +67 -0
  39. package/src/app/tables/page.tsx +21 -0
  40. package/src/app/tables/templates/page.tsx +19 -0
  41. package/src/components/book/book-reader.tsx +62 -9
  42. package/src/components/book/content-blocks.tsx +6 -1
  43. package/src/components/chat/chat-table-result.tsx +139 -0
  44. package/src/components/documents/document-browser.tsx +1 -1
  45. package/src/components/projects/project-form-sheet.tsx +3 -27
  46. package/src/components/schedules/schedule-form.tsx +5 -27
  47. package/src/components/settings/data-management-section.tsx +17 -12
  48. package/src/components/settings/database-snapshots-section.tsx +469 -0
  49. package/src/components/shared/app-sidebar.tsx +2 -0
  50. package/src/components/shared/document-picker-sheet.tsx +214 -11
  51. package/src/components/tables/table-browser.tsx +234 -0
  52. package/src/components/tables/table-cell-editor.tsx +226 -0
  53. package/src/components/tables/table-chart-builder.tsx +288 -0
  54. package/src/components/tables/table-chart-view.tsx +146 -0
  55. package/src/components/tables/table-column-header.tsx +103 -0
  56. package/src/components/tables/table-column-sheet.tsx +331 -0
  57. package/src/components/tables/table-create-sheet.tsx +240 -0
  58. package/src/components/tables/table-detail-sheet.tsx +144 -0
  59. package/src/components/tables/table-detail-tabs.tsx +278 -0
  60. package/src/components/tables/table-grid.tsx +61 -0
  61. package/src/components/tables/table-history-tab.tsx +148 -0
  62. package/src/components/tables/table-import-wizard.tsx +542 -0
  63. package/src/components/tables/table-list-table.tsx +95 -0
  64. package/src/components/tables/table-relation-combobox.tsx +217 -0
  65. package/src/components/tables/table-row-sheet.tsx +271 -0
  66. package/src/components/tables/table-spreadsheet.tsx +394 -0
  67. package/src/components/tables/table-template-gallery.tsx +162 -0
  68. package/src/components/tables/table-template-preview.tsx +219 -0
  69. package/src/components/tables/table-toolbar.tsx +79 -0
  70. package/src/components/tables/table-triggers-tab.tsx +446 -0
  71. package/src/components/tables/types.ts +6 -0
  72. package/src/components/tables/use-spreadsheet-keys.ts +171 -0
  73. package/src/components/tables/utils.ts +29 -0
  74. package/src/components/tasks/task-create-panel.tsx +5 -31
  75. package/src/components/tasks/task-edit-dialog.tsx +5 -27
  76. package/src/components/workflows/workflow-form-view.tsx +11 -35
  77. package/src/components/workflows/workflow-status-view.tsx +1 -1
  78. package/src/instrumentation.ts +3 -0
  79. package/src/lib/agents/__tests__/claude-agent.test.ts +5 -1
  80. package/src/lib/agents/claude-agent.ts +3 -1
  81. package/src/lib/agents/profiles/builtins/document-writer/SKILL.md +23 -0
  82. package/src/lib/agents/profiles/builtins/technical-writer/SKILL.md +10 -0
  83. package/src/lib/agents/profiles/builtins/technical-writer/profile.yaml +1 -1
  84. package/src/lib/agents/runtime/anthropic-direct.ts +29 -0
  85. package/src/lib/agents/runtime/openai-direct.ts +29 -0
  86. package/src/lib/book/chapter-generator.ts +81 -5
  87. package/src/lib/book/chapter-mapping.ts +58 -24
  88. package/src/lib/book/content.ts +83 -47
  89. package/src/lib/book/markdown-parser.ts +1 -1
  90. package/src/lib/book/reading-paths.ts +8 -8
  91. package/src/lib/book/types.ts +1 -1
  92. package/src/lib/book/update-detector.ts +4 -1
  93. package/src/lib/chat/stagent-tools.ts +2 -0
  94. package/src/lib/chat/tool-catalog.ts +34 -0
  95. package/src/lib/chat/tools/table-tools.ts +955 -0
  96. package/src/lib/chat/tools/workflow-tools.ts +9 -1
  97. package/src/lib/constants/table-status.ts +68 -0
  98. package/src/lib/data/__tests__/clear.test.ts +1 -1
  99. package/src/lib/data/clear.ts +45 -0
  100. package/src/lib/data/seed-data/__tests__/profiles.test.ts +28 -23
  101. package/src/lib/data/seed-data/conversations.ts +350 -42
  102. package/src/lib/data/seed-data/documents.ts +564 -591
  103. package/src/lib/data/seed-data/learned-context.ts +101 -22
  104. package/src/lib/data/seed-data/notifications.ts +344 -70
  105. package/src/lib/data/seed-data/profile-test-results.ts +92 -11
  106. package/src/lib/data/seed-data/profiles.ts +144 -46
  107. package/src/lib/data/seed-data/projects.ts +50 -18
  108. package/src/lib/data/seed-data/repo-imports.ts +28 -13
  109. package/src/lib/data/seed-data/schedules.ts +208 -41
  110. package/src/lib/data/seed-data/table-templates.ts +234 -0
  111. package/src/lib/data/seed-data/tasks.ts +614 -116
  112. package/src/lib/data/seed-data/usage-ledger.ts +182 -103
  113. package/src/lib/data/seed-data/user-tables.ts +203 -0
  114. package/src/lib/data/seed-data/views.ts +52 -7
  115. package/src/lib/data/seed-data/workflows.ts +231 -84
  116. package/src/lib/data/seed.ts +55 -14
  117. package/src/lib/data/tables.ts +417 -0
  118. package/src/lib/db/bootstrap.ts +227 -0
  119. package/src/lib/db/index.ts +9 -0
  120. package/src/lib/db/migrations/0019_add_tables_feature.sql +160 -0
  121. package/src/lib/db/migrations/0020_add_table_triggers.sql +19 -0
  122. package/src/lib/db/migrations/0021_add_row_history.sql +15 -0
  123. package/src/lib/db/schema.ts +368 -0
  124. package/src/lib/snapshots/auto-backup.ts +132 -0
  125. package/src/lib/snapshots/retention.ts +64 -0
  126. package/src/lib/snapshots/snapshot-manager.ts +429 -0
  127. package/src/lib/tables/computed.ts +61 -0
  128. package/src/lib/tables/context-builder.ts +139 -0
  129. package/src/lib/tables/formula-engine.ts +415 -0
  130. package/src/lib/tables/history.ts +115 -0
  131. package/src/lib/tables/import.ts +343 -0
  132. package/src/lib/tables/query-builder.ts +152 -0
  133. package/src/lib/tables/trigger-evaluator.ts +146 -0
  134. package/src/lib/tables/types.ts +141 -0
  135. package/src/lib/tables/validation.ts +119 -0
  136. package/src/lib/utils/stagent-paths.ts +20 -0
  137. package/src/lib/workflows/types.ts +1 -1
  138. package/tsconfig.json +3 -1
  139. /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
+ }