thatcher 1.0.4 → 1.0.5

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 (62) hide show
  1. package/package.json +18 -1
  2. package/src/app/api/debug/[[...path]]/route.js +217 -0
  3. package/src/app/api/formula/[[...path]]/route.js +142 -0
  4. package/src/cli.js +0 -1
  5. package/src/config/spec-helpers.js +6 -0
  6. package/src/engine.server.js +2 -2
  7. package/src/index.js +21 -11
  8. package/src/lib/auth-middleware.js +4 -2
  9. package/src/lib/busybase-adapter.js +162 -0
  10. package/src/lib/config-field-helpers.js +1 -1
  11. package/src/lib/config-generator-engine.js +24 -0
  12. package/src/lib/crud-handlers.js +21 -1
  13. package/src/lib/database-core.js +4 -7
  14. package/src/lib/database-migrations.js +254 -0
  15. package/src/lib/date-utils.js +164 -0
  16. package/src/lib/debug-registry.js +165 -0
  17. package/src/lib/error-handler.js +78 -0
  18. package/src/lib/export-sink.js +226 -0
  19. package/src/lib/field-iterator.js +126 -0
  20. package/src/lib/hyperformula-service.js +304 -0
  21. package/src/lib/metrics-collector.js +196 -0
  22. package/src/lib/observability-bootstrap.js +121 -0
  23. package/src/lib/perf-profiler.js +238 -0
  24. package/src/lib/query-engine.js +47 -0
  25. package/src/lib/query-string-adapter.js +97 -2
  26. package/src/lib/request-tracing.js +216 -0
  27. package/src/lib/response-formatter.js +0 -2
  28. package/src/lib/route-resolver.js +10 -0
  29. package/src/lib/status-helpers.js +128 -0
  30. package/src/lib/tracing.js +287 -0
  31. package/src/lib/utils.js +81 -0
  32. package/src/lib/validate.js +124 -1
  33. package/src/lib/xstate-workflow-engine.js +478 -0
  34. package/src/plugins/index.js +27 -0
  35. package/src/server/server.js +23 -4
  36. package/src/services/permission.service.js +6 -2
  37. package/src/ui/advanced-search-renderer.js +3 -3
  38. package/src/ui/advanced-widgets.js +4 -4
  39. package/src/ui/common-handlers.js +4 -2
  40. package/src/ui/dashboard-renderer.js +4 -4
  41. package/src/ui/engagement-cards.js +10 -10
  42. package/src/ui/engagement-grid-renderer.js +11 -8
  43. package/src/ui/entity-renderer.js +4 -2
  44. package/src/ui/event-delegation.js +348 -8
  45. package/src/ui/file-dialogs.js +9 -9
  46. package/src/ui/format-helpers.js +37 -3
  47. package/src/ui/highlight-threading-renderer.js +9 -9
  48. package/src/ui/job-management-renderer.js +6 -6
  49. package/src/ui/monitoring-dashboard-client.js +17 -3
  50. package/src/ui/notifications-renderer.js +6 -7
  51. package/src/ui/page-handler.js +9 -0
  52. package/src/ui/render-helpers.js +7 -1
  53. package/src/ui/review-comparison-renderer.js +1 -1
  54. package/src/ui/review-detail-renderer.js +6 -6
  55. package/src/ui/review-mwr-renderer.js +13 -10
  56. package/src/ui/review-widgets.js +5 -5
  57. package/src/ui/rfi-detail-renderer.js +9 -9
  58. package/src/ui/settings-renderer-advanced.js +13 -13
  59. package/src/ui/settings-renderer-teams.js +10 -9
  60. package/src/ui/settings-renderer.js +25 -24
  61. package/src/ui/spacing-system.js +8 -4
  62. 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.4",
3
+ "version": "1.0.5",
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
@@ -12,7 +12,6 @@
12
12
  */
13
13
 
14
14
  import { startThatcher, Thatcher } from './index.js';
15
- import { command } from 'commander'; // We'll use simple args parsing
16
15
  import * as readline from 'readline';
17
16
 
18
17
  const args = process.argv.slice(2);
@@ -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) {
@@ -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 '../config/env.js';
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
- const { ConfigGeneratorEngine } = await import(resolveModule('./src/lib/config-generator-engine.js'));
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('./src/server/server.js'));
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('./src/lib/query-engine.js'));
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('./src/lib/query-engine.js'));
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('./src/lib/query-engine-write.js'));
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('./src/lib/query-engine-write.js'));
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('./src/lib/query-engine-write.js'));
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('./src/lib/query-engine.js'));
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('./src/lib/workflow-engine.js'));
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('./src/lib/workflow-engine.js'));
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, lucia } from '../engine.server.js'; // Will need to adapt
7
- import { getSpec } from './spec-helpers.js';
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;