thatcher 1.0.41 → 1.0.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/package.json +2 -5
- package/src/app/api/audit/dashboard/route.js +2 -2
- package/src/app/api/audit/logs/route.js +2 -2
- package/src/app/api/audit/permissions/[id]/route.js +2 -2
- package/src/app/api/audit/permissions/route.js +3 -3
- package/src/app/api/audit/permissions/stats/route.js +2 -2
- package/src/app/api/audit/route.js +2 -3
- package/src/app/api/audit/stats/route.js +2 -2
- package/src/app/api/auth/google/callback/route.js +1 -1
- package/src/app/api/auth/google/route.js +1 -1
- package/src/app/api/auth/login/route.js +1 -1
- package/src/app/api/auth/logout/route.js +1 -1
- package/src/app/api/auth/me/route.js +1 -1
- package/src/app/api/auth/mwr-bridge/route.js +1 -1
- package/src/app/api/auth/password-reset/route.js +1 -1
- package/src/app/api/csrf-token/route.js +1 -1
- package/src/app/api/debug/[[...path]]/route.js +2 -2
- package/src/app/api/debug/config/route.js +1 -1
- package/src/app/api/debug/hooks/route.js +1 -1
- package/src/app/api/debug/plugins/route.js +1 -1
- package/src/app/api/debug/sqlite/route.js +1 -1
- package/src/app/api/debug/sync/route.js +1 -1
- package/src/app/api/debug/workflow/route.js +1 -1
- package/src/app/api/domains/[domain]/route.js +1 -2
- package/src/app/api/domains/route.js +1 -1
- package/src/app/api/email/allocate/batch/route.js +1 -1
- package/src/app/api/email/allocate/route.js +1 -1
- package/src/app/api/email/receive/route.js +1 -1
- package/src/app/api/email/send/route.js +1 -1
- package/src/app/api/email/unallocated/route.js +1 -1
- package/src/app/api/files/[id]/route.js +1 -1
- package/src/app/api/health/route.js +1 -2
- package/src/app/api/metrics/route.js +1 -2
- package/src/engine.js +3 -3
- package/src/engine.server.js +3 -3
- package/src/index.js +35 -10
- package/src/lib/action-factory.js +1 -1
- package/src/lib/api-helpers.js +1 -1
- package/src/lib/api.js +3 -4
- package/src/lib/audit-logger-enhanced.js +1 -1
- package/src/lib/auth-middleware.js +1 -1
- package/src/lib/auth-route-helpers.js +1 -1
- package/src/lib/{busybase-adapter.js → busybase/adapter.js} +3 -3
- package/src/lib/{busybase-audit-reads.js → busybase/audit-reads.js} +1 -1
- package/src/lib/{busybase-audit.js → busybase/audit.js} +2 -2
- package/src/lib/{busybase-lucia-adapter.js → busybase/lucia-adapter.js} +1 -1
- package/src/lib/{busybase-store.js → busybase/store.js} +3 -3
- package/src/lib/config-generator-engine.js +13 -1
- package/src/lib/crud-action-helpers.js +1 -1
- package/src/lib/crud-handlers.js +4 -4
- package/src/lib/email-sender.js +1 -1
- package/src/lib/errors/index.js +3 -0
- package/src/lib/{error-recovery.js → errors/recovery.js} +120 -4
- package/src/lib/{error-handler.js → errors/types.js} +1 -1
- package/src/lib/errors/wrap.js +225 -0
- package/src/lib/events-engine.js +1 -1
- package/src/lib/hot-reload/index.js +34 -0
- package/src/lib/index.js +5 -5
- package/src/lib/keyed-cache.js +112 -0
- package/src/lib/monitor.js +285 -0
- package/src/lib/next-shim.js +294 -0
- package/src/lib/observability-init.js +213 -0
- package/src/lib/perf.js +468 -0
- package/src/lib/query-cache.js +40 -46
- package/src/lib/realtime-server.js +18 -0
- package/src/lib/render-cache.js +14 -28
- package/src/lib/{request-tracing.js → request-trace.js} +57 -2
- package/src/lib/response-formatter.js +18 -0
- package/src/lib/route-helpers.js +1 -1
- package/src/lib/state-protocol.js +13 -0
- package/src/lib/state-transport-client.js +6 -0
- package/src/lib/state-transport-reconnect.js +5 -0
- package/src/lib/state-transport-server.js +8 -0
- package/src/lib/utils.js +1 -1
- package/src/lib/{validate.js → validation/entity-validators.js} +9 -8
- package/src/lib/validation/index.js +1 -1
- package/src/lib/validation/security-validators.js +1 -1
- package/src/lib/validation-middleware.js +2 -2
- package/src/lib/workflow-engine.js +23 -3
- package/src/lib/xstate-workflow-engine.js +18 -2
- package/src/services/collaborator-role.service.js +2 -2
- package/src/services/notification-engine.js +5 -5
- package/src/services/permission.service.js +1 -1
- package/src/ui/format-helpers.js +1 -4
- package/src/ui/highlight-threading-renderer.js +1 -1
- package/src/ui/page-handler-admin.js +6 -5
- package/src/ui/page-handler-helpers.js +2 -2
- package/src/ui/page-handler-reviews.js +4 -4
- package/src/ui/page-handler-rfi.js +1 -1
- package/src/ui/page-handler.js +2 -2
- package/src/ui/render-helpers.js +1 -1
- package/src/ui/renderer.js +1 -1
- package/src/ui/{review-comparison-renderer.js → review/comparison.js} +1 -1
- package/src/ui/{review-detail-renderer.js → review/detail-renderer.js} +3 -3
- package/src/ui/review/index.js +32 -0
- package/src/ui/{review-mwr-renderer.js → review/mwr.js} +1 -1
- package/src/ui/{review-renderer.js → review/renderer.js} +1 -1
- package/src/ui/{settings-renderer.js → settings/home.js} +7 -34
- package/src/ui/{settings-renderer-advanced.js → settings/review.js} +117 -56
- package/src/ui/settings/shared.js +45 -0
- package/src/ui/{settings-renderer-teams.js → settings/teams.js} +1 -2
- package/src/ui/settings/templates.js +116 -0
- package/src/lib/api-error-wrapper.js +0 -125
- package/src/lib/db-monitor.js +0 -127
- package/src/lib/error-resilience.js +0 -132
- package/src/lib/monitoring-init.js +0 -67
- package/src/lib/next-compat.js +0 -94
- package/src/lib/next-polyfills.js +0 -135
- package/src/lib/observability-bootstrap.js +0 -116
- package/src/lib/perf-monitor.js +0 -94
- package/src/lib/perf-profiler.js +0 -233
- package/src/lib/query-perf.js +0 -117
- package/src/lib/request-tracker.js +0 -43
- package/src/lib/resource-monitor.js +0 -120
- package/src/lib/validators.js +0 -67
- package/src/lib/with-error-handler.js +0 -31
- package/src/ui/settings-renderer-advanced2.js +0 -159
- /package/src/ui/{review-detail-panels.js → review/detail-panels.js} +0 -0
- /package/src/ui/{review-detail-script.js → review/detail-script.js} +0 -0
- /package/src/ui/{review-widgets.js → review/widgets.js} +0 -0
- /package/src/ui/{review-zone-nav.js → review/zone-nav.js} +0 -0
package/src/lib/query-cache.js
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
const resultCache = new Map();
|
|
3
|
-
const queryCache = new Map();
|
|
4
|
-
let cacheHits = 0;
|
|
5
|
-
let cacheMisses = 0;
|
|
6
|
-
let stmtCacheHits = 0;
|
|
7
|
-
let stmtCacheMisses = 0;
|
|
1
|
+
import { createCache } from './keyed-cache.js';
|
|
8
2
|
|
|
9
3
|
const MAX_STMT_CACHE = 500;
|
|
10
4
|
const MAX_RESULT_CACHE = 200;
|
|
@@ -23,19 +17,31 @@ const TTL_CONFIG = {
|
|
|
23
17
|
default: DEFAULT_TTL
|
|
24
18
|
};
|
|
25
19
|
|
|
20
|
+
// Prepared statements never expire by time (only size-evicted), so ttlMs is
|
|
21
|
+
// left unset (no expiry) -- matches the original stmtCache having no TTL check.
|
|
22
|
+
const stmtCache = createCache({ maxSize: MAX_STMT_CACHE });
|
|
23
|
+
// The query cache (cacheQuery) also never expired by time in the original.
|
|
24
|
+
const queryCache = createCache({ maxSize: MAX_QUERY_CACHE });
|
|
25
|
+
// The result cache's TTL varies PER ENTRY (by the entity it was cached under),
|
|
26
|
+
// so a single fixed ttlMs on the shared primitive cannot express it directly.
|
|
27
|
+
// Kept as ONE cache instance (matching the original single-Map, single
|
|
28
|
+
// MAX_RESULT_CACHE=200-total ceiling -- a per-TTL-bucket split would silently
|
|
29
|
+
// multiply the effective capacity, a real behaviour change) with no TTL on the
|
|
30
|
+
// primitive itself; each entry's own per-entity TTL is checked here at read
|
|
31
|
+
// time against a stored timestamp, exactly like the original inline check did.
|
|
32
|
+
// Hit/miss counting is also done at this layer (not the primitive's own
|
|
33
|
+
// built-in counters) since a ttl=0/expired lookup must count as a MISS to
|
|
34
|
+
// match the original's exact cacheHits/cacheMisses semantics.
|
|
35
|
+
const resultCache = createCache({ maxSize: MAX_RESULT_CACHE });
|
|
36
|
+
let cacheHits = 0;
|
|
37
|
+
let cacheMisses = 0;
|
|
38
|
+
|
|
26
39
|
export const prepareStmt = (db, sql) => {
|
|
27
40
|
const cached = stmtCache.get(sql);
|
|
28
|
-
if (cached) {
|
|
29
|
-
stmtCacheHits++;
|
|
41
|
+
if (cached !== undefined) {
|
|
30
42
|
return cached;
|
|
31
43
|
}
|
|
32
44
|
|
|
33
|
-
stmtCacheMisses++;
|
|
34
|
-
if (stmtCache.size >= MAX_STMT_CACHE) {
|
|
35
|
-
const firstKey = stmtCache.keys().next().value;
|
|
36
|
-
stmtCache.delete(firstKey);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
45
|
const stmt = db.prepare(sql);
|
|
40
46
|
stmtCache.set(sql, stmt);
|
|
41
47
|
return stmt;
|
|
@@ -43,7 +49,7 @@ export const prepareStmt = (db, sql) => {
|
|
|
43
49
|
|
|
44
50
|
export const getCached = (key, entity = 'default') => {
|
|
45
51
|
const entry = resultCache.get(key);
|
|
46
|
-
if (
|
|
52
|
+
if (entry === undefined) {
|
|
47
53
|
cacheMisses++;
|
|
48
54
|
return null;
|
|
49
55
|
}
|
|
@@ -68,11 +74,6 @@ export const setCached = (key, data, entity = 'default') => {
|
|
|
68
74
|
const ttl = TTL_CONFIG[entity] || TTL_CONFIG.default;
|
|
69
75
|
if (ttl === 0) return;
|
|
70
76
|
|
|
71
|
-
if (resultCache.size >= MAX_RESULT_CACHE) {
|
|
72
|
-
const firstKey = resultCache.keys().next().value;
|
|
73
|
-
resultCache.delete(firstKey);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
77
|
resultCache.set(key, { data, timestamp: Date.now() });
|
|
77
78
|
};
|
|
78
79
|
|
|
@@ -84,43 +85,36 @@ export const invalidate = (pattern) => {
|
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
const regex = new RegExp(pattern);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
for (const key of queryCache.keys()) {
|
|
91
|
-
if (regex.test(key)) queryCache.delete(key);
|
|
92
|
-
}
|
|
88
|
+
resultCache.deleteWhere(key => regex.test(key));
|
|
89
|
+
queryCache.deleteWhere(key => regex.test(key));
|
|
93
90
|
};
|
|
94
91
|
|
|
95
92
|
export const cacheQuery = (key, fn) => {
|
|
96
93
|
const cached = queryCache.get(key);
|
|
97
|
-
if (cached) return cached;
|
|
98
|
-
|
|
99
|
-
if (queryCache.size >= MAX_QUERY_CACHE) {
|
|
100
|
-
const firstKey = queryCache.keys().next().value;
|
|
101
|
-
queryCache.delete(firstKey);
|
|
102
|
-
}
|
|
94
|
+
if (cached !== undefined) return cached;
|
|
103
95
|
|
|
104
96
|
const result = fn();
|
|
105
97
|
queryCache.set(key, result);
|
|
106
98
|
return result;
|
|
107
99
|
};
|
|
108
100
|
|
|
109
|
-
export const getStats = () =>
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
101
|
+
export const getStats = () => {
|
|
102
|
+
const stmtStats = stmtCache.stats();
|
|
103
|
+
return {
|
|
104
|
+
cacheHits,
|
|
105
|
+
cacheMisses,
|
|
106
|
+
hitRate: cacheHits + cacheMisses > 0 ? (cacheHits / (cacheHits + cacheMisses) * 100).toFixed(2) + '%' : '0%',
|
|
107
|
+
stmtCacheHits: stmtStats.hits,
|
|
108
|
+
stmtCacheMisses: stmtStats.misses,
|
|
109
|
+
stmtHitRate: stmtStats.hits + stmtStats.misses > 0 ? (stmtStats.hits / (stmtStats.hits + stmtStats.misses) * 100).toFixed(2) + '%' : '0%',
|
|
110
|
+
stmtCacheSize: stmtStats.size,
|
|
111
|
+
resultCacheSize: resultCache.stats().size,
|
|
112
|
+
queryCacheSize: queryCache.stats().size
|
|
113
|
+
};
|
|
114
|
+
};
|
|
120
115
|
|
|
121
116
|
export const clearStats = () => {
|
|
122
117
|
cacheHits = 0;
|
|
123
118
|
cacheMisses = 0;
|
|
124
|
-
|
|
125
|
-
stmtCacheMisses = 0;
|
|
119
|
+
stmtCache.resetStats();
|
|
126
120
|
};
|
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
// ws-broadcast channel: in-process pub/sub used by the CRUD layer to fan out
|
|
2
|
+
// entity create/update/delete notifications (see src/lib/api.js, which calls
|
|
3
|
+
// broadcastUpdate() on every write, and src/lib/index.js which re-exports this
|
|
4
|
+
// module). Despite the "(polling mode)" log line there is no actual transport
|
|
5
|
+
// here -- no WebSocket, no poll endpoint -- just an in-memory Map of
|
|
6
|
+
// channel -> Set<callback>, so subscribers must live in the same process.
|
|
7
|
+
//
|
|
8
|
+
// This is a DIFFERENT channel from the state-transport-*.js / state-protocol.js
|
|
9
|
+
// quartet (state-transport-server.js / state-transport-client.js /
|
|
10
|
+
// state-transport-reconnect.js): that stack is a real `ws`-backed WebSocket
|
|
11
|
+
// server+client with a structured message protocol, vector clocks, and
|
|
12
|
+
// reconnect/polling-fallback semantics, but as of this writing it has ZERO
|
|
13
|
+
// importers anywhere in the repo outside its own internal cross-imports --
|
|
14
|
+
// nothing constructs a StateTransportServer/StateTransportClient. So today
|
|
15
|
+
// state-transport-* does NOT supersede this file; this file (realtime-server.js)
|
|
16
|
+
// is the one actually wired into production (src/lib/api.js), while
|
|
17
|
+
// state-transport-* is a more capable but currently-unwired structured protocol.
|
|
18
|
+
// Do not delete either without re-checking real importers first.
|
|
1
19
|
import { createLogger } from './logger.js';
|
|
2
20
|
|
|
3
21
|
const log = createLogger('[Realtime]');
|
package/src/lib/render-cache.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
import { createCache } from './keyed-cache.js'
|
|
2
|
+
|
|
2
3
|
const maxSize = 500
|
|
3
4
|
const ttl = 60000
|
|
4
|
-
|
|
5
|
-
let misses = 0
|
|
5
|
+
const cache = createCache({ ttlMs: ttl, maxSize })
|
|
6
6
|
|
|
7
7
|
function cacheKey(fn, args) {
|
|
8
8
|
return `${fn.name}:${JSON.stringify(args)}`
|
|
@@ -12,17 +12,11 @@ export function memoize(fn) {
|
|
|
12
12
|
return function(...args) {
|
|
13
13
|
const key = cacheKey(fn, args)
|
|
14
14
|
const cached = cache.get(key)
|
|
15
|
-
if (cached
|
|
16
|
-
|
|
17
|
-
return cached.value
|
|
15
|
+
if (cached !== undefined) {
|
|
16
|
+
return cached
|
|
18
17
|
}
|
|
19
|
-
misses++
|
|
20
18
|
const value = fn(...args)
|
|
21
|
-
cache.set(key,
|
|
22
|
-
if (cache.size > maxSize) {
|
|
23
|
-
const first = cache.keys().next().value
|
|
24
|
-
cache.delete(first)
|
|
25
|
-
}
|
|
19
|
+
cache.set(key, value)
|
|
26
20
|
return value
|
|
27
21
|
}
|
|
28
22
|
}
|
|
@@ -30,25 +24,18 @@ export function memoize(fn) {
|
|
|
30
24
|
export function invalidate(pattern) {
|
|
31
25
|
if (!pattern) {
|
|
32
26
|
cache.clear()
|
|
33
|
-
return
|
|
34
|
-
}
|
|
35
|
-
let count = 0
|
|
36
|
-
for (const key of cache.keys()) {
|
|
37
|
-
if (key.includes(pattern)) {
|
|
38
|
-
cache.delete(key)
|
|
39
|
-
count++
|
|
40
|
-
}
|
|
27
|
+
return 0
|
|
41
28
|
}
|
|
42
|
-
return
|
|
29
|
+
return cache.deleteWhere(key => key.includes(pattern))
|
|
43
30
|
}
|
|
44
31
|
|
|
45
32
|
export function getCacheStats() {
|
|
46
|
-
const
|
|
33
|
+
const s = cache.stats()
|
|
47
34
|
return {
|
|
48
|
-
size:
|
|
49
|
-
hits,
|
|
50
|
-
misses,
|
|
51
|
-
hitRate:
|
|
35
|
+
size: s.size,
|
|
36
|
+
hits: s.hits,
|
|
37
|
+
misses: s.misses,
|
|
38
|
+
hitRate: (s.hits + s.misses) > 0 ? (s.hitRate * 100).toFixed(2) + '%' : '0%',
|
|
52
39
|
maxSize,
|
|
53
40
|
ttl
|
|
54
41
|
}
|
|
@@ -56,6 +43,5 @@ export function getCacheStats() {
|
|
|
56
43
|
|
|
57
44
|
export function clearCache() {
|
|
58
45
|
cache.clear()
|
|
59
|
-
|
|
60
|
-
misses = 0
|
|
46
|
+
cache.resetStats()
|
|
61
47
|
}
|
|
@@ -1,9 +1,19 @@
|
|
|
1
|
+
// Merged from request-tracing.js (span/OTel-shaped tracing over the shared
|
|
2
|
+
// tracer + perfProfiler) and request-tracker.js (simple duration/status
|
|
3
|
+
// request+error recording into metrics-collector.js's recordRequest/
|
|
4
|
+
// recordError channel). Two request-scoped tracking layers, same concern,
|
|
5
|
+
// kept side by side with their original exported names unchanged.
|
|
6
|
+
|
|
1
7
|
import { tracer } from './tracing.js';
|
|
2
|
-
import { perfProfiler } from './perf
|
|
8
|
+
import { perfProfiler } from './perf.js';
|
|
3
9
|
import { createLogger } from './logger.js';
|
|
10
|
+
import { recordRequest, recordError } from './metrics-collector.js';
|
|
4
11
|
|
|
5
12
|
const logger = createLogger('[RequestTracing]');
|
|
6
13
|
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// From request-tracing.js
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
7
17
|
export function withTracing(handler) {
|
|
8
18
|
return async (req, ...rest) => {
|
|
9
19
|
const method = req.method || 'UNKNOWN';
|
|
@@ -208,4 +218,49 @@ export function createTracingMiddleware() {
|
|
|
208
218
|
};
|
|
209
219
|
}
|
|
210
220
|
|
|
211
|
-
export default withTracing;
|
|
221
|
+
export default withTracing;
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// From request-tracker.js
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
function wrapHandler(handler, metadata = {}) {
|
|
227
|
+
return async (request, context) => {
|
|
228
|
+
const start = process.hrtime.bigint();
|
|
229
|
+
const method = request.method;
|
|
230
|
+
const path = new URL(request.url).pathname;
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const response = await handler(request, context);
|
|
234
|
+
const duration = Number(process.hrtime.bigint() - start) / 1000000;
|
|
235
|
+
const status = response.status || 200;
|
|
236
|
+
|
|
237
|
+
recordRequest(path, method, duration, status);
|
|
238
|
+
|
|
239
|
+
return response;
|
|
240
|
+
} catch (err) {
|
|
241
|
+
const duration = Number(process.hrtime.bigint() - start) / 1000000;
|
|
242
|
+
|
|
243
|
+
recordRequest(path, method, duration, 500);
|
|
244
|
+
recordError(path, method, err.message, err.stack);
|
|
245
|
+
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function trackRequest(path, method, duration, status) {
|
|
252
|
+
recordRequest(path, method, duration, status);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function trackError(path, method, error, stack) {
|
|
256
|
+
recordError(path, method, error, stack);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export { wrapHandler, trackRequest, trackError };
|
|
260
|
+
|
|
261
|
+
if (typeof globalThis !== 'undefined') {
|
|
262
|
+
globalThis.__requestTracker = {
|
|
263
|
+
trackRequest,
|
|
264
|
+
trackError
|
|
265
|
+
};
|
|
266
|
+
}
|
|
@@ -35,6 +35,24 @@ export function error(message, status = HTTP.INTERNAL_ERROR, code = 'ERROR') {
|
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// Accepts a normalized AppError-shaped object (message/code/status/details)
|
|
39
|
+
// and renders it the same way error() renders a plain message/status/code
|
|
40
|
+
// triple. Pre-existing callers (withErrorHandler in lib/errors/wrap.js)
|
|
41
|
+
// imported this name with no such export ever having existed here; adding
|
|
42
|
+
// it now is the real fix for what was previously a dead, never-taken error
|
|
43
|
+
// path (nothing ever exercised it directly, so the gap went unnoticed).
|
|
44
|
+
export function apiError(err) {
|
|
45
|
+
const status = err?.status || err?.statusCode || HTTP.INTERNAL_ERROR;
|
|
46
|
+
const code = err?.code || 'ERROR';
|
|
47
|
+
const message = err?.message || 'An unexpected error occurred';
|
|
48
|
+
const body = { error: message, code };
|
|
49
|
+
if (err?.details) body.details = err.details;
|
|
50
|
+
return new Response(JSON.stringify(body), {
|
|
51
|
+
status,
|
|
52
|
+
headers: { 'Content-Type': 'application/json' },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
export function withMetadata(data, status, type = 'success') {
|
|
39
57
|
return { ...data, _meta: { status, type, timestamp: Date.now() } };
|
|
40
58
|
}
|
package/src/lib/route-helpers.js
CHANGED
|
@@ -3,7 +3,7 @@ import { getSpec, getNavItems } from '@/config/spec-helpers';
|
|
|
3
3
|
import { get, getChildren } from '@/engine';
|
|
4
4
|
import { can } from '@/services/permission.service';
|
|
5
5
|
import { withPageAuth } from '@/lib/auth-middleware';
|
|
6
|
-
import { notFound } from '@/lib/next-
|
|
6
|
+
import { notFound } from '@/lib/next-shim';
|
|
7
7
|
import { loadFormOptions } from '@/lib/utils';
|
|
8
8
|
|
|
9
9
|
export { withPageAuth as requireEntityAccess };
|
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
// state-sync channel: message-type vocabulary + VectorClock + schema validation
|
|
2
|
+
// shared by state-transport-server.js / state-transport-client.js /
|
|
3
|
+
// state-transport-reconnect.js. This is a structured, reconnect-aware
|
|
4
|
+
// WebSocket protocol (real `ws` transport, IP/rate-limited via
|
|
5
|
+
// connection-guard.js, exponential-backoff client reconnect with a polling
|
|
6
|
+
// fallback) -- distinct from the simpler in-process pub/sub in
|
|
7
|
+
// realtime-server.js (the "ws-broadcast" channel actually wired into the CRUD
|
|
8
|
+
// write path via src/lib/api.js). As of this writing, grepping the whole repo
|
|
9
|
+
// found ZERO importers of this quartet outside their own internal cross-imports
|
|
10
|
+
// (state-transport-client.js imports state-transport-reconnect.js; both import
|
|
11
|
+
// this file) -- nothing external constructs a StateTransportServer/Client, so
|
|
12
|
+
// this stack is currently unwired/dormant rather than superseding
|
|
13
|
+
// realtime-server.js. Keep both; re-check importers before deleting either.
|
|
1
14
|
const MESSAGE_TYPES = {
|
|
2
15
|
STATE_UPDATE: 'state_update',
|
|
3
16
|
STATE_SNAPSHOT: 'state_snapshot',
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
// state-sync channel (client side): browser WebSocket client with
|
|
2
|
+
// exponential-backoff reconnect + polling fallback, part of the
|
|
3
|
+
// state-protocol.js quartet. See the header comment in state-protocol.js --
|
|
4
|
+
// this is a structured, reconnect-aware protocol distinct from
|
|
5
|
+
// realtime-server.js's simple in-process pub/sub. Zero real importers found
|
|
6
|
+
// repo-wide as of this writing; currently dormant/unwired.
|
|
1
7
|
import Protocol from './state-protocol.js'
|
|
2
8
|
import { CONFIG, ReconnectManager } from './state-transport-reconnect.js'
|
|
3
9
|
import { createLogger } from './logger.js'
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// state-sync channel (reconnect logic): exponential-backoff + polling-fallback
|
|
2
|
+
// manager consumed by state-transport-client.js, part of the state-protocol.js
|
|
3
|
+
// quartet. See the header comment in state-protocol.js for the full
|
|
4
|
+
// ws-broadcast vs state-sync distinction. Zero real importers repo-wide
|
|
5
|
+
// outside this quartet's own internal cross-imports as of this writing.
|
|
1
6
|
import Protocol from './state-protocol.js'
|
|
2
7
|
|
|
3
8
|
const CONFIG = {
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
// state-sync channel (server side): real `ws`-backed WebSocketServer with
|
|
2
|
+
// connection-guard IP/rate limiting, part of the state-protocol.js quartet.
|
|
3
|
+
// See the header comment in state-protocol.js for the full picture -- this is
|
|
4
|
+
// a structured, reconnect-aware state-sync protocol, DIFFERENT from and not a
|
|
5
|
+
// replacement for realtime-server.js's simple in-process pub/sub
|
|
6
|
+
// (the "ws-broadcast" channel actually used by the CRUD write path). As of
|
|
7
|
+
// this writing this file has zero real importers repo-wide; nothing
|
|
8
|
+
// constructs a StateTransportServer today.
|
|
1
9
|
import { WebSocketServer } from 'ws'
|
|
2
10
|
import { EventEmitter } from 'events'
|
|
3
11
|
import Protocol from '@/lib/state-protocol.js'
|
package/src/lib/utils.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
// Adapted from moonlanding/src/lib/validate.js
|
|
2
|
+
// Entity/spec-driven field, record, and status-transition validation.
|
|
2
3
|
|
|
3
|
-
import { getSpec } from '
|
|
4
|
-
import { isValidEmail
|
|
5
|
-
import { getValidTransitions } from '
|
|
6
|
-
import { isBeforeDate } from '
|
|
4
|
+
import { getSpec } from '@/config/spec-helpers';
|
|
5
|
+
import { isValidEmail } from '@/lib/validation/format-validators';
|
|
6
|
+
import { getValidTransitions } from '@/lib/status-helpers';
|
|
7
|
+
import { isBeforeDate } from '@/lib/date-utils';
|
|
7
8
|
|
|
8
|
-
// Re-export so consumers can reach the email validator from
|
|
9
|
+
// Re-export so consumers can reach the email validator from entity-validators.js (parity with moon)
|
|
9
10
|
export { isValidEmail };
|
|
10
11
|
|
|
11
12
|
const HTML_ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
12
13
|
|
|
13
|
-
function sanitizeHtml(str) {
|
|
14
|
+
export function sanitizeHtml(str) {
|
|
14
15
|
return typeof str === 'string' ? str.replace(/[&<>"']/g, c => HTML_ESC[c]) : str;
|
|
15
16
|
}
|
|
16
17
|
|
|
@@ -50,7 +51,7 @@ export async function validateField(fieldDef, value, options = {}) {
|
|
|
50
51
|
return { valid: true };
|
|
51
52
|
}
|
|
52
53
|
try {
|
|
53
|
-
const { getBy } = await import('
|
|
54
|
+
const { getBy } = await import('@/lib/busybase/store');
|
|
54
55
|
const refTable = fieldDef.ref === 'user' ? 'users' : fieldDef.ref;
|
|
55
56
|
if (!(await getBy(refTable, 'id', value))) {
|
|
56
57
|
return {
|
|
@@ -141,7 +142,7 @@ async function checkUnique(fieldDef, value, { fieldName, entityName, existingRec
|
|
|
141
142
|
const existingValue = existingRecord?.[fieldName];
|
|
142
143
|
if (value === existingValue) return null;
|
|
143
144
|
try {
|
|
144
|
-
const { getBy } = await import('
|
|
145
|
+
const { getBy } = await import('@/lib/busybase/store');
|
|
145
146
|
const table = entityName === 'user' ? 'users' : entityName;
|
|
146
147
|
const dup = await getBy(table, fieldName, value);
|
|
147
148
|
if (dup && (!existingRecord || dup.id !== existingRecord.id)) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { NextResponse } from '@/lib/next-
|
|
1
|
+
import { NextResponse } from '@/lib/next-shim';
|
|
2
2
|
import {
|
|
3
3
|
detectXSS,
|
|
4
4
|
detectSQLInjection,
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
rateLimitCheck,
|
|
8
8
|
validateCSRFToken
|
|
9
9
|
} from '@/lib/validation';
|
|
10
|
-
import { validateEntity, validateUpdate, hasErrors } from '@/lib/
|
|
10
|
+
import { validateEntity, validateUpdate, hasErrors } from '@/lib/validation/entity-validators';
|
|
11
11
|
|
|
12
12
|
export async function validateRequest(request, options = {}) {
|
|
13
13
|
const { requireCSRF = true, rateLimit = { max: 100, window: 60000 }, entityName = null } = options;
|
|
@@ -1,7 +1,27 @@
|
|
|
1
|
-
|
|
1
|
+
// Plain-function entity lifecycle/workflow engine: reads a `config.workflows[name]`
|
|
2
|
+
// definition (stages with forward/backward/requires_role/entry/readonly/order/
|
|
3
|
+
// locks/actions) and validates+applies transitions against a busybase-backed
|
|
4
|
+
// entity record. THIS IS THE LIVE PATH: its `validateTransition` is called from
|
|
5
|
+
// src/lib/events-engine.js to gate an `engagement_lifecycle` stage change, a real
|
|
6
|
+
// production call site. It is also re-exported from src/lib/index.js (the top-level
|
|
7
|
+
// Thatcher class delegates `thatcher.transition()`/`thatcher.getAvailableTransitions()`
|
|
8
|
+
// here -- see src/index.js).
|
|
9
|
+
//
|
|
10
|
+
// xstate-workflow-engine.js implements the SAME job (same config shape, same
|
|
11
|
+
// validateTransition/getAvailableTransitions/transition/getStateField/
|
|
12
|
+
// getStageLabels/getStateLocks/getStateActions exports, nearly line-for-line
|
|
13
|
+
// duplicated validation branches) PLUS a real xstate machine + live per-entity
|
|
14
|
+
// actors on top. Despite the more capable implementation, as of this writing its
|
|
15
|
+
// only importers repo-wide are a debug route (src/app/api/debug/[[...path]]/route.js,
|
|
16
|
+
// which hard-disables itself when NODE_ENV=production) and a root-level manual
|
|
17
|
+
// test.js script -- neither is a production call site. So xstate-workflow-engine.js
|
|
18
|
+
// does NOT currently supersede this file; if anything the reverse holds (this file
|
|
19
|
+
// is live, that one is dormant/debug-only). Re-check import sites before treating
|
|
20
|
+
// either as the "old" one.
|
|
21
|
+
import { get, update, create } from './busybase/store.js';
|
|
2
22
|
import { getConfigEngineSync } from './config-generator-engine.js';
|
|
3
23
|
import { executeHook } from './hook-engine.js';
|
|
4
|
-
import { AppError } from './
|
|
24
|
+
import { AppError } from './errors/index.js';
|
|
5
25
|
import { HTTP } from '../config/constants.js';
|
|
6
26
|
import { createLogger } from './logger.js';
|
|
7
27
|
|
|
@@ -137,7 +157,7 @@ export async function transition(entityType, entityId, workflowName, toState, us
|
|
|
137
157
|
}
|
|
138
158
|
|
|
139
159
|
// Dynamically import write engine to avoid circular dependency
|
|
140
|
-
const { update: updateRecord } = await import('./busybase
|
|
160
|
+
const { update: updateRecord } = await import('./busybase/store.js');
|
|
141
161
|
const updated = await updateRecord(entityType, entityId, updates, user);
|
|
142
162
|
|
|
143
163
|
executeHook(`transition:${entityType}`, {
|
|
@@ -1,7 +1,23 @@
|
|
|
1
|
+
// xstate-backed workflow engine: reads the SAME `config.workflows[name]`
|
|
2
|
+
// definition as workflow-engine.js and duplicates its plain-function exports
|
|
3
|
+
// (validateTransition/getAvailableTransitions/transition/getStateField/
|
|
4
|
+
// getStageLabels/getStateLocks/getStateActions, nearly line-for-line the same
|
|
5
|
+
// validation logic) but additionally compiles a real xstate machine
|
|
6
|
+
// (compileWorkflow) and manages live per-entity actors (createActorForEntity/
|
|
7
|
+
// sendEvent/getSnapshot/getActiveActors).
|
|
8
|
+
//
|
|
9
|
+
// NOT currently the live path. As of this writing its only importers repo-wide
|
|
10
|
+
// are src/app/api/debug/[[...path]]/route.js (getXStateWorkflowEngine -- and that
|
|
11
|
+
// whole route hard-disables itself when NODE_ENV=production) and a root-level
|
|
12
|
+
// manual `test.js` script outside src/ -- neither is a production call site.
|
|
13
|
+
// workflow-engine.js is the one actually wired into production (called from
|
|
14
|
+
// src/lib/events-engine.js and re-exported by src/index.js's Thatcher class).
|
|
15
|
+
// This file does NOT supersede workflow-engine.js; treat it as a dormant/
|
|
16
|
+
// debug-only alternate implementation until a real call site adopts it.
|
|
1
17
|
import { createMachine, createActor, assign, fromPromise, sendParent } from 'xstate';
|
|
2
18
|
import { getConfigEngineSync } from './config-generator-engine.js';
|
|
3
19
|
import { hookEngine } from './hook-engine.js';
|
|
4
|
-
import { AppError } from './
|
|
20
|
+
import { AppError } from './errors/index.js';
|
|
5
21
|
import { HTTP } from '../config/constants.js';
|
|
6
22
|
import { createLogger } from './logger.js';
|
|
7
23
|
|
|
@@ -385,7 +401,7 @@ export function getAvailableTransitions(workflowName, currentState, user, record
|
|
|
385
401
|
}
|
|
386
402
|
|
|
387
403
|
export async function transition(entityType, entityId, workflowName, toState, user, reason = '') {
|
|
388
|
-
const { get, update: updateRecord } = await import('./busybase
|
|
404
|
+
const { get, update: updateRecord } = await import('./busybase/store.js');
|
|
389
405
|
|
|
390
406
|
const record = await get(entityType, entityId);
|
|
391
407
|
if (!record) throw new AppError('Record not found', 'NOT_FOUND', HTTP.NOT_FOUND);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Extracted from moonlanding/src/services/collaborator-role.service.js
|
|
2
2
|
|
|
3
|
-
import { list, get, update, create } from '../lib/busybase
|
|
4
|
-
import { AppError } from '../lib/
|
|
3
|
+
import { list, get, update, create } from '../lib/busybase/store.js';
|
|
4
|
+
import { AppError } from '../lib/errors/index.js';
|
|
5
5
|
import { HTTP } from '../config/constants.js';
|
|
6
6
|
|
|
7
7
|
const COLLABORATOR_ROLE_PERMISSIONS = {
|
|
@@ -11,7 +11,7 @@ export function getNotificationTemplate(name) {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export async function createNotification(notification) {
|
|
14
|
-
const { create } = await import('../lib/busybase
|
|
14
|
+
const { create } = await import('../lib/busybase/store.js');
|
|
15
15
|
return create('notification', {
|
|
16
16
|
...notification,
|
|
17
17
|
created_at: Math.floor(Date.now() / 1000),
|
|
@@ -66,14 +66,14 @@ export async function sendNotification(type, userId, context = {}, options = {})
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
export async function markNotificationRead(notificationId, userId) {
|
|
69
|
-
const { update } = await import('../lib/busybase
|
|
69
|
+
const { update } = await import('../lib/busybase/store.js');
|
|
70
70
|
return update('notification', notificationId, {
|
|
71
71
|
read_at: Math.floor(Date.now() / 1000),
|
|
72
72
|
});
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
export async function getUnreadCount(userId) {
|
|
76
|
-
const { list } = await import('../lib/busybase
|
|
76
|
+
const { list } = await import('../lib/busybase/store.js');
|
|
77
77
|
return (await list('notification', {
|
|
78
78
|
user_id: userId,
|
|
79
79
|
read_at: null,
|
|
@@ -83,13 +83,13 @@ export async function getUnreadCount(userId) {
|
|
|
83
83
|
// Resolve a `recipients` token (a role/group name used across events-engine.js
|
|
84
84
|
// call sites, e.g. 'client_users', 'team_members', 'collaborator') to a list of
|
|
85
85
|
// user records to notify. There is no dedicated recipient-resolution engine in
|
|
86
|
-
// this codebase yet, so this composes the existing busybase
|
|
86
|
+
// this codebase yet, so this composes the existing busybase/store queries the
|
|
87
87
|
// same way the rest of the app looks up users -- a single explicit user object
|
|
88
88
|
// on the context (context.recipientUser / a matching *_id field) is honored
|
|
89
89
|
// directly, and a bare role/group token that cannot be resolved to concrete
|
|
90
90
|
// users is logged and skipped rather than silently guessed at.
|
|
91
91
|
async function resolveRecipients(recipients, context) {
|
|
92
|
-
const { list, get } = await import('../lib/busybase
|
|
92
|
+
const { list, get } = await import('../lib/busybase/store.js');
|
|
93
93
|
|
|
94
94
|
if (context.recipientUser) return [context.recipientUser];
|
|
95
95
|
if (context.collaborator?.email) return [context.collaborator];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Adapted from moonlanding/src/services/permission.service.js
|
|
2
2
|
|
|
3
3
|
import { getCollaboratorRole, checkCollaboratorAccess } from '../services/collaborator-role.service.js';
|
|
4
|
-
import { PermissionError } from '../lib/
|
|
4
|
+
import { PermissionError } from '../lib/errors/index.js';
|
|
5
5
|
import { getConfigEngineSync } from '../lib/config-generator-engine.js';
|
|
6
6
|
|
|
7
7
|
class PermissionService {
|
package/src/ui/format-helpers.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { STAGE_COLORS, STATUS_COLORS } from './render-helpers.js';
|
|
2
|
+
export { esc } from './render-helpers.js';
|
|
2
3
|
|
|
3
4
|
export function formatDate(ts, opts) {
|
|
4
5
|
if (!ts) return '-';
|
|
@@ -129,7 +130,3 @@ export function emptyState(message, iconName) {
|
|
|
129
130
|
<div style="font-size:0.88rem;font-weight:500">${message || 'No items found'}</div>
|
|
130
131
|
</div>`;
|
|
131
132
|
}
|
|
132
|
-
|
|
133
|
-
export function esc(s) {
|
|
134
|
-
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
135
|
-
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { page } from '@/ui/layout.js';
|
|
2
2
|
import { canEdit } from '@/ui/permissions-ui.js';
|
|
3
|
-
import { reviewZoneNav } from '@/ui/review
|
|
3
|
+
import { reviewZoneNav } from '@/ui/review/zone-nav.js';
|
|
4
4
|
import { esc } from '@/ui/render-helpers.js';
|
|
5
5
|
import { SPACING, renderStatsRow, renderPageHeader, renderButton, renderEmptyState } from '@/ui/spacing-system.js';
|
|
6
6
|
|