thatcher 1.0.4 → 1.0.6
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/package.json +18 -1
- package/src/app/api/debug/[[...path]]/route.js +217 -0
- package/src/app/api/formula/[[...path]]/route.js +142 -0
- package/src/cli.js +0 -1
- package/src/config/spec-helpers.js +6 -0
- package/src/engine.server.js +2 -2
- package/src/index.js +21 -11
- package/src/lib/auth-middleware.js +4 -2
- package/src/lib/busybase-adapter.js +162 -0
- package/src/lib/config-field-helpers.js +1 -1
- package/src/lib/config-generator-engine.js +24 -0
- package/src/lib/crud-handlers.js +21 -1
- package/src/lib/database-core.js +4 -7
- package/src/lib/database-migrations.js +254 -0
- package/src/lib/date-utils.js +164 -0
- package/src/lib/debug-registry.js +165 -0
- package/src/lib/error-handler.js +78 -0
- package/src/lib/export-sink.js +226 -0
- package/src/lib/field-iterator.js +126 -0
- package/src/lib/hyperformula-service.js +304 -0
- package/src/lib/metrics-collector.js +196 -0
- package/src/lib/observability-bootstrap.js +121 -0
- package/src/lib/perf-profiler.js +238 -0
- package/src/lib/query-engine.js +47 -0
- package/src/lib/query-string-adapter.js +97 -2
- package/src/lib/request-tracing.js +216 -0
- package/src/lib/response-formatter.js +0 -2
- package/src/lib/route-resolver.js +10 -0
- package/src/lib/status-helpers.js +128 -0
- package/src/lib/tracing.js +287 -0
- package/src/lib/utils.js +81 -0
- package/src/lib/validate.js +124 -1
- package/src/lib/xstate-workflow-engine.js +478 -0
- package/src/plugins/index.js +27 -0
- package/src/server/server.js +23 -4
- package/src/services/permission.service.js +6 -2
- package/src/ui/advanced-search-renderer.js +3 -3
- package/src/ui/advanced-widgets.js +4 -4
- package/src/ui/common-handlers.js +4 -2
- package/src/ui/dashboard-renderer.js +4 -4
- package/src/ui/engagement-cards.js +10 -10
- package/src/ui/engagement-grid-renderer.js +11 -8
- package/src/ui/entity-renderer.js +4 -2
- package/src/ui/event-delegation.js +348 -8
- package/src/ui/file-dialogs.js +9 -9
- package/src/ui/format-helpers.js +37 -3
- package/src/ui/highlight-threading-renderer.js +9 -9
- package/src/ui/job-management-renderer.js +6 -6
- package/src/ui/monitoring-dashboard-client.js +17 -3
- package/src/ui/notifications-renderer.js +6 -7
- package/src/ui/page-handler.js +9 -0
- package/src/ui/render-helpers.js +8 -1
- package/src/ui/review-comparison-renderer.js +1 -1
- package/src/ui/review-detail-renderer.js +6 -6
- package/src/ui/review-mwr-renderer.js +13 -10
- package/src/ui/review-widgets.js +5 -5
- package/src/ui/rfi-detail-renderer.js +9 -9
- package/src/ui/settings-renderer-advanced.js +13 -13
- package/src/ui/settings-renderer-teams.js +10 -9
- package/src/ui/settings-renderer.js +25 -24
- package/src/ui/spacing-system.js +8 -4
- package/src/ui/widgets.js +2 -2
package/package.json
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thatcher",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "A config-driven application framework for building data-intensive web apps without code. 100% feature-complete extraction from moonlanding.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./engine.server": "./src/engine.server.js",
|
|
10
|
+
"./lib/*": "./src/lib/*.js",
|
|
11
|
+
"./lib/*.js": "./src/lib/*.js",
|
|
12
|
+
"./ui/*": "./src/ui/*.js",
|
|
13
|
+
"./ui/*.js": "./src/ui/*.js",
|
|
14
|
+
"./config/*": "./src/config/*.js",
|
|
15
|
+
"./config/*.js": "./src/config/*.js",
|
|
16
|
+
"./services/*": "./src/services/*.js",
|
|
17
|
+
"./services/*.js": "./src/services/*.js",
|
|
18
|
+
"./migration/*": "./src/migration/*.js",
|
|
19
|
+
"./validation/*": "./src/validation/*.js",
|
|
20
|
+
"./adapters/*": "./src/adapters/*.js",
|
|
21
|
+
"./plugins/*": "./src/plugins/*.js",
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
7
24
|
"bin": {
|
|
8
25
|
"thatcher": "src/cli.js",
|
|
9
26
|
"thatcher-dev": "src/cli.js"
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug API Routes - Comprehensive observability endpoints
|
|
3
|
+
* Exposes all subsystem state as JSON for debugging and monitoring
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { tracer } from '@/lib/tracing.js';
|
|
7
|
+
import { perfProfiler } from '@/lib/perf-profiler.js';
|
|
8
|
+
import { debugRegistry } from '@/lib/debug-registry.js';
|
|
9
|
+
import { getExportSink } from '@/lib/export-sink.js';
|
|
10
|
+
import { getXStateWorkflowEngine } from '@/lib/xstate-workflow-engine.js';
|
|
11
|
+
import { getHyperFormulaService } from '@/lib/hyperformula-service.js';
|
|
12
|
+
import { getBusyBaseAdapter } from '@/lib/busybase-adapter.js';
|
|
13
|
+
import { hookEngine } from '@/lib/hook-engine.js';
|
|
14
|
+
import { createLogger } from '@/lib/logger.js';
|
|
15
|
+
|
|
16
|
+
const logger = createLogger('[DebugAPI]');
|
|
17
|
+
|
|
18
|
+
function jsonResponse(body, status = 200) {
|
|
19
|
+
return new Response(JSON.stringify(body, null, 2), {
|
|
20
|
+
status,
|
|
21
|
+
headers: { 'Content-Type': 'application/json' },
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function errorResponse(message, status = 400) {
|
|
26
|
+
return jsonResponse({ error: message }, status);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function GET(request) {
|
|
30
|
+
const url = new URL(request.url);
|
|
31
|
+
const path = url.pathname;
|
|
32
|
+
const params = url.searchParams;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
if (path === '/api/debug') {
|
|
36
|
+
return jsonResponse({
|
|
37
|
+
subsystems: {
|
|
38
|
+
tracing: tracer.getStats(),
|
|
39
|
+
performance: perfProfiler.getAllStats().length,
|
|
40
|
+
xstate: getXStateWorkflowEngine()?.getStats() || null,
|
|
41
|
+
hyperformula: getHyperFormulaService()?.getStats() || null,
|
|
42
|
+
busybase: getBusyBaseAdapter()?.getStats() || null,
|
|
43
|
+
hooks: hookEngine.stats(),
|
|
44
|
+
export: getExportSink()?.getStats() || null,
|
|
45
|
+
health: debugRegistry.health(),
|
|
46
|
+
},
|
|
47
|
+
timestamp: Date.now(),
|
|
48
|
+
uptime: process.uptime(),
|
|
49
|
+
memory: process.memoryUsage(),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (path === '/api/debug/traces') {
|
|
54
|
+
const limit = parseInt(params.get('limit') || '100');
|
|
55
|
+
const status = params.get('status');
|
|
56
|
+
const minDuration = parseFloat(params.get('duration'));
|
|
57
|
+
|
|
58
|
+
let traces = tracer.getRecentTraces(limit * 10);
|
|
59
|
+
|
|
60
|
+
if (status) {
|
|
61
|
+
traces = traces.filter(t => t.status === status);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (minDuration) {
|
|
65
|
+
traces = traces.filter(t => t.duration && t.duration > minDuration);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return jsonResponse({ traces: traces.slice(-limit), count: traces.length });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (path.startsWith('/api/debug/traces/')) {
|
|
72
|
+
const traceId = path.split('/api/debug/traces/')[1];
|
|
73
|
+
const trace = tracer.getTraceById(traceId);
|
|
74
|
+
if (!trace) return errorResponse(`Trace "${traceId}" not found`, 404);
|
|
75
|
+
return jsonResponse(trace);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (path === '/api/debug/formulas') {
|
|
79
|
+
const service = getHyperFormulaService();
|
|
80
|
+
if (!service) return errorResponse('HyperFormula not initialized', 503);
|
|
81
|
+
return jsonResponse({
|
|
82
|
+
sheets: service.listSheets(),
|
|
83
|
+
stats: service.getStats(),
|
|
84
|
+
log: service.getStats().evaluationLogSize,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (path.startsWith('/api/debug/formulas/')) {
|
|
89
|
+
const service = getHyperFormulaService();
|
|
90
|
+
if (!service) return errorResponse('HyperFormula not initialized', 503);
|
|
91
|
+
const sheetName = decodeURIComponent(path.split('/api/debug/formulas/')[1]);
|
|
92
|
+
const info = service.getSheetInfo(sheetName);
|
|
93
|
+
if (!info) return errorResponse(`Sheet "${sheetName}" not found`, 404);
|
|
94
|
+
return jsonResponse({
|
|
95
|
+
...info,
|
|
96
|
+
values: service.getSheetValues(sheetName),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (path === '/api/debug/machines') {
|
|
101
|
+
const engine = getXStateWorkflowEngine();
|
|
102
|
+
if (!engine) return errorResponse('XState engine not initialized', 503);
|
|
103
|
+
return jsonResponse({
|
|
104
|
+
actors: engine.getActiveActors(),
|
|
105
|
+
machines: engine.getCachedMachines(),
|
|
106
|
+
stats: engine.getStats(),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (path.startsWith('/api/debug/machines/')) {
|
|
111
|
+
const engine = getXStateWorkflowEngine();
|
|
112
|
+
if (!engine) return errorResponse('XState engine not initialized', 503);
|
|
113
|
+
const actorKey = path.split('/api/debug/machines/')[1];
|
|
114
|
+
const snapshot = engine.getSnapshot(...actorKey.split(':'));
|
|
115
|
+
if (!snapshot) return errorResponse(`Actor "${actorKey}" not found`, 404);
|
|
116
|
+
return jsonResponse({
|
|
117
|
+
actorKey,
|
|
118
|
+
value: snapshot.value,
|
|
119
|
+
context: snapshot.context,
|
|
120
|
+
done: snapshot.done,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (path === '/api/debug/hooks') {
|
|
125
|
+
return jsonResponse({
|
|
126
|
+
stats: hookEngine.stats(),
|
|
127
|
+
events: Object.keys(hookEngine.stats()),
|
|
128
|
+
totalHandlers: Object.values(hookEngine.stats()).reduce((a, b) => a + b, 0),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (path === '/api/debug/database') {
|
|
133
|
+
const dbMonitor = globalThis.__dbMonitor;
|
|
134
|
+
const busybase = getBusyBaseAdapter();
|
|
135
|
+
|
|
136
|
+
return jsonResponse({
|
|
137
|
+
monitor: dbMonitor ? {
|
|
138
|
+
activeQueries: dbMonitor.activeQueries?.length || 0,
|
|
139
|
+
slowQueries: dbMonitor.slowQueries?.slice(-20) || [],
|
|
140
|
+
stats: dbMonitor.stats?.(),
|
|
141
|
+
} : null,
|
|
142
|
+
busybase: busybase ? busybase.getStats() : null,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (path === '/api/debug/realtime') {
|
|
147
|
+
const channels = globalThis.__channels || {};
|
|
148
|
+
return jsonResponse({
|
|
149
|
+
channels: Object.keys(channels),
|
|
150
|
+
subscribers: Object.entries(channels).map(([name, ch]) => ({
|
|
151
|
+
name,
|
|
152
|
+
subscriberCount: ch.subscribers?.length || 0,
|
|
153
|
+
})),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (path === '/api/debug/health') {
|
|
158
|
+
return jsonResponse(debugRegistry.health());
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (path === '/api/debug/metrics') {
|
|
162
|
+
const metrics = globalThis.__metrics;
|
|
163
|
+
return jsonResponse({
|
|
164
|
+
metrics: metrics ? {
|
|
165
|
+
requests: metrics.requests?.slice(-100) || [],
|
|
166
|
+
slowQueries: metrics.slowQueries || [],
|
|
167
|
+
errors: metrics.errors?.slice(-50) || [],
|
|
168
|
+
stats: metrics.getStats?.(),
|
|
169
|
+
} : null,
|
|
170
|
+
performance: perfProfiler.getAllStats(),
|
|
171
|
+
slowOperations: perfProfiler.getSlowOperations(),
|
|
172
|
+
regressions: perfProfiler.detectRegressions(),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (path === '/api/debug/system') {
|
|
177
|
+
return jsonResponse({
|
|
178
|
+
pid: process.pid,
|
|
179
|
+
uptime: process.uptime(),
|
|
180
|
+
memory: process.memoryUsage(),
|
|
181
|
+
cpuUsage: process.cpuUsage(),
|
|
182
|
+
nodeVersion: process.version,
|
|
183
|
+
platform: process.platform,
|
|
184
|
+
arch: process.arch,
|
|
185
|
+
env: {
|
|
186
|
+
NODE_ENV: process.env.NODE_ENV,
|
|
187
|
+
DEBUG: process.env.DEBUG,
|
|
188
|
+
PORT: process.env.PORT,
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (path === '/api/debug/perf') {
|
|
194
|
+
return jsonResponse({
|
|
195
|
+
stats: perfProfiler.getAllStats(),
|
|
196
|
+
slow: perfProfiler.getSlowOperations(),
|
|
197
|
+
regressions: perfProfiler.detectRegressions(),
|
|
198
|
+
alerts: perfProfiler.getAlerts(),
|
|
199
|
+
thresholds: Object.fromEntries(perfProfiler._thresholds),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (path === '/api/debug/export') {
|
|
204
|
+
const sink = getExportSink();
|
|
205
|
+
return jsonResponse(sink ? sink.getStats() : { enabled: false });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (path === '/api/debug/registry') {
|
|
209
|
+
return jsonResponse(debugRegistry.toJSON());
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return errorResponse(`Unknown debug endpoint: ${path}`, 404);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
logger.error('Debug API error', { error: error.message, path });
|
|
215
|
+
return errorResponse(error.message, 500);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formula API Routes - HyperFormula operations via HTTP
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { getHyperFormulaService, createHyperFormulaService } from '@/lib/hyperformula-service.js';
|
|
6
|
+
import { createLogger } from '@/lib/logger.js';
|
|
7
|
+
|
|
8
|
+
const logger = createLogger('[FormulaAPI]');
|
|
9
|
+
|
|
10
|
+
function jsonResponse(body, status = 200) {
|
|
11
|
+
return new Response(JSON.stringify(body), {
|
|
12
|
+
status,
|
|
13
|
+
headers: { 'Content-Type': 'application/json' },
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function errorResponse(message, status = 400) {
|
|
18
|
+
return jsonResponse({ error: message }, status);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function ensureService() {
|
|
22
|
+
let service = getHyperFormulaService();
|
|
23
|
+
if (!service) {
|
|
24
|
+
service = await createHyperFormulaService();
|
|
25
|
+
}
|
|
26
|
+
return service;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function GET(request) {
|
|
30
|
+
const url = new URL(request.url);
|
|
31
|
+
const path = url.pathname;
|
|
32
|
+
|
|
33
|
+
if (path === '/api/formula/sheets') {
|
|
34
|
+
const service = await ensureService();
|
|
35
|
+
return jsonResponse({ sheets: service.listSheets(), stats: service.getStats() });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (path.startsWith('/api/formula/sheet/')) {
|
|
39
|
+
const sheetName = decodeURIComponent(path.split('/api/formula/sheet/')[1]);
|
|
40
|
+
const service = await ensureService();
|
|
41
|
+
const info = service.getSheetInfo(sheetName);
|
|
42
|
+
if (!info) return errorResponse(`Sheet "${sheetName}" not found`, 404);
|
|
43
|
+
|
|
44
|
+
return jsonResponse({
|
|
45
|
+
...info,
|
|
46
|
+
values: service.getSheetValues(sheetName),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return errorResponse('Not found', 404);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function POST(request) {
|
|
54
|
+
const url = new URL(request.url);
|
|
55
|
+
const path = url.pathname;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const body = await request.json();
|
|
59
|
+
|
|
60
|
+
if (path === '/api/formula/evaluate') {
|
|
61
|
+
const service = await ensureService();
|
|
62
|
+
|
|
63
|
+
if (body.formula) {
|
|
64
|
+
const { result, duration } = service.evaluateFormula(body.formula);
|
|
65
|
+
return jsonResponse({ formula: body.formula, result, durationMs: Math.round(duration * 100) / 100 });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (body.sheet && body.cell && body.value !== undefined) {
|
|
69
|
+
service.setCellContents(body.sheet, body.cell, body.value);
|
|
70
|
+
return jsonResponse({ success: true, sheet: body.sheet, cell: body.cell });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return errorResponse('Missing formula or sheet/cell/value', 400);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (path === '/api/formula/sheet') {
|
|
77
|
+
const service = await ensureService();
|
|
78
|
+
|
|
79
|
+
if (body.action === 'create') {
|
|
80
|
+
const { name, sheetId } = service.createSheet(body.name, body.data);
|
|
81
|
+
return jsonResponse({ name, sheetId }, 201);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (body.action === 'remove') {
|
|
85
|
+
service.removeSheet(body.name);
|
|
86
|
+
return jsonResponse({ success: true });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (body.action === 'rename') {
|
|
90
|
+
service.renameSheet(body.oldName, body.newName);
|
|
91
|
+
return jsonResponse({ success: true });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (body.action === 'setContent') {
|
|
95
|
+
service.setSheetContent(body.name, body.data);
|
|
96
|
+
return jsonResponse({ success: true });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return errorResponse('Unknown action. Use: create, remove, rename, setContent', 400);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (path === '/api/formula/validate') {
|
|
103
|
+
const service = await ensureService();
|
|
104
|
+
|
|
105
|
+
if (!body.formula) {
|
|
106
|
+
return errorResponse('Missing formula', 400);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const result = service.validateFormula(body.formula);
|
|
110
|
+
return jsonResponse(result);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (path === '/api/formula/cell') {
|
|
114
|
+
const service = await ensureService();
|
|
115
|
+
|
|
116
|
+
if (!body.sheet || !body.cell) {
|
|
117
|
+
return errorResponse('Missing sheet or cell', 400);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const value = service.getCellValue(body.sheet, body.cell);
|
|
121
|
+
const formula = service.getCellFormula(body.sheet, body.cell);
|
|
122
|
+
|
|
123
|
+
return jsonResponse({ sheet: body.sheet, cell: body.cell, value, formula });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (path === '/api/formula/dependencies') {
|
|
127
|
+
const service = await ensureService();
|
|
128
|
+
|
|
129
|
+
if (!body.sheet || !body.cell) {
|
|
130
|
+
return errorResponse('Missing sheet or cell', 400);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const deps = service.getDependencies(body.sheet, body.cell);
|
|
134
|
+
return jsonResponse({ sheet: body.sheet, cell: body.cell, dependencies: deps });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return errorResponse('Not found', 404);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
logger.error('Formula API error', { error: error.message });
|
|
140
|
+
return errorResponse(error.message, 500);
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
* @returns {object|null} Entity specification or null if not found
|
|
11
11
|
*/
|
|
12
12
|
export function getSpec(name, configEngine) {
|
|
13
|
+
// Callers across the framework invoke getSpec(name) without threading the engine
|
|
14
|
+
// (moon's getSpec self-resolved it). Fall back to the singleton when omitted.
|
|
15
|
+
if (!configEngine) {
|
|
16
|
+
const g = globalThis.__thatcherConfigEngine;
|
|
17
|
+
if (g) configEngine = g;
|
|
18
|
+
}
|
|
13
19
|
try {
|
|
14
20
|
return configEngine.generateEntitySpec(name);
|
|
15
21
|
} catch (error) {
|
package/src/engine.server.js
CHANGED
|
@@ -7,8 +7,8 @@ import { Lucia } from 'lucia';
|
|
|
7
7
|
import { BetterSqlite3Adapter } from '@lucia-auth/adapter-sqlite';
|
|
8
8
|
import { Google } from 'arctic';
|
|
9
9
|
import bcrypt from 'bcrypt';
|
|
10
|
-
import { getDatabase } from './database-core.js';
|
|
11
|
-
import { buildConfig, hasGoogleAuth } from '
|
|
10
|
+
import { getDatabase } from './lib/database-core.js';
|
|
11
|
+
import { buildConfig, hasGoogleAuth } from './config/env.js';
|
|
12
12
|
|
|
13
13
|
let _lucia = null;
|
|
14
14
|
let _google = null;
|
package/src/index.js
CHANGED
|
@@ -116,9 +116,19 @@ export class Thatcher {
|
|
|
116
116
|
throw new Error('No configuration provided. Supply config path or object.');
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
// Init config engine
|
|
120
|
-
|
|
119
|
+
// Init config engine AND set the module-level singleton that getConfigEngineSync()
|
|
120
|
+
// returns. The server, plugin loader, and generateEntitySpec all read the singleton
|
|
121
|
+
// via getConfigEngineSync(), so a locally-newed engine alone leaves them uninitialized.
|
|
122
|
+
const engineMod = await import(resolveModule('./lib/config-generator-engine.js'));
|
|
123
|
+
const { ConfigGeneratorEngine, setConfigEngine } = engineMod;
|
|
121
124
|
_configEngine = new ConfigGeneratorEngine(masterConfig);
|
|
125
|
+
setConfigEngine(_configEngine);
|
|
126
|
+
this.configEngine = _configEngine;
|
|
127
|
+
// Cross-module-instance bridge: tsx can load config-generator-engine.js as two
|
|
128
|
+
// distinct module instances (static import vs resolveModule file:// URL), so the
|
|
129
|
+
// module-level singleton set above is not always visible to server.js. globalThis
|
|
130
|
+
// is the one registry both instances share.
|
|
131
|
+
globalThis.__thatcherConfigEngine = _configEngine;
|
|
122
132
|
|
|
123
133
|
// Debug exposure
|
|
124
134
|
if (globalThis.__debug__) {
|
|
@@ -220,7 +230,7 @@ export class Thatcher {
|
|
|
220
230
|
const port = opts.port || this.options.server.port;
|
|
221
231
|
const host = opts.host || this.options.server.host;
|
|
222
232
|
|
|
223
|
-
const { createServer } = await import(resolveModule('./
|
|
233
|
+
const { createServer } = await import(resolveModule('./server/server.js'));
|
|
224
234
|
_server = createServer({
|
|
225
235
|
thatcher: this,
|
|
226
236
|
config: this.config,
|
|
@@ -280,44 +290,44 @@ export class Thatcher {
|
|
|
280
290
|
// === CRUD operations ===
|
|
281
291
|
|
|
282
292
|
async list(entity, where = {}, opts = {}) {
|
|
283
|
-
const { list } = await import(resolveModule('./
|
|
293
|
+
const { list } = await import(resolveModule('./lib/query-engine.js'));
|
|
284
294
|
return list(entity, where, opts);
|
|
285
295
|
}
|
|
286
296
|
|
|
287
297
|
async get(entity, id) {
|
|
288
|
-
const { get } = await import(resolveModule('./
|
|
298
|
+
const { get } = await import(resolveModule('./lib/query-engine.js'));
|
|
289
299
|
return get(entity, id);
|
|
290
300
|
}
|
|
291
301
|
|
|
292
302
|
async create(entity, data, user) {
|
|
293
|
-
const { create } = await import(resolveModule('./
|
|
303
|
+
const { create } = await import(resolveModule('./lib/query-engine-write.js'));
|
|
294
304
|
return create(entity, data, user);
|
|
295
305
|
}
|
|
296
306
|
|
|
297
307
|
async update(entity, id, data, user) {
|
|
298
|
-
const { update } = await import(resolveModule('./
|
|
308
|
+
const { update } = await import(resolveModule('./lib/query-engine-write.js'));
|
|
299
309
|
return update(entity, id, data, user);
|
|
300
310
|
}
|
|
301
311
|
|
|
302
312
|
async delete(entity, id) {
|
|
303
|
-
const { remove } = await import(resolveModule('./
|
|
313
|
+
const { remove } = await import(resolveModule('./lib/query-engine-write.js'));
|
|
304
314
|
return remove(entity, id);
|
|
305
315
|
}
|
|
306
316
|
|
|
307
317
|
async search(entity, query, where = {}, opts = {}) {
|
|
308
|
-
const { search } = await import(resolveModule('./
|
|
318
|
+
const { search } = await import(resolveModule('./lib/query-engine.js'));
|
|
309
319
|
return search(entity, query, where, opts);
|
|
310
320
|
}
|
|
311
321
|
|
|
312
322
|
// === Workflow ===
|
|
313
323
|
|
|
314
324
|
async transition(entityType, entityId, workflowName, toState, user, reason = '') {
|
|
315
|
-
const { transition } = await import(resolveModule('./
|
|
325
|
+
const { transition } = await import(resolveModule('./lib/workflow-engine.js'));
|
|
316
326
|
return transition(entityType, entityId, workflowName, toState, user, reason);
|
|
317
327
|
}
|
|
318
328
|
|
|
319
329
|
async getAvailableTransitions(workflowName, currentState, user, record = null) {
|
|
320
|
-
const { getAvailableTransitions } = await import(resolveModule('./
|
|
330
|
+
const { getAvailableTransitions } = await import(resolveModule('./lib/workflow-engine.js'));
|
|
321
331
|
return getAvailableTransitions(workflowName, currentState, user, record);
|
|
322
332
|
}
|
|
323
333
|
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* Adapted from moonlanding/src/lib/auth-middleware.js
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { getUser,
|
|
7
|
-
import { getSpec } from '
|
|
6
|
+
import { getUser, getLucia } from '../engine.server.js';
|
|
7
|
+
import { getSpec } from '../config/spec-helpers.js';
|
|
8
8
|
import { can } from '../services/permission.service.js';
|
|
9
9
|
import { UnauthorizedError, PermissionError, NotFoundError } from './error-handler.js';
|
|
10
10
|
|
|
@@ -26,6 +26,8 @@ const actionMap = {
|
|
|
26
26
|
export function getSessionToken(req) {
|
|
27
27
|
const cookieHeader = req?.headers?.cookie || '';
|
|
28
28
|
if (!cookieHeader) return null;
|
|
29
|
+
let lucia = null;
|
|
30
|
+
try { lucia = getLucia(); } catch { /* auth not initialized yet */ }
|
|
29
31
|
const cookieName = lucia?.sessionCookieName || 'thatcher_session';
|
|
30
32
|
const match = cookieHeader.split(';').find(c => c.trim().startsWith(cookieName + '='));
|
|
31
33
|
if (!match) return null;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BusyBase Database Adapter - Supabase-compatible database layer for Thatcher
|
|
3
|
+
* Supports embedded mode (in-process) and remote mode (HTTP client)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createLogger } from './logger.js';
|
|
7
|
+
import { hookEngine } from './hook-engine.js';
|
|
8
|
+
|
|
9
|
+
const logger = createLogger('[BusyBase]');
|
|
10
|
+
|
|
11
|
+
let _bbInstance = null;
|
|
12
|
+
let _bbMode = null;
|
|
13
|
+
let _bbConfig = null;
|
|
14
|
+
let _realtimeChannels = new Map();
|
|
15
|
+
|
|
16
|
+
export class BusyBaseAdapter {
|
|
17
|
+
constructor(config = {}) {
|
|
18
|
+
this.config = {
|
|
19
|
+
mode: config.mode || 'embedded',
|
|
20
|
+
url: config.url || 'http://localhost:54321',
|
|
21
|
+
project: config.project || 'local',
|
|
22
|
+
dir: config.dir || 'busybase_data',
|
|
23
|
+
hooks: config.hooks !== false,
|
|
24
|
+
...config,
|
|
25
|
+
};
|
|
26
|
+
this.initialized = false;
|
|
27
|
+
this._client = null;
|
|
28
|
+
this._embedded = null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async init() {
|
|
32
|
+
if (this.initialized) return this;
|
|
33
|
+
|
|
34
|
+
if (this.config.mode === 'embedded') {
|
|
35
|
+
const { createEmbedded } = await import('busybase/embedded');
|
|
36
|
+
this._embedded = await createEmbedded({ dir: this.config.dir });
|
|
37
|
+
this._client = this._embedded;
|
|
38
|
+
logger.info('Embedded mode initialized', { dir: this.config.dir });
|
|
39
|
+
} else {
|
|
40
|
+
const { default: BB, createClient } = await import('busybase');
|
|
41
|
+
this._client = BB(this.config.url, this.config.project);
|
|
42
|
+
logger.info('Remote mode initialized', { url: this.config.url });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (this.config.hooks) {
|
|
46
|
+
this._registerBusyBaseHooks();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
this.initialized = true;
|
|
50
|
+
_bbInstance = this;
|
|
51
|
+
_bbMode = this.config.mode;
|
|
52
|
+
_bbConfig = this.config;
|
|
53
|
+
|
|
54
|
+
if (globalThis.__debug__) {
|
|
55
|
+
globalThis.__debug__.expose('busybase', {
|
|
56
|
+
mode: () => _bbMode,
|
|
57
|
+
config: () => _bbConfig,
|
|
58
|
+
client: () => this._client,
|
|
59
|
+
channels: () => Array.from(_realtimeChannels.keys()),
|
|
60
|
+
stats: () => this.getStats(),
|
|
61
|
+
}, 'BusyBase Adapter');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return this;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_registerBusyBaseHooks() {
|
|
68
|
+
const hookMap = {
|
|
69
|
+
beforeInsert: 'beforeInsert',
|
|
70
|
+
afterInsert: 'afterInsert',
|
|
71
|
+
beforeUpdate: 'beforeUpdate',
|
|
72
|
+
afterUpdate: 'afterUpdate',
|
|
73
|
+
beforeDelete: 'beforeDelete',
|
|
74
|
+
afterDelete: 'afterDelete',
|
|
75
|
+
beforeSelect: 'beforeSelect',
|
|
76
|
+
afterSelect: 'afterSelect',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
for (const [bbHook, thEvent] of Object.entries(hookMap)) {
|
|
80
|
+
if (globalThis.__busybase_hooks__) {
|
|
81
|
+
globalThis.__busybase_hooks__[bbHook] = async (table, data, ...rest) => {
|
|
82
|
+
const context = { table, data, ...rest };
|
|
83
|
+
await hookEngine.execute(`busybase:${thEvent}`, context);
|
|
84
|
+
return context.data;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async subscribe(table, callback, event = '*') {
|
|
91
|
+
if (this.config.mode === 'embedded') {
|
|
92
|
+
logger.warn('Realtime subscriptions not supported in embedded mode');
|
|
93
|
+
return () => {};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const channelName = `${table}-${event}-${Date.now()}`;
|
|
97
|
+
const channel = this._client.channel(channelName)
|
|
98
|
+
.on('postgres_changes', { event, schema: 'public', table }, (payload) => {
|
|
99
|
+
callback(payload);
|
|
100
|
+
})
|
|
101
|
+
.subscribe((status) => {
|
|
102
|
+
logger.info('Channel subscription', { table, event, status });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
_realtimeChannels.set(channelName, { channel, table, event, callback });
|
|
106
|
+
|
|
107
|
+
return () => {
|
|
108
|
+
channel.unsubscribe();
|
|
109
|
+
_realtimeChannels.delete(channelName);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async unsubscribeAll() {
|
|
114
|
+
for (const [name, { channel }] of _realtimeChannels.entries()) {
|
|
115
|
+
channel.unsubscribe();
|
|
116
|
+
}
|
|
117
|
+
_realtimeChannels.clear();
|
|
118
|
+
if (this._client?.removeAllChannels) {
|
|
119
|
+
this._client.removeAllChannels();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
getClient() {
|
|
124
|
+
return this._client;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
getStats() {
|
|
128
|
+
return {
|
|
129
|
+
mode: _bbMode,
|
|
130
|
+
channels: _realtimeChannels.size,
|
|
131
|
+
initialized: this.initialized,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async close() {
|
|
136
|
+
await this.unsubscribeAll();
|
|
137
|
+
_bbInstance = null;
|
|
138
|
+
_bbMode = null;
|
|
139
|
+
_bbConfig = null;
|
|
140
|
+
this.initialized = false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function createBusyBaseAdapter(config = {}) {
|
|
145
|
+
const adapter = new BusyBaseAdapter(config);
|
|
146
|
+
await adapter.init();
|
|
147
|
+
return adapter;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function getBusyBaseAdapter() {
|
|
151
|
+
return _bbInstance;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function getBusyBaseClient() {
|
|
155
|
+
return _bbInstance?._client;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function isBusyBaseAvailable() {
|
|
159
|
+
return _bbInstance?.initialized === true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export default BusyBaseAdapter;
|