thatcher 1.0.4
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/LICENSE +21 -0
- package/README.md +398 -0
- package/package.json +73 -0
- package/src/adapters/google-auth.js +148 -0
- package/src/adapters/google-drive.js +209 -0
- package/src/app/api/[entity]/[[...path]]/route.js +33 -0
- package/src/app/api/audit/dashboard/route.js +77 -0
- package/src/app/api/audit/logs/route.js +46 -0
- package/src/app/api/audit/permissions/[id]/route.js +37 -0
- package/src/app/api/audit/permissions/route.js +93 -0
- package/src/app/api/audit/permissions/stats/route.js +29 -0
- package/src/app/api/audit/route.js +79 -0
- package/src/app/api/audit/stats/route.js +18 -0
- package/src/app/api/auth/google/callback/route.js +94 -0
- package/src/app/api/auth/google/route.js +58 -0
- package/src/app/api/auth/login/route.js +121 -0
- package/src/app/api/auth/logout/route.js +52 -0
- package/src/app/api/auth/me/route.js +16 -0
- package/src/app/api/auth/mwr-bridge/route.js +77 -0
- package/src/app/api/auth/password-reset/route.js +65 -0
- package/src/app/api/cron/trigger/route.js +58 -0
- package/src/app/api/csrf-token/route.js +8 -0
- package/src/app/api/debug/config/route.js +22 -0
- package/src/app/api/debug/hooks/route.js +16 -0
- package/src/app/api/debug/plugins/route.js +20 -0
- package/src/app/api/debug/sqlite/route.js +21 -0
- package/src/app/api/debug/sync/route.js +29 -0
- package/src/app/api/debug/workflow/route.js +20 -0
- package/src/app/api/domains/[domain]/route.js +26 -0
- package/src/app/api/domains/route.js +21 -0
- package/src/app/api/email/allocate/batch/route.js +106 -0
- package/src/app/api/email/allocate/route.js +141 -0
- package/src/app/api/email/receive/route.js +158 -0
- package/src/app/api/email/route.js +3 -0
- package/src/app/api/email/send/route.js +77 -0
- package/src/app/api/email/unallocated/route.js +47 -0
- package/src/app/api/files/[id]/route.js +38 -0
- package/src/app/api/health/route.js +95 -0
- package/src/app/api/metrics/route.js +73 -0
- package/src/app/api/monitoring/dashboard/route.js +18 -0
- package/src/cli.js +243 -0
- package/src/config/config-loader.js +112 -0
- package/src/config/constants.js +127 -0
- package/src/config/env.js +164 -0
- package/src/config/spec-helpers.js +232 -0
- package/src/engine.server.js +212 -0
- package/src/index.js +368 -0
- package/src/lib/accessibility.js +162 -0
- package/src/lib/action-factory.js +34 -0
- package/src/lib/action-utils.js +21 -0
- package/src/lib/alert-manager.js +189 -0
- package/src/lib/api-error-wrapper.js +125 -0
- package/src/lib/api-helpers.js +53 -0
- package/src/lib/api.js +82 -0
- package/src/lib/audit-logger-enhanced.js +117 -0
- package/src/lib/audit-logger.js +193 -0
- package/src/lib/auth-middleware.js +102 -0
- package/src/lib/auth-route-helpers.js +83 -0
- package/src/lib/business-rules-engine.js +86 -0
- package/src/lib/compression.js +44 -0
- package/src/lib/config-field-helpers.js +91 -0
- package/src/lib/config-generator-engine.js +445 -0
- package/src/lib/config-helpers.js +120 -0
- package/src/lib/connection-guard.js +79 -0
- package/src/lib/crud-action-helpers.js +34 -0
- package/src/lib/crud-factory.js +83 -0
- package/src/lib/crud-handlers.js +244 -0
- package/src/lib/csrf-protection.js +63 -0
- package/src/lib/database-core.js +258 -0
- package/src/lib/database-migrations.js +96 -0
- package/src/lib/date-utils.js +159 -0
- package/src/lib/db-backup.js +97 -0
- package/src/lib/db-monitor.js +127 -0
- package/src/lib/domain-loader.js +82 -0
- package/src/lib/email-sender.js +100 -0
- package/src/lib/error-boundary.js +134 -0
- package/src/lib/error-handler.js +84 -0
- package/src/lib/error-recovery.js +190 -0
- package/src/lib/error-resilience.js +130 -0
- package/src/lib/errors.js +69 -0
- package/src/lib/events-engine.js +182 -0
- package/src/lib/field-iterator.js +50 -0
- package/src/lib/field-registry.js +68 -0
- package/src/lib/field-types.js +154 -0
- package/src/lib/generic-crud-handler.js +32 -0
- package/src/lib/health-monitor.js +134 -0
- package/src/lib/hook-engine.js +169 -0
- package/src/lib/hot-reload/cache-invalidator.js +115 -0
- package/src/lib/hot-reload/checkpoint.js +95 -0
- package/src/lib/hot-reload/debug-exposure.js +67 -0
- package/src/lib/hot-reload/directory-watcher.js +96 -0
- package/src/lib/hot-reload/index.js +50 -0
- package/src/lib/hot-reload/mutex.js +75 -0
- package/src/lib/hot-reload/promise-container.js +66 -0
- package/src/lib/hot-reload/route-wrapper.js +46 -0
- package/src/lib/hot-reload/safe-error.js +51 -0
- package/src/lib/hot-reload/supervisor.js +161 -0
- package/src/lib/hot-reload/timeout-wrapper.js +52 -0
- package/src/lib/http-methods-factory.js +25 -0
- package/src/lib/index-optimizer.js +96 -0
- package/src/lib/index.js +35 -0
- package/src/lib/list-data-transform.js +39 -0
- package/src/lib/log-aggregator.js +116 -0
- package/src/lib/logger.js +55 -0
- package/src/lib/metrics-collector.js +102 -0
- package/src/lib/minifier.js +19 -0
- package/src/lib/monitoring-init.js +67 -0
- package/src/lib/next-compat.js +80 -0
- package/src/lib/next-polyfills.js +135 -0
- package/src/lib/perf-monitor.js +91 -0
- package/src/lib/progress-components.js +181 -0
- package/src/lib/query-cache.js +126 -0
- package/src/lib/query-engine-write.js +221 -0
- package/src/lib/query-engine.js +399 -0
- package/src/lib/query-perf.js +117 -0
- package/src/lib/query-string-adapter.js +75 -0
- package/src/lib/realtime-server.js +67 -0
- package/src/lib/render-cache.js +61 -0
- package/src/lib/request-tracker.js +43 -0
- package/src/lib/resource-hints.js +29 -0
- package/src/lib/resource-monitor.js +117 -0
- package/src/lib/response-formatter.js +80 -0
- package/src/lib/route-helpers.js +33 -0
- package/src/lib/route-resolver.js +142 -0
- package/src/lib/safe-json.js +8 -0
- package/src/lib/server-bootstrap.js +71 -0
- package/src/lib/stage-pipeline.js +153 -0
- package/src/lib/state-protocol.js +171 -0
- package/src/lib/state-transport-client.js +169 -0
- package/src/lib/state-transport-reconnect.js +121 -0
- package/src/lib/state-transport-server.js +181 -0
- package/src/lib/static-server.js +97 -0
- package/src/lib/status-helpers.js +98 -0
- package/src/lib/universal-handler.js +7 -0
- package/src/lib/utils.js +93 -0
- package/src/lib/validate.js +197 -0
- package/src/lib/validation/business-validators.js +61 -0
- package/src/lib/validation/csrf.js +51 -0
- package/src/lib/validation/file-validators.js +34 -0
- package/src/lib/validation/format-validators.js +106 -0
- package/src/lib/validation/index.js +19 -0
- package/src/lib/validation/rate-limit.js +31 -0
- package/src/lib/validation/security-validators.js +78 -0
- package/src/lib/validation-middleware.js +133 -0
- package/src/lib/validators.js +105 -0
- package/src/lib/with-audit-logging.js +63 -0
- package/src/lib/with-error-handler.js +31 -0
- package/src/lib/workflow-engine.js +250 -0
- package/src/server/server.js +305 -0
- package/src/services/collaborator-role.service.js +205 -0
- package/src/services/email-sender.js +105 -0
- package/src/services/notification-engine.js +110 -0
- package/src/services/permission.service.js +181 -0
- package/src/ui/advanced-search-renderer.js +42 -0
- package/src/ui/advanced-widgets.js +47 -0
- package/src/ui/auth-pages.js +114 -0
- package/src/ui/auth-styles.js +53 -0
- package/src/ui/client.js +99 -0
- package/src/ui/collaboration-dialogs.js +31 -0
- package/src/ui/common-handlers.js +156 -0
- package/src/ui/component-engine.js +103 -0
- package/src/ui/dashboard-renderer.js +150 -0
- package/src/ui/dialog-engine.js +147 -0
- package/src/ui/dialog-factory.js +38 -0
- package/src/ui/engagement-cards.js +76 -0
- package/src/ui/engagement-dialogs.js +100 -0
- package/src/ui/engagement-grid-renderer.js +109 -0
- package/src/ui/entity-renderer.js +176 -0
- package/src/ui/event-delegation.js +108 -0
- package/src/ui/fetch-json.js +24 -0
- package/src/ui/file-dialogs.js +81 -0
- package/src/ui/flexup-report-renderer.js +173 -0
- package/src/ui/format-helpers.js +102 -0
- package/src/ui/global-tags.js +144 -0
- package/src/ui/highlight-threading-renderer.js +176 -0
- package/src/ui/idle-logout.js +156 -0
- package/src/ui/job-management-renderer.js +61 -0
- package/src/ui/layout.js +219 -0
- package/src/ui/letter-dialogs.js +37 -0
- package/src/ui/ml-console-renderer.js +108 -0
- package/src/ui/monitoring-dashboard-client.js +136 -0
- package/src/ui/monitoring-dashboard.js +134 -0
- package/src/ui/notifications-renderer.js +51 -0
- package/src/ui/page-handler-admin.js +121 -0
- package/src/ui/page-handler-helpers.js +111 -0
- package/src/ui/page-handler-reviews.js +165 -0
- package/src/ui/page-handler-rfi.js +42 -0
- package/src/ui/page-handler.js +210 -0
- package/src/ui/password-reset-page.js +146 -0
- package/src/ui/perf-helpers.js +96 -0
- package/src/ui/perf-renderer.js +71 -0
- package/src/ui/permissions-ui.js +163 -0
- package/src/ui/picker-dialogs.js +100 -0
- package/src/ui/render-helpers.js +124 -0
- package/src/ui/renderer.js +35 -0
- package/src/ui/review-comparison-renderer.js +58 -0
- package/src/ui/review-detail-panels.js +71 -0
- package/src/ui/review-detail-renderer.js +170 -0
- package/src/ui/review-detail-script.js +95 -0
- package/src/ui/review-mwr-renderer.js +113 -0
- package/src/ui/review-renderer.js +202 -0
- package/src/ui/review-widgets.js +88 -0
- package/src/ui/review-zone-nav.js +12 -0
- package/src/ui/rfi-detail-renderer.js +191 -0
- package/src/ui/rfi-renderer.js +194 -0
- package/src/ui/rfi-report-renderer.js +56 -0
- package/src/ui/rippleui.css +1 -0
- package/src/ui/settings-renderer-advanced.js +195 -0
- package/src/ui/settings-renderer-advanced2.js +158 -0
- package/src/ui/settings-renderer-teams.js +112 -0
- package/src/ui/settings-renderer.js +166 -0
- package/src/ui/spacing-system.js +155 -0
- package/src/ui/standalone-login.js +109 -0
- package/src/ui/styles.css +2530 -0
- package/src/ui/styles2.css +1602 -0
- package/src/ui/test-page.js +23 -0
- package/src/ui/validation-rules.js +73 -0
- package/src/ui/validation-ui.js +147 -0
- package/src/ui/virtual-scroll.js +107 -0
- package/src/ui/webjsx.js +61 -0
- package/src/ui/widgets.js +152 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thatcher SDK - Configuration-Driven Application Framework
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
|
|
11
|
+
// Forward-declare so we can set later
|
|
12
|
+
let _configEngine = null;
|
|
13
|
+
let _databaseInitialized = false;
|
|
14
|
+
let _pluginsLoaded = false;
|
|
15
|
+
let _server = null;
|
|
16
|
+
let _hotReloadWatchers = [];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve a path relative to this package's src directory as an import URL
|
|
20
|
+
* @param {string} relative - Path like './src/lib/database-core.js'
|
|
21
|
+
* @returns {string} file:// URL
|
|
22
|
+
*/
|
|
23
|
+
function resolveModule(relative) {
|
|
24
|
+
const abs = path.resolve(__dirname, relative);
|
|
25
|
+
return pathToFileURL(abs).href;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Thatcher class - main API
|
|
30
|
+
*/
|
|
31
|
+
export class Thatcher {
|
|
32
|
+
constructor(options = {}) {
|
|
33
|
+
this.options = this.normalizeOptions(options);
|
|
34
|
+
this.config = null;
|
|
35
|
+
this.initialized = false;
|
|
36
|
+
this.started = false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
normalizeOptions(options) {
|
|
40
|
+
return {
|
|
41
|
+
config: options.config || null,
|
|
42
|
+
databasePath: options.databasePath || path.resolve(process.cwd(), 'data', 'app.db'),
|
|
43
|
+
env: options.env || {},
|
|
44
|
+
plugins: options.plugins || [],
|
|
45
|
+
server: {
|
|
46
|
+
port: options.server?.port || parseInt(process.env.PORT || '3000', 10),
|
|
47
|
+
host: options.server?.host || '0.0.0.0',
|
|
48
|
+
hotReload: options.server?.hotReload !== false,
|
|
49
|
+
},
|
|
50
|
+
ui: options.ui !== false,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Initialize the system (config, DB, plugins)
|
|
56
|
+
*/
|
|
57
|
+
async init() {
|
|
58
|
+
if (this.initialized) return this;
|
|
59
|
+
|
|
60
|
+
// Apply env overrides
|
|
61
|
+
Object.assign(process.env, this.options.env);
|
|
62
|
+
|
|
63
|
+
// Load configuration
|
|
64
|
+
await this.loadConfig();
|
|
65
|
+
|
|
66
|
+
// Initialize database
|
|
67
|
+
await this.initDatabase();
|
|
68
|
+
|
|
69
|
+
// Load plugins
|
|
70
|
+
await this.loadPlugins();
|
|
71
|
+
|
|
72
|
+
// Hot reload
|
|
73
|
+
if (this.options.server.hotReload) {
|
|
74
|
+
this.setupHotReload();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.initialized = true;
|
|
78
|
+
return this;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Load master configuration
|
|
83
|
+
*/
|
|
84
|
+
async loadConfig() {
|
|
85
|
+
const { config } = this.options;
|
|
86
|
+
let masterConfig;
|
|
87
|
+
|
|
88
|
+
if (typeof config === 'string') {
|
|
89
|
+
const configPath = path.resolve(process.cwd(), config);
|
|
90
|
+
if (!fs.existsSync(configPath)) {
|
|
91
|
+
throw new Error(`Configuration file not found: ${configPath}`);
|
|
92
|
+
}
|
|
93
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
94
|
+
const { default: yaml } = await import('js-yaml');
|
|
95
|
+
masterConfig = yaml.load(content);
|
|
96
|
+
} else if (typeof config === 'object') {
|
|
97
|
+
masterConfig = config;
|
|
98
|
+
} else {
|
|
99
|
+
// Auto-discover
|
|
100
|
+
const defaultPaths = [
|
|
101
|
+
path.resolve(process.cwd(), 'master-config.yml'),
|
|
102
|
+
path.resolve(process.cwd(), 'thatcher.config.yml'),
|
|
103
|
+
];
|
|
104
|
+
for (const p of defaultPaths) {
|
|
105
|
+
if (fs.existsSync(p)) {
|
|
106
|
+
this.options.config = p;
|
|
107
|
+
const content = fs.readFileSync(p, 'utf-8');
|
|
108
|
+
const { default: yaml } = await import('js-yaml');
|
|
109
|
+
masterConfig = yaml.load(content);
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!masterConfig) {
|
|
116
|
+
throw new Error('No configuration provided. Supply config path or object.');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Init config engine
|
|
120
|
+
const { ConfigGeneratorEngine } = await import(resolveModule('./src/lib/config-generator-engine.js'));
|
|
121
|
+
_configEngine = new ConfigGeneratorEngine(masterConfig);
|
|
122
|
+
|
|
123
|
+
// Debug exposure
|
|
124
|
+
if (globalThis.__debug__) {
|
|
125
|
+
globalThis.__debug__.expose('configEngine', _configEngine, 'Config Engine');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this.config = masterConfig;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Initialize database with schema from config
|
|
133
|
+
*/
|
|
134
|
+
async initDatabase() {
|
|
135
|
+
if (_databaseInitialized) return;
|
|
136
|
+
const { migrate } = await import(resolveModule('./lib/database-core.js'));
|
|
137
|
+
migrate(_configEngine);
|
|
138
|
+
const { getDatabase } = await import(resolveModule('./lib/database-core.js'));
|
|
139
|
+
getDatabase(); // prime the connection
|
|
140
|
+
_databaseInitialized = true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Load .plugin.js files
|
|
145
|
+
*/
|
|
146
|
+
async loadPlugins() {
|
|
147
|
+
if (_pluginsLoaded) return;
|
|
148
|
+
|
|
149
|
+
// User-supplied plugins
|
|
150
|
+
for (const plugin of this.options.plugins) {
|
|
151
|
+
if (_configEngine && plugin.entityName) {
|
|
152
|
+
_configEngine.registerPlugin(plugin.entityName, plugin);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Auto-discover in cwd/plugins and cwd/src/plugins
|
|
157
|
+
const pluginDirs = [
|
|
158
|
+
path.resolve(process.cwd(), 'plugins'),
|
|
159
|
+
path.resolve(process.cwd(), 'src/plugins'),
|
|
160
|
+
];
|
|
161
|
+
for (const dir of pluginDirs) {
|
|
162
|
+
if (fs.existsSync(dir)) {
|
|
163
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.plugin.js'));
|
|
164
|
+
for (const file of files) {
|
|
165
|
+
try {
|
|
166
|
+
const mod = await import(`file://${path.join(dir, file)}?t=${Date.now()}`);
|
|
167
|
+
const plugin = mod.default || mod;
|
|
168
|
+
if (plugin.entityName) {
|
|
169
|
+
_configEngine.registerPlugin(plugin.entityName, plugin);
|
|
170
|
+
}
|
|
171
|
+
} catch (e) {
|
|
172
|
+
console.error(`[plugins] Failed to load ${file}:`, e.message);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
_pluginsLoaded = true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Setup hot reload watchers
|
|
183
|
+
*/
|
|
184
|
+
setupHotReload() {
|
|
185
|
+
const dirs = [
|
|
186
|
+
path.resolve(process.cwd(), 'config'),
|
|
187
|
+
path.resolve(process.cwd(), 'api'),
|
|
188
|
+
path.resolve(process.cwd(), 'ui'),
|
|
189
|
+
path.resolve(process.cwd(), 'plugins'),
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
for (const dir of dirs) {
|
|
193
|
+
if (!fs.existsSync(dir)) continue;
|
|
194
|
+
try {
|
|
195
|
+
const watcher = fs.watch(dir, { recursive: true }, (_, filename) => {
|
|
196
|
+
if (filename && (filename.endsWith('.js') || filename.endsWith('.jsx') || filename.endsWith('.yml'))) {
|
|
197
|
+
console.log(`[HotReload] ${filename} changed`);
|
|
198
|
+
this.invalidateCache();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
_hotReloadWatchers.push(watcher);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
console.warn(`[HotReload] Failed to watch ${dir}:`, err.message);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
invalidateCache() {
|
|
209
|
+
if (_configEngine) {
|
|
210
|
+
_configEngine.invalidateCache();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Start HTTP server
|
|
216
|
+
*/
|
|
217
|
+
async startServer(opts = {}) {
|
|
218
|
+
if (!this.initialized) throw new Error('Call init() first');
|
|
219
|
+
|
|
220
|
+
const port = opts.port || this.options.server.port;
|
|
221
|
+
const host = opts.host || this.options.server.host;
|
|
222
|
+
|
|
223
|
+
const { createServer } = await import(resolveModule('./src/server/server.js'));
|
|
224
|
+
_server = createServer({
|
|
225
|
+
thatcher: this,
|
|
226
|
+
config: this.config,
|
|
227
|
+
configEngine: _configEngine,
|
|
228
|
+
port,
|
|
229
|
+
host,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return new Promise((resolve) => {
|
|
233
|
+
_server.listen(port, host, () => {
|
|
234
|
+
console.log(`\n▲ Thatcher Server\n- Local: http://localhost:${port}\n✓ Ready\n`);
|
|
235
|
+
resolve(_server);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Stop server
|
|
242
|
+
*/
|
|
243
|
+
async stop() {
|
|
244
|
+
if (_server) {
|
|
245
|
+
_server.close();
|
|
246
|
+
_server = null;
|
|
247
|
+
}
|
|
248
|
+
for (const w of _hotReloadWatchers) w.close();
|
|
249
|
+
_hotReloadWatchers = [];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Get config engine
|
|
254
|
+
*/
|
|
255
|
+
getConfigEngine() {
|
|
256
|
+
return _configEngine;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Get entity spec
|
|
261
|
+
*/
|
|
262
|
+
getEntitySpec(name) {
|
|
263
|
+
return _configEngine?.generateEntitySpec(name);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* List all entities
|
|
268
|
+
*/
|
|
269
|
+
getAllEntities() {
|
|
270
|
+
return _configEngine?.getAllEntities() || [];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Get workflow definition
|
|
275
|
+
*/
|
|
276
|
+
getWorkflow(name) {
|
|
277
|
+
return _configEngine?.getWorkflow(name);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// === CRUD operations ===
|
|
281
|
+
|
|
282
|
+
async list(entity, where = {}, opts = {}) {
|
|
283
|
+
const { list } = await import(resolveModule('./src/lib/query-engine.js'));
|
|
284
|
+
return list(entity, where, opts);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async get(entity, id) {
|
|
288
|
+
const { get } = await import(resolveModule('./src/lib/query-engine.js'));
|
|
289
|
+
return get(entity, id);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async create(entity, data, user) {
|
|
293
|
+
const { create } = await import(resolveModule('./src/lib/query-engine-write.js'));
|
|
294
|
+
return create(entity, data, user);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async update(entity, id, data, user) {
|
|
298
|
+
const { update } = await import(resolveModule('./src/lib/query-engine-write.js'));
|
|
299
|
+
return update(entity, id, data, user);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async delete(entity, id) {
|
|
303
|
+
const { remove } = await import(resolveModule('./src/lib/query-engine-write.js'));
|
|
304
|
+
return remove(entity, id);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async search(entity, query, where = {}, opts = {}) {
|
|
308
|
+
const { search } = await import(resolveModule('./src/lib/query-engine.js'));
|
|
309
|
+
return search(entity, query, where, opts);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// === Workflow ===
|
|
313
|
+
|
|
314
|
+
async transition(entityType, entityId, workflowName, toState, user, reason = '') {
|
|
315
|
+
const { transition } = await import(resolveModule('./src/lib/workflow-engine.js'));
|
|
316
|
+
return transition(entityType, entityId, workflowName, toState, user, reason);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async getAvailableTransitions(workflowName, currentState, user, record = null) {
|
|
320
|
+
const { getAvailableTransitions } = await import(resolveModule('./src/lib/workflow-engine.js'));
|
|
321
|
+
return getAvailableTransitions(workflowName, currentState, user, record);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// === AuthZ ===
|
|
325
|
+
|
|
326
|
+
async can(user, spec, action) {
|
|
327
|
+
const { can } = await import(resolveModule('./services/permission.service.js'));
|
|
328
|
+
return can(user, spec, action);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async requirePermission(user, spec, action) {
|
|
332
|
+
const { require } = await import(resolveModule('./services/permission.service.js'));
|
|
333
|
+
return require(user, spec, action);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// === Hooks ===
|
|
337
|
+
|
|
338
|
+
async executeHook(event, context) {
|
|
339
|
+
const { executeHook } = await import(resolveModule('./lib/hook-engine.js'));
|
|
340
|
+
return executeHook(event, context);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// === Database transaction ===
|
|
344
|
+
|
|
345
|
+
async withTransaction(callback) {
|
|
346
|
+
const { withTransaction } = await import(resolveModule('./lib/query-engine.js'));
|
|
347
|
+
return withTransaction(callback);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Create thatcher instance
|
|
353
|
+
*/
|
|
354
|
+
export function createThatcher(options) {
|
|
355
|
+
return new Thatcher(options);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Quick-start helper
|
|
360
|
+
*/
|
|
361
|
+
export async function startThatcher(options = {}) {
|
|
362
|
+
const t = new Thatcher(options);
|
|
363
|
+
await t.init();
|
|
364
|
+
await t.startServer();
|
|
365
|
+
return t;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export default Thatcher;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
export const aria = {
|
|
2
|
+
label: (text) => `aria-label="${escapeHtml(text)}"`,
|
|
3
|
+
labelledBy: (id) => `aria-labelledby="${id}"`,
|
|
4
|
+
describedBy: (id) => `aria-describedby="${id}"`,
|
|
5
|
+
live: (politeness = 'polite') => `aria-live="${politeness}" aria-atomic="true"`,
|
|
6
|
+
hidden: (hidden = true) => hidden ? 'aria-hidden="true"' : '',
|
|
7
|
+
expanded: (expanded) => `aria-expanded="${expanded}"`,
|
|
8
|
+
pressed: (pressed) => `aria-pressed="${pressed}"`,
|
|
9
|
+
checked: (checked) => `aria-checked="${checked}"`,
|
|
10
|
+
disabled: (disabled) => disabled ? 'aria-disabled="true"' : '',
|
|
11
|
+
current: (type = 'page') => `aria-current="${type}"`,
|
|
12
|
+
haspopup: (type = 'dialog') => `aria-haspopup="${type}"`,
|
|
13
|
+
controls: (id) => `aria-controls="${id}"`,
|
|
14
|
+
owns: (id) => `aria-owns="${id}"`,
|
|
15
|
+
invalid: (invalid) => invalid ? 'aria-invalid="true"' : '',
|
|
16
|
+
required: () => 'aria-required="true"',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const role = {
|
|
20
|
+
button: 'role="button"',
|
|
21
|
+
dialog: 'role="dialog"',
|
|
22
|
+
alertdialog: 'role="alertdialog"',
|
|
23
|
+
navigation: 'role="navigation"',
|
|
24
|
+
main: 'role="main"',
|
|
25
|
+
search: 'role="search"',
|
|
26
|
+
form: 'role="form"',
|
|
27
|
+
region: 'role="region"',
|
|
28
|
+
banner: 'role="banner"',
|
|
29
|
+
contentinfo: 'role="contentinfo"',
|
|
30
|
+
complementary: 'role="complementary"',
|
|
31
|
+
list: 'role="list"',
|
|
32
|
+
listitem: 'role="listitem"',
|
|
33
|
+
menu: 'role="menu"',
|
|
34
|
+
menuitem: 'role="menuitem"',
|
|
35
|
+
status: 'role="status"',
|
|
36
|
+
alert: 'role="alert"',
|
|
37
|
+
progressbar: 'role="progressbar"',
|
|
38
|
+
tablist: 'role="tablist"',
|
|
39
|
+
tab: 'role="tab"',
|
|
40
|
+
tabpanel: 'role="tabpanel"',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function escapeHtml(text) {
|
|
44
|
+
return String(text).replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function skipLink(href, text) {
|
|
48
|
+
return `<a href="${href}" class="skip-link" ${aria.label('Skip to ' + text)}>${text}</a>`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function landmark(type, label, content) {
|
|
52
|
+
const r = role[type] || `role="${type}"`;
|
|
53
|
+
const a = label ? aria.label(label) : '';
|
|
54
|
+
return `<div ${r} ${a}>${content}</div>`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function semanticButton(text, attrs = {}) {
|
|
58
|
+
const label = attrs.label || text;
|
|
59
|
+
const disabled = attrs.disabled ? 'disabled' : '';
|
|
60
|
+
const pressed = attrs.pressed !== undefined ? aria.pressed(attrs.pressed) : '';
|
|
61
|
+
const expanded = attrs.expanded !== undefined ? aria.expanded(attrs.expanded) : '';
|
|
62
|
+
const controls = attrs.controls ? aria.controls(attrs.controls) : '';
|
|
63
|
+
const onClick = attrs.onclick ? `onclick="${attrs.onclick}"` : '';
|
|
64
|
+
const className = attrs.class || 'btn';
|
|
65
|
+
return `<button type="button" class="${className}" ${aria.label(label)} ${disabled} ${pressed} ${expanded} ${controls} ${onClick}>${text}</button>`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function semanticLink(href, text, attrs = {}) {
|
|
69
|
+
const label = attrs.label || text;
|
|
70
|
+
const current = attrs.current ? aria.current(attrs.current) : '';
|
|
71
|
+
const className = attrs.class || '';
|
|
72
|
+
return `<a href="${href}" class="${className}" ${aria.label(label)} ${current}>${text}</a>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function formField(type, name, attrs = {}) {
|
|
76
|
+
const id = attrs.id || `field-${name}`;
|
|
77
|
+
const label = attrs.label || name;
|
|
78
|
+
const required = attrs.required ? aria.required() : '';
|
|
79
|
+
const invalid = attrs.invalid ? aria.invalid(true) : '';
|
|
80
|
+
const describedBy = attrs.description ? aria.describedBy(`${id}-desc`) : '';
|
|
81
|
+
const value = attrs.value !== undefined ? `value="${escapeHtml(attrs.value)}"` : '';
|
|
82
|
+
const placeholder = attrs.placeholder ? `placeholder="${escapeHtml(attrs.placeholder)}"` : '';
|
|
83
|
+
const className = attrs.class || 'input input-bordered';
|
|
84
|
+
|
|
85
|
+
let field;
|
|
86
|
+
if (type === 'textarea') {
|
|
87
|
+
field = `<textarea id="${id}" name="${name}" class="${className}" ${required} ${invalid} ${describedBy} ${placeholder}>${attrs.value || ''}</textarea>`;
|
|
88
|
+
} else if (type === 'select') {
|
|
89
|
+
const options = (attrs.options || []).map(opt =>
|
|
90
|
+
`<option value="${opt.value}"${opt.selected ? ' selected' : ''}>${opt.label}</option>`
|
|
91
|
+
).join('');
|
|
92
|
+
field = `<select id="${id}" name="${name}" class="${className}" ${required} ${invalid} ${describedBy}>${options}</select>`;
|
|
93
|
+
} else {
|
|
94
|
+
field = `<input type="${type}" id="${id}" name="${name}" class="${className}" ${value} ${placeholder} ${required} ${invalid} ${describedBy}/>`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const labelEl = `<label for="${id}" class="label"><span class="label-text">${label}${attrs.required ? ' <span aria-hidden="true">*</span>' : ''}</span></label>`;
|
|
98
|
+
const descEl = attrs.description ? `<p id="${id}-desc" class="text-xs text-gray-500 mt-1">${attrs.description}</p>` : '';
|
|
99
|
+
|
|
100
|
+
return `<div class="form-control">${labelEl}${field}${descEl}</div>`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function liveRegion(id, politeness = 'polite') {
|
|
104
|
+
return `<div id="${id}" ${aria.live(politeness)} class="sr-only"></div>`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function announceToScreenReader(regionId, message) {
|
|
108
|
+
return `document.getElementById('${regionId}').textContent = '${escapeHtml(message).replace(/'/g, "\\'")}';`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const focusTrap = {
|
|
112
|
+
activate: (containerId) => `
|
|
113
|
+
(function() {
|
|
114
|
+
const container = document.getElementById('${containerId}');
|
|
115
|
+
if (!container) return;
|
|
116
|
+
const focusable = container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
|
117
|
+
const first = focusable[0];
|
|
118
|
+
const last = focusable[focusable.length - 1];
|
|
119
|
+
container._trapHandler = (e) => {
|
|
120
|
+
if (e.key !== 'Tab') return;
|
|
121
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
last.focus();
|
|
124
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
first.focus();
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
container.addEventListener('keydown', container._trapHandler);
|
|
130
|
+
first?.focus();
|
|
131
|
+
})();
|
|
132
|
+
`,
|
|
133
|
+
deactivate: (containerId) => `
|
|
134
|
+
(function() {
|
|
135
|
+
const container = document.getElementById('${containerId}');
|
|
136
|
+
if (container?._trapHandler) {
|
|
137
|
+
container.removeEventListener('keydown', container._trapHandler);
|
|
138
|
+
delete container._trapHandler;
|
|
139
|
+
}
|
|
140
|
+
})();
|
|
141
|
+
`
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const keyboard = {
|
|
145
|
+
escape: (callback) => `if (event.key === 'Escape') { ${callback} }`,
|
|
146
|
+
enter: (callback) => `if (event.key === 'Enter') { ${callback} }`,
|
|
147
|
+
space: (callback) => `if (event.key === ' ' || event.key === 'Spacebar') { event.preventDefault(); ${callback} }`,
|
|
148
|
+
arrowNav: (upCallback, downCallback) => `
|
|
149
|
+
if (event.key === 'ArrowUp') { event.preventDefault(); ${upCallback} }
|
|
150
|
+
if (event.key === 'ArrowDown') { event.preventDefault(); ${downCallback} }
|
|
151
|
+
`,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export function visuallyHidden(content) {
|
|
155
|
+
return `<span class="sr-only">${content}</span>`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function accessibleImage(src, alt, attrs = {}) {
|
|
159
|
+
const className = attrs.class || '';
|
|
160
|
+
const decorative = attrs.decorative ? 'alt="" role="presentation"' : `alt="${escapeHtml(alt)}"`;
|
|
161
|
+
return `<img src="${src}" ${decorative} class="${className}"/>`;
|
|
162
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { create, update, remove } from '@/engine';
|
|
4
|
+
import { requireUser, check } from '@/engine.server';
|
|
5
|
+
import { getSpec } from '@/config/spec-helpers';
|
|
6
|
+
import { revalidatePath } from '@/lib/next-polyfills';
|
|
7
|
+
|
|
8
|
+
export async function serverCreateEntity(entityName, data) {
|
|
9
|
+
const user = await requireUser();
|
|
10
|
+
const spec = getSpec(entityName);
|
|
11
|
+
await check(user, spec, 'create');
|
|
12
|
+
const result = create(entityName, data, user);
|
|
13
|
+
revalidatePath(`/${entityName}`);
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function serverUpdateEntity(entityName, id, data) {
|
|
18
|
+
const user = await requireUser();
|
|
19
|
+
const spec = getSpec(entityName);
|
|
20
|
+
await check(user, spec, 'edit');
|
|
21
|
+
const result = update(entityName, id, data, user);
|
|
22
|
+
revalidatePath(`/${entityName}/${id}`);
|
|
23
|
+
revalidatePath(`/${entityName}`);
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function serverDeleteEntity(entityName, id) {
|
|
28
|
+
const user = await requireUser();
|
|
29
|
+
const spec = getSpec(entityName);
|
|
30
|
+
await check(user, spec, 'delete');
|
|
31
|
+
const result = remove(entityName, id);
|
|
32
|
+
revalidatePath(`/${entityName}`);
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { serverCreateEntity, serverUpdateEntity, serverDeleteEntity } from '@/lib/action-factory';
|
|
2
|
+
|
|
3
|
+
export function createCRUDActions(entityName) {
|
|
4
|
+
return {
|
|
5
|
+
create: (data) => serverCreateEntity(entityName, data),
|
|
6
|
+
update: (id, data) => serverUpdateEntity(entityName, id, data),
|
|
7
|
+
delete: (id) => serverDeleteEntity(entityName, id),
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createEntityAction(entityName, actionName, permission, handler) {
|
|
12
|
+
return async (...args) => {
|
|
13
|
+
'use server';
|
|
14
|
+
const { requireUser, check } = await import('@/engine.server');
|
|
15
|
+
const { getSpec } = await import('@/config');
|
|
16
|
+
const user = await requireUser();
|
|
17
|
+
const spec = getSpec(entityName);
|
|
18
|
+
await check(user, spec, permission);
|
|
19
|
+
return handler(user, spec, ...args);
|
|
20
|
+
};
|
|
21
|
+
}
|