thatcher 1.0.7 → 1.0.34
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 +4 -16
- package/package.json +12 -18
- package/src/adapters/google-auth.js +4 -43
- package/src/adapters/google-drive.js +12 -67
- package/src/app/api/audit/dashboard/route.js +14 -11
- package/src/app/api/audit/logs/route.js +2 -2
- package/src/app/api/audit/permissions/[id]/route.js +6 -3
- package/src/app/api/audit/permissions/route.js +10 -7
- package/src/app/api/audit/permissions/stats/route.js +8 -5
- package/src/app/api/audit/route.js +4 -4
- package/src/app/api/audit/stats/route.js +1 -1
- package/src/app/api/auth/google/callback/route.js +5 -2
- package/src/app/api/auth/google/route.js +4 -1
- package/src/app/api/auth/login/route.js +5 -2
- package/src/app/api/auth/mwr-bridge/route.js +11 -21
- package/src/app/api/auth/password-reset/route.js +15 -14
- package/src/app/api/cron/trigger/route.js +4 -1
- package/src/app/api/debug/[[...path]]/route.js +6 -5
- package/src/app/api/debug/sqlite/route.js +7 -12
- package/src/app/api/debug/sync/route.js +5 -5
- package/src/app/api/domains/route.js +4 -1
- package/src/app/api/email/allocate/batch/route.js +19 -29
- package/src/app/api/email/allocate/route.js +16 -17
- package/src/app/api/email/receive/route.js +31 -42
- package/src/app/api/email/send/route.js +28 -16
- package/src/app/api/email/unallocated/route.js +10 -13
- package/src/app/api/files/[id]/route.js +4 -1
- package/src/app/api/formula/[[...path]]/route.js +0 -4
- package/src/app/api/health/route.js +10 -9
- package/src/app/api/metrics/route.js +1 -1
- package/src/cli.js +4 -4
- package/src/config/config-loader.js +1 -21
- package/src/config/constants.js +0 -35
- package/src/config/env.js +0 -30
- package/src/config/spec-helpers.js +6 -95
- package/src/engine.js +23 -0
- package/src/engine.server.js +8 -78
- package/src/index.js +42 -67
- package/src/lib/alert-manager.js +7 -3
- package/src/lib/api-helpers.js +4 -3
- package/src/lib/api.js +10 -10
- package/src/lib/audit-logger-enhanced.js +77 -83
- package/src/lib/auth-middleware.js +1 -32
- package/src/lib/auth-route-helpers.js +5 -0
- package/src/lib/busybase-adapter.js +0 -5
- package/src/lib/busybase-audit-reads.js +142 -0
- package/src/lib/busybase-audit.js +41 -0
- package/src/lib/busybase-lucia-adapter.js +72 -0
- package/src/lib/busybase-store.js +338 -0
- package/src/lib/config-field-helpers.js +0 -17
- package/src/lib/config-generator-engine.js +19 -36
- package/src/lib/config-helpers.js +0 -21
- package/src/lib/crud-action-helpers.js +2 -2
- package/src/lib/crud-factory.js +1 -16
- package/src/lib/crud-handlers.js +17 -52
- package/src/lib/csrf-protection.js +5 -2
- package/src/lib/date-utils.js +3 -127
- package/src/lib/debug-registry.js +0 -5
- package/src/lib/domain-loader.js +4 -1
- package/src/lib/email-sender.js +25 -23
- package/src/lib/error-handler.js +5 -41
- package/src/lib/error-recovery.js +10 -7
- package/src/lib/error-resilience.js +7 -5
- package/src/lib/events-engine.js +9 -6
- package/src/lib/export-sink.js +0 -5
- package/src/lib/field-iterator.js +7 -76
- package/src/lib/field-registry.js +0 -22
- package/src/lib/generic-crud-handler.js +0 -16
- package/src/lib/hook-engine.js +5 -53
- package/src/lib/hot-reload/checkpoint.js +5 -2
- package/src/lib/hot-reload/promise-container.js +7 -3
- package/src/lib/hot-reload/route-wrapper.js +4 -1
- package/src/lib/hot-reload/supervisor.js +6 -3
- package/src/lib/hyperformula-service.js +0 -5
- package/src/lib/id-helpers.js +10 -0
- package/src/lib/index.js +4 -7
- package/src/lib/log-aggregator.js +0 -1
- package/src/lib/logger.js +0 -15
- package/src/lib/metrics-collector.js +2 -46
- package/src/lib/next-compat.js +15 -1
- package/src/lib/observability-bootstrap.js +0 -5
- package/src/lib/perf-monitor.js +4 -1
- package/src/lib/perf-profiler.js +0 -5
- package/src/lib/query-string-adapter.js +5 -47
- package/src/lib/realtime-server.js +4 -26
- package/src/lib/request-tracing.js +0 -5
- package/src/lib/resource-monitor.js +4 -1
- package/src/lib/response-formatter.js +0 -38
- package/src/lib/route-helpers.js +3 -1
- package/src/lib/safe-json.js +5 -1
- package/src/lib/server-bootstrap.js +10 -9
- package/src/lib/stage-pipeline.js +2 -2
- package/src/lib/state-transport-client.js +4 -1
- package/src/lib/static-server.js +2 -2
- package/src/lib/status-helpers.js +2 -51
- package/src/lib/tracing.js +0 -5
- package/src/lib/utils.js +6 -67
- package/src/lib/validate.js +5 -88
- package/src/lib/validators.js +0 -38
- package/src/lib/with-audit-logging.js +1 -1
- package/src/lib/workflow-engine.js +10 -77
- package/src/lib/xstate-workflow-engine.js +3 -9
- package/src/plugins/index.js +5 -3
- package/src/server/server.js +26 -27
- package/src/services/collaborator-role.service.js +21 -77
- package/src/services/email-sender.js +7 -34
- package/src/services/notification-engine.js +12 -33
- package/src/services/permission.service.js +10 -73
- package/src/ui/advanced-search-renderer.js +3 -2
- package/src/ui/advanced-widgets.js +1 -1
- package/src/ui/auth-pages.js +10 -9
- package/src/ui/auth-styles.js +1 -1
- package/src/ui/client.js +1 -1
- package/src/ui/component-engine.js +1 -1
- package/src/ui/dashboard-renderer.js +5 -5
- package/src/ui/dialog-engine.js +6 -9
- package/src/ui/dialog-factory.js +2 -2
- package/src/ui/engagement-cards.js +7 -7
- package/src/ui/engagement-dialogs.js +2 -3
- package/src/ui/engagement-grid-renderer.js +20 -19
- package/src/ui/entity-renderer.js +56 -42
- package/src/ui/file-dialogs.js +25 -9
- package/src/ui/flexup-report-renderer.js +21 -18
- package/src/ui/format-helpers.js +27 -28
- package/src/ui/highlight-threading-renderer.js +9 -9
- package/src/ui/job-management-renderer.js +18 -16
- package/src/ui/layout.js +51 -25
- package/src/ui/ml-console-renderer.js +15 -16
- package/src/ui/monitoring-dashboard-client.js +9 -8
- package/src/ui/notifications-renderer.js +11 -10
- package/src/ui/page-handler-admin.js +32 -23
- package/src/ui/page-handler-helpers.js +27 -23
- package/src/ui/page-handler-reviews.js +71 -35
- package/src/ui/page-handler-rfi.js +28 -14
- package/src/ui/page-handler.js +65 -48
- package/src/ui/picker-dialogs.js +26 -19
- package/src/ui/render-helpers.js +84 -46
- package/src/ui/review-comparison-renderer.js +8 -7
- package/src/ui/review-detail-panels.js +8 -9
- package/src/ui/review-detail-renderer.js +3 -3
- package/src/ui/review-detail-script.js +13 -12
- package/src/ui/review-mwr-renderer.js +6 -5
- package/src/ui/review-renderer.js +7 -6
- package/src/ui/review-widgets.js +3 -3
- package/src/ui/rfi-detail-renderer.js +37 -20
- package/src/ui/rfi-report-renderer.js +14 -9
- package/src/ui/settings-renderer-advanced.js +21 -18
- package/src/ui/settings-renderer-advanced2.js +20 -19
- package/src/ui/settings-renderer-teams.js +4 -4
- package/src/ui/settings-renderer.js +4 -4
- package/src/ui/spacing-system.js +5 -2
- package/src/ui/standalone-login.js +2 -2
- package/src/ui/styles.css +2 -1
- package/src/ui/styles2.css +75 -5
- package/src/ui/test-page.js +4 -4
- package/src/ui/widgets.js +20 -15
- package/src/lib/audit-logger.js +0 -193
- package/src/lib/database-core.js +0 -255
- package/src/lib/database-migrations.js +0 -350
- package/src/lib/db-backup.js +0 -97
- package/src/lib/error-boundary.js +0 -134
- package/src/lib/errors.js +0 -69
- package/src/lib/health-monitor.js +0 -134
- package/src/lib/index-optimizer.js +0 -96
- package/src/lib/query-engine-write.js +0 -221
- package/src/lib/query-engine.js +0 -446
- package/src/ui/collaboration-dialogs.js +0 -31
- package/src/ui/global-tags.js +0 -144
- package/src/ui/idle-logout.js +0 -156
- package/src/ui/letter-dialogs.js +0 -37
- package/src/ui/password-reset-page.js +0 -146
- package/src/ui/perf-renderer.js +0 -71
- package/src/ui/rfi-renderer.js +0 -194
- package/src/ui/validation-ui.js +0 -147
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* BusyBase data store — async replacement for the better-sqlite3 query engine.
|
|
3
|
+
*
|
|
4
|
+
* busybase is a Supabase-style async document store (LanceDB) with no SQL joins or
|
|
5
|
+
* foreign keys, so the relational behaviours of the old query-engine are reimplemented
|
|
6
|
+
* here in JS:
|
|
7
|
+
* - ref "display" fields (old LEFT JOIN) -> client-side lookup of the referenced row
|
|
8
|
+
* - soft-delete / archive filtering -> in-memory predicate on the result set
|
|
9
|
+
* - sort / limit / offset -> busybase order()/range()
|
|
10
|
+
*
|
|
11
|
+
* Every export is async (busybase is async); callers are the already-async API route
|
|
12
|
+
* handlers and crud-handlers, so the await ripple terminates at an existing boundary.
|
|
13
|
+
*
|
|
14
|
+
* Requires Bun (busybase embedded uses Bun.password + the vectordb native binding).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { getSpec } from '../config/spec-helpers.js';
|
|
18
|
+
import { RECORD_STATUS } from '../config/constants.js';
|
|
19
|
+
import { genId, now } from './id-helpers.js';
|
|
20
|
+
|
|
21
|
+
let _client = null;
|
|
22
|
+
|
|
23
|
+
export function setBusyBaseClient(client) {
|
|
24
|
+
_client = client;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function client() {
|
|
28
|
+
if (!_client) throw new Error('BusyBase store not initialised. Call setBusyBaseClient() first.');
|
|
29
|
+
return _client;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// busybase uses the entity name as the table; map the special `user` -> `users`.
|
|
33
|
+
function tableName(entity) {
|
|
34
|
+
return entity === 'user' ? 'users' : entity;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function unwrap({ data, error }, op) {
|
|
38
|
+
if (error) throw new Error(`BusyBase ${op} failed: ${error.message || error}`);
|
|
39
|
+
return data;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Resolve ref "display" fields for a set of rows the way the old LEFT JOIN did:
|
|
43
|
+
// for every ref field with a `display` spec, fetch the referenced rows and attach
|
|
44
|
+
// `<field>_display`. Batched per ref-table to avoid N+1 within a single field.
|
|
45
|
+
//
|
|
46
|
+
// getSpec returns null for raw infra tables (sessions, audit_logs, structured_logs,
|
|
47
|
+
// password_reset_tokens, mwr_bridge_tokens, email, activity_log, ...) that aren't
|
|
48
|
+
// config entities. Treat those as plain document tables: no ref-display, no
|
|
49
|
+
// soft-delete, hard CRUD. specOf() guarantees a usable shape.
|
|
50
|
+
function specOf(entity) {
|
|
51
|
+
return getSpec(entity) || { fields: {}, raw: true };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function attachRefDisplays(entity, rows) {
|
|
55
|
+
if (!rows.length) return rows;
|
|
56
|
+
const spec = specOf(entity);
|
|
57
|
+
const refFields = Object.entries(spec.fields || {}).filter(([, f]) => f.type === 'ref' && f.display);
|
|
58
|
+
if (!refFields.length) return rows;
|
|
59
|
+
|
|
60
|
+
for (const [key, f] of refFields) {
|
|
61
|
+
const refTable = tableName(f.ref);
|
|
62
|
+
const displayField = (f.display.split('.')[1]) || 'name';
|
|
63
|
+
const ids = [...new Set(rows.map(r => r[key]).filter(v => v != null))];
|
|
64
|
+
if (!ids.length) continue;
|
|
65
|
+
const refRows = unwrap(await client().from(refTable).select('*').in('id', ids), 'ref-resolve');
|
|
66
|
+
const byId = new Map(refRows.map(r => [String(r.id), r]));
|
|
67
|
+
for (const r of rows) {
|
|
68
|
+
const ref = r[key] != null ? byId.get(String(r[key])) : null;
|
|
69
|
+
if (ref) r[`${key}_display`] = ref[displayField];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return rows;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Apply soft-delete / archive default filtering in memory (no SQL WHERE).
|
|
76
|
+
function applyVisibility(spec, rows, where, options) {
|
|
77
|
+
let out = rows;
|
|
78
|
+
if (spec.fields?.status && !('status' in where) && !options.includeDeleted) {
|
|
79
|
+
out = out.filter(r => r.status !== RECORD_STATUS.DELETED);
|
|
80
|
+
}
|
|
81
|
+
if (spec.fields?.archived && !('archived' in where) && !options.includeArchived) {
|
|
82
|
+
out = out.filter(r => !r.archived || r.archived === 0);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Compile a where-object onto the busybase query builder. A scalar value is an
|
|
88
|
+
// equality (the original behaviour); an operator object opens range/set/negation
|
|
89
|
+
// filters the builder already supports (sdk.js eq/neq/gt/gte/lt/lte/in/like/ilike,
|
|
90
|
+
// and a top-level $or clause). This lets a config-driven query express "today"
|
|
91
|
+
// (created_at between), "near me" (a lat/lon bounding box), and "open" (status in
|
|
92
|
+
// the non-terminal set) without the caller hand-rolling JS filters. Operators are a
|
|
93
|
+
// small fixed allowlist; an unknown operator key throws rather than silently
|
|
94
|
+
// matching everything (a wrong filter must fail loud, not leak rows).
|
|
95
|
+
const WHERE_OPS = {
|
|
96
|
+
$eq: (b, k, v) => b.eq(k, v),
|
|
97
|
+
$ne: (b, k, v) => b.neq(k, v),
|
|
98
|
+
$gt: (b, k, v) => b.gt(k, v),
|
|
99
|
+
$gte: (b, k, v) => b.gte(k, v),
|
|
100
|
+
$lt: (b, k, v) => b.lt(k, v),
|
|
101
|
+
$lte: (b, k, v) => b.lte(k, v),
|
|
102
|
+
$in: (b, k, v) => b.in(k, Array.isArray(v) ? v : [v]),
|
|
103
|
+
$like: (b, k, v) => b.like(k, v),
|
|
104
|
+
$ilike: (b, k, v) => b.ilike(k, v),
|
|
105
|
+
};
|
|
106
|
+
function applyWhere(builder, where) {
|
|
107
|
+
for (const [k, v] of Object.entries(where)) {
|
|
108
|
+
if (v === undefined || v === null) continue;
|
|
109
|
+
// Top-level $or: an array of {field: value|operator-object} sub-clauses,
|
|
110
|
+
// OR-joined (e.g. find a case the worker reported OR is assigned to).
|
|
111
|
+
// LIMITATION: busybase's or() parser splits the compiled clause string on
|
|
112
|
+
// ',' (between sub-filters) and '.' (between field/op/value), and there is
|
|
113
|
+
// no escaping mechanism -- a value containing either character (e.g.
|
|
114
|
+
// 'Underberg, KZN' or '1.5') cannot be represented and would corrupt the
|
|
115
|
+
// clause into bogus sub-filters that silently leak or miss rows. Fail loud
|
|
116
|
+
// instead: reject such values here. Callers needing them must use a
|
|
117
|
+
// non-$or filter (eq/like) or restructure the query.
|
|
118
|
+
if (k === '$or' && Array.isArray(v)) {
|
|
119
|
+
const assertOrSafe = (val) => {
|
|
120
|
+
const s = String(val);
|
|
121
|
+
if (s.includes(',') || s.includes('.')) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`busybase applyWhere: $or sub-clause value ${JSON.stringify(s)} contains ',' or '.', ` +
|
|
124
|
+
`which busybase's or() delimiter syntax cannot escape; use a non-$or filter for this value`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return s;
|
|
128
|
+
};
|
|
129
|
+
// busybase's or() parser reads PostgREST order: field.op.value (NOT
|
|
130
|
+
// op.field.value -- a mismatched sub-clause parses to null and is
|
|
131
|
+
// silently dropped, making the $or match everything).
|
|
132
|
+
const clause = v.map(sub => Object.entries(sub).map(([sk, sv]) => {
|
|
133
|
+
if (sv && typeof sv === 'object' && !Array.isArray(sv)) {
|
|
134
|
+
const [op, ov] = Object.entries(sv)[0];
|
|
135
|
+
const bare = op.replace(/^\$/, '');
|
|
136
|
+
return `${sk}.${bare}.${Array.isArray(ov) ? ov.map(assertOrSafe).join(',') : assertOrSafe(ov)}`;
|
|
137
|
+
}
|
|
138
|
+
return `${sk}.eq.${assertOrSafe(sv)}`;
|
|
139
|
+
}).join(',')).join(',');
|
|
140
|
+
builder = builder.or(clause);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// An operator object: { $gte: x, $lt: y } applies each supported operator.
|
|
144
|
+
if (typeof v === 'object' && !Array.isArray(v)) {
|
|
145
|
+
for (const [op, ov] of Object.entries(v)) {
|
|
146
|
+
const fn = WHERE_OPS[op];
|
|
147
|
+
if (!fn) throw new Error(`busybase applyWhere: unsupported operator ${op} on field ${k}`);
|
|
148
|
+
builder = fn(builder, k, ov);
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
// A bare array value is an IN set; a scalar is equality.
|
|
153
|
+
builder = Array.isArray(v) ? builder.in(k, v) : builder.eq(k, v);
|
|
154
|
+
}
|
|
155
|
+
return builder;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function list(entity, where = {}, options = {}) {
|
|
159
|
+
const spec = specOf(entity);
|
|
160
|
+
const tbl = tableName(entity);
|
|
161
|
+
let rows = unwrap(await applyWhere(client().from(tbl).select('*'), where), 'list');
|
|
162
|
+
rows = applyVisibility(spec, rows, where, options);
|
|
163
|
+
|
|
164
|
+
// Row-access scoping: when a caller passes options.user AND the entity declares
|
|
165
|
+
// rowAccess, restrict the rows to what that user may see (their assigned cases,
|
|
166
|
+
// their team, etc.). Opt-in -- no user means the read is unchanged, so internal
|
|
167
|
+
// and admin callers are unaffected. This makes a config row_access spec actually
|
|
168
|
+
// enforced on the read path (previously list() took no user, so the spec was
|
|
169
|
+
// inert and a scoped enquiry would leak every row).
|
|
170
|
+
if (options.user && (spec.rowAccess || spec.row_access)) {
|
|
171
|
+
const { permissionService } = await import('../services/permission.service.js');
|
|
172
|
+
rows = permissionService.filterRecords(options.user, spec, rows);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Sort accepts a single {field,dir} (the original) OR an ARRAY of them for
|
|
176
|
+
// tie-broken order (e.g. [{field:'priority',dir:'DESC'},{field:'last_event_at',
|
|
177
|
+
// dir:'DESC'}]) -- so a recency-with-tiebreak list is config, not a JS sort in
|
|
178
|
+
// the caller. Each key is guarded against spec.fields; unknown keys are skipped.
|
|
179
|
+
const sortSpec = options.sort || spec.list?.defaultSort;
|
|
180
|
+
const sortKeys = (Array.isArray(sortSpec) ? sortSpec : sortSpec ? [sortSpec] : [])
|
|
181
|
+
.filter(s => s && s.field && spec.fields?.[s.field]);
|
|
182
|
+
if (sortKeys.length) {
|
|
183
|
+
rows.sort((a, b) => {
|
|
184
|
+
for (const s of sortKeys) {
|
|
185
|
+
const av = a[s.field], bv = b[s.field];
|
|
186
|
+
if (av === bv) continue;
|
|
187
|
+
const desc = (s.dir || 'ASC').toUpperCase() === 'DESC';
|
|
188
|
+
return (av > bv ? 1 : -1) * (desc ? -1 : 1);
|
|
189
|
+
}
|
|
190
|
+
return 0;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
if (options.offset || options.limit) {
|
|
194
|
+
const off = parseInt(options.offset || 0, 10);
|
|
195
|
+
const lim = options.limit ? parseInt(options.limit, 10) : rows.length;
|
|
196
|
+
rows = rows.slice(off, off + lim);
|
|
197
|
+
}
|
|
198
|
+
return attachRefDisplays(entity, rows);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function count(entity, where = {}, options = {}) {
|
|
202
|
+
const spec = specOf(entity);
|
|
203
|
+
const tbl = tableName(entity);
|
|
204
|
+
let rows = unwrap(await applyWhere(client().from(tbl).select('*'), where), 'count');
|
|
205
|
+
rows = applyVisibility(spec, rows, where, options);
|
|
206
|
+
return rows.length;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function listWithPagination(entity, where = {}, page = 1, pageSize = 50) {
|
|
210
|
+
const finalPage = Math.max(1, page);
|
|
211
|
+
const total = await count(entity, where);
|
|
212
|
+
const items = await list(entity, where, { offset: (finalPage - 1) * pageSize, limit: pageSize });
|
|
213
|
+
return { items, pagination: { page: finalPage, pageSize, total, totalPages: Math.ceil(total / pageSize) } };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function get(entity, id) {
|
|
217
|
+
const tbl = tableName(entity);
|
|
218
|
+
const row = unwrap(await client().from(tbl).select('*').eq('id', id).maybeSingle(), 'get');
|
|
219
|
+
if (!row) return null;
|
|
220
|
+
const [withDisplay] = await attachRefDisplays(entity, [row]);
|
|
221
|
+
return withDisplay;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function getBy(entity, field, value) {
|
|
225
|
+
const tbl = tableName(entity);
|
|
226
|
+
return unwrap(await client().from(tbl).select('*').eq(field, value).maybeSingle(), 'getBy');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function create(entity, data, user) {
|
|
230
|
+
const spec = specOf(entity);
|
|
231
|
+
const tbl = tableName(entity);
|
|
232
|
+
|
|
233
|
+
const record = {
|
|
234
|
+
...data,
|
|
235
|
+
id: data.id || genId(),
|
|
236
|
+
created_by: user?.id || '',
|
|
237
|
+
created_at: now(),
|
|
238
|
+
updated_at: 0,
|
|
239
|
+
status: data.status || RECORD_STATUS.ACTIVE,
|
|
240
|
+
};
|
|
241
|
+
for (const [key, field] of Object.entries(spec.fields || {})) {
|
|
242
|
+
if (field.auto === 'uuid' && !record[key]) record[key] = genId();
|
|
243
|
+
if (field.auto === 'timestamp' && !record[key]) record[key] = now();
|
|
244
|
+
}
|
|
245
|
+
// LanceDB cannot infer a column's type from a null on first insert; coerce any
|
|
246
|
+
// null/undefined to a typed sentinel ('' for strings) so the insert schema is stable.
|
|
247
|
+
for (const k of Object.keys(record)) {
|
|
248
|
+
if (record[k] === null || record[k] === undefined) record[k] = '';
|
|
249
|
+
}
|
|
250
|
+
unwrap(await client().from(tbl).insert(record), 'create');
|
|
251
|
+
// Always return the locally-constructed record: it holds the genId we put in
|
|
252
|
+
// the TEXT id column. The store's insert() may return a rowid/driver shape, so
|
|
253
|
+
// trusting `created` would hand callers the wrong id.
|
|
254
|
+
return record;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function update(entity, id, data) {
|
|
258
|
+
const spec = specOf(entity);
|
|
259
|
+
const tbl = tableName(entity);
|
|
260
|
+
|
|
261
|
+
const existing = await get(entity, id);
|
|
262
|
+
if (!existing) throw new Error(`${entity} with id ${id} not found`);
|
|
263
|
+
|
|
264
|
+
const patch = { ...data, updated_at: now() };
|
|
265
|
+
unwrap(await client().from(tbl).update(patch).eq('id', id), 'update');
|
|
266
|
+
return get(entity, id);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function remove(entity, id) {
|
|
270
|
+
const spec = specOf(entity);
|
|
271
|
+
const tbl = tableName(entity);
|
|
272
|
+
|
|
273
|
+
const existing = await get(entity, id);
|
|
274
|
+
if (!existing) throw new Error(`${entity} with id ${id} not found`);
|
|
275
|
+
|
|
276
|
+
if (spec.fields && 'status' in spec.fields) {
|
|
277
|
+
unwrap(await client().from(tbl).update({ status: RECORD_STATUS.DELETED, updated_at: now() }).eq('id', id), 'soft-delete');
|
|
278
|
+
return { ...existing, status: RECORD_STATUS.DELETED };
|
|
279
|
+
}
|
|
280
|
+
if (spec.fields && 'archived' in spec.fields) {
|
|
281
|
+
unwrap(await client().from(tbl).update({ archived: 1, updated_at: now() }).eq('id', id), 'archive');
|
|
282
|
+
return { ...existing, archived: 1 };
|
|
283
|
+
}
|
|
284
|
+
unwrap(await client().from(tbl).delete().eq('id', id), 'delete');
|
|
285
|
+
return existing;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export async function bulkCreate(entity, records, user) {
|
|
289
|
+
const out = [];
|
|
290
|
+
for (const data of records) out.push(await create(entity, data, user));
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Substring search across the entity's text-ish fields (busybase has no FTS,
|
|
295
|
+
// so this is the in-memory equivalent of the old LIKE fallback). Returns visible rows only.
|
|
296
|
+
export async function search(entity, query, where = {}, options = {}) {
|
|
297
|
+
const spec = specOf(entity);
|
|
298
|
+
const rows = await list(entity, where, { ...options, limit: undefined, offset: undefined });
|
|
299
|
+
const q = String(query || '').toLowerCase();
|
|
300
|
+
if (!q) return rows;
|
|
301
|
+
const fields = Object.keys(spec.fields || {}).filter(
|
|
302
|
+
f => ['text', 'textarea', 'email'].includes(spec.fields[f].type)
|
|
303
|
+
);
|
|
304
|
+
let matched = rows.filter(r => fields.some(f => String(r[f] ?? '').toLowerCase().includes(q)));
|
|
305
|
+
if (options.offset || options.limit) {
|
|
306
|
+
const off = parseInt(options.offset || 0, 10);
|
|
307
|
+
const lim = options.limit ? parseInt(options.limit, 10) : matched.length;
|
|
308
|
+
matched = matched.slice(off, off + lim);
|
|
309
|
+
}
|
|
310
|
+
return matched;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function searchWithPagination(entity, query, where = {}, page = 1, pageSize = null) {
|
|
314
|
+
const spec = specOf(entity);
|
|
315
|
+
const finalPageSize = pageSize || spec.list?.pageSize || 50;
|
|
316
|
+
const finalPage = Math.max(1, page);
|
|
317
|
+
const all = await search(entity, query, where);
|
|
318
|
+
const total = all.length;
|
|
319
|
+
const items = all.slice((finalPage - 1) * finalPageSize, finalPage * finalPageSize);
|
|
320
|
+
return { items, pagination: { page: finalPage, pageSize: finalPageSize, total, totalPages: Math.ceil(total / finalPageSize) } };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Children of a parent via foreign-key field (old: WHERE fk = ? AND status != deleted).
|
|
324
|
+
export async function getChildren(parentEntity, parentId, childDef) {
|
|
325
|
+
const fk = childDef.fk || childDef.foreignKey || `${parentEntity}_id`;
|
|
326
|
+
return list(childDef.entity, { [fk]: parentId });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function batchGetChildren(parentEntity, parentId, childSpecs) {
|
|
330
|
+
const defs = Array.isArray(childSpecs)
|
|
331
|
+
? childSpecs.map(e => [e, { entity: e }])
|
|
332
|
+
: Object.entries(childSpecs);
|
|
333
|
+
const out = {};
|
|
334
|
+
for (const [key, def] of defs) {
|
|
335
|
+
out[key] = await getChildren(parentEntity, parentId, def.entity ? def : { entity: key, ...def });
|
|
336
|
+
}
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Config Field Helpers - Field definition processing and validation
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
1
|
import { getSpec } from '../config/spec-helpers.js';
|
|
6
2
|
|
|
7
|
-
/**
|
|
8
|
-
* Generate fields from field overrides
|
|
9
|
-
*/
|
|
10
3
|
export function generateFieldsFromOverrides(overrides, baseFields) {
|
|
11
4
|
const result = { ...baseFields };
|
|
12
5
|
if (!overrides) return result;
|
|
@@ -22,13 +15,9 @@ export function generateFieldsFromOverrides(overrides, baseFields) {
|
|
|
22
15
|
return result;
|
|
23
16
|
}
|
|
24
17
|
|
|
25
|
-
/**
|
|
26
|
-
* Generate default field definitions for implicit fields
|
|
27
|
-
*/
|
|
28
18
|
export function generateDefaultFields(entityDef) {
|
|
29
19
|
const defaults = {};
|
|
30
20
|
|
|
31
|
-
// Add common fields
|
|
32
21
|
if (!entityDef.fields) return defaults;
|
|
33
22
|
|
|
34
23
|
for (const [key, field] of Object.entries(entityDef.fields)) {
|
|
@@ -45,9 +34,6 @@ export function generateDefaultFields(entityDef) {
|
|
|
45
34
|
return defaults;
|
|
46
35
|
}
|
|
47
36
|
|
|
48
|
-
/**
|
|
49
|
-
* Build enum options from various sources (array, ref to status enum, etc.)
|
|
50
|
-
*/
|
|
51
37
|
export function buildEnumOptions(fieldDef, entityName, configEngine) {
|
|
52
38
|
if (Array.isArray(fieldDef.options)) {
|
|
53
39
|
return fieldDef.options.map(o => typeof o === 'object' ? o.value : o);
|
|
@@ -76,9 +62,6 @@ export function buildEnumOptions(fieldDef, entityName, configEngine) {
|
|
|
76
62
|
return [];
|
|
77
63
|
}
|
|
78
64
|
|
|
79
|
-
/**
|
|
80
|
-
* Ensure all fields have labels
|
|
81
|
-
*/
|
|
82
65
|
export function ensureFieldLabels(fields) {
|
|
83
66
|
const result = {};
|
|
84
67
|
for (const [key, field] of Object.entries(fields)) {
|
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Config Generator Engine - Resolves entity specs from YAML config
|
|
3
|
-
* Core of Thatcher's configuration-driven architecture
|
|
4
|
-
* Adapted from moonlanding/src/lib/config-generator-engine.js
|
|
5
|
-
*/
|
|
1
|
+
// Adapted from moonlanding/src/lib/config-generator-engine.js
|
|
6
2
|
|
|
7
3
|
import yaml from 'js-yaml';
|
|
8
4
|
import { LRUCache, deepFreeze, deepClone, recursiveResolve } from './config-helpers.js';
|
|
@@ -17,9 +13,6 @@ export class ConfigGeneratorEngine {
|
|
|
17
13
|
this._plugins = new Map();
|
|
18
14
|
}
|
|
19
15
|
|
|
20
|
-
/**
|
|
21
|
-
* Register a plugin to extend an entity
|
|
22
|
-
*/
|
|
23
16
|
registerPlugin(entityName, plugin = {}) {
|
|
24
17
|
if (!entityName || typeof entityName !== 'string') {
|
|
25
18
|
throw new Error('[ConfigGeneratorEngine] registerPlugin: entityName required');
|
|
@@ -232,10 +225,6 @@ export class ConfigGeneratorEngine {
|
|
|
232
225
|
return this;
|
|
233
226
|
}
|
|
234
227
|
|
|
235
|
-
/**
|
|
236
|
-
* Generate complete entity specification from config
|
|
237
|
-
* This is the main method that builds the spec used by CRUD, UI, etc.
|
|
238
|
-
*/
|
|
239
228
|
generateEntitySpec(entityName) {
|
|
240
229
|
if (!entityName) throw new Error('[config] generateEntitySpec: entityName required');
|
|
241
230
|
|
|
@@ -291,24 +280,35 @@ export class ConfigGeneratorEngine {
|
|
|
291
280
|
options: {},
|
|
292
281
|
};
|
|
293
282
|
|
|
294
|
-
// Merge field overrides
|
|
295
283
|
if (entityDef.field_overrides) {
|
|
296
284
|
spec.field_overrides = recursiveResolve(entityDef.field_overrides, config);
|
|
297
285
|
}
|
|
298
286
|
|
|
299
|
-
// Build fields from definition + overrides + plugin additions
|
|
300
287
|
const baseFields = entityDef.fields || {};
|
|
301
288
|
const plugin = this._plugins.get(entityName);
|
|
302
289
|
const pluginFields = plugin?.fields || {};
|
|
303
290
|
|
|
304
|
-
// Combine and process fields
|
|
305
291
|
const allFields = {
|
|
306
292
|
...baseFields,
|
|
307
293
|
...spec.field_overrides,
|
|
308
294
|
...pluginFields,
|
|
309
295
|
};
|
|
310
296
|
|
|
311
|
-
//
|
|
297
|
+
// Auto-inject the system fields every entity needs (id is the primary key;
|
|
298
|
+
// created_at/created_by/updated_at/status are written by the store on every
|
|
299
|
+
// create/update). Declared fields of the same name win, so a config can
|
|
300
|
+
// still override e.g. status into an enum.
|
|
301
|
+
const SYSTEM_FIELDS = {
|
|
302
|
+
id: { type: 'id', readonly: true },
|
|
303
|
+
created_at: { type: 'timestamp', readonly: true },
|
|
304
|
+
created_by: { type: 'text', readonly: true },
|
|
305
|
+
updated_at: { type: 'timestamp', readonly: true },
|
|
306
|
+
status: { type: 'text' },
|
|
307
|
+
};
|
|
308
|
+
for (const [key, field] of Object.entries(SYSTEM_FIELDS)) {
|
|
309
|
+
if (!(key in allFields)) allFields[key] = field;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
312
|
for (const [key, field] of Object.entries(allFields)) {
|
|
313
313
|
spec.fields[key] = {
|
|
314
314
|
type: field.type || 'text',
|
|
@@ -330,7 +330,6 @@ export class ConfigGeneratorEngine {
|
|
|
330
330
|
};
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
-
// Permission matrix
|
|
334
333
|
if (entityDef.permission_template) {
|
|
335
334
|
const matrix = this.getPermissionTemplate(entityDef.permission_template);
|
|
336
335
|
const access = {};
|
|
@@ -349,7 +348,6 @@ export class ConfigGeneratorEngine {
|
|
|
349
348
|
spec.permissions = roleActions;
|
|
350
349
|
}
|
|
351
350
|
|
|
352
|
-
// Workflow binding
|
|
353
351
|
if (entityDef.workflow) {
|
|
354
352
|
const wf = this.getWorkflow(entityDef.workflow);
|
|
355
353
|
spec.workflow = wf;
|
|
@@ -368,7 +366,6 @@ export class ConfigGeneratorEngine {
|
|
|
368
366
|
spec.variants = deepClone(entityDef.variants);
|
|
369
367
|
}
|
|
370
368
|
|
|
371
|
-
// List options
|
|
372
369
|
if (entityDef.list) {
|
|
373
370
|
spec.list = {
|
|
374
371
|
defaultSort: entityDef.list.defaultSort || { field: 'created_at', dir: 'desc' },
|
|
@@ -406,9 +403,6 @@ export class ConfigGeneratorEngine {
|
|
|
406
403
|
}
|
|
407
404
|
}
|
|
408
405
|
|
|
409
|
-
/**
|
|
410
|
-
* Get or create singleton config engine
|
|
411
|
-
*/
|
|
412
406
|
let _singleton = null;
|
|
413
407
|
|
|
414
408
|
export function getConfigEngineSync() {
|
|
@@ -429,11 +423,6 @@ export async function getConfigEngine() {
|
|
|
429
423
|
return getConfigEngineSync();
|
|
430
424
|
}
|
|
431
425
|
|
|
432
|
-
/**
|
|
433
|
-
* Initialize config engine from config source
|
|
434
|
-
* @param {string|object} configSource
|
|
435
|
-
* @returns {ConfigGeneratorEngine}
|
|
436
|
-
*/
|
|
437
426
|
export async function initConfig(configSource) {
|
|
438
427
|
const { loadConfig } = await import('../config/config-loader.js');
|
|
439
428
|
const { validateConfig, getConfigWithDefaults } = await import('../config/config-loader.js');
|
|
@@ -450,20 +439,14 @@ export async function initConfig(configSource) {
|
|
|
450
439
|
return _singleton;
|
|
451
440
|
}
|
|
452
441
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
* which constructs the engine from an already-parsed config object). This is what
|
|
456
|
-
* makes getConfigEngineSync() resolve for the server/plugin/spec paths.
|
|
457
|
-
* @param {ConfigGeneratorEngine} engine
|
|
458
|
-
*/
|
|
442
|
+
// Used by the bootstrap in index.js which constructs the engine from an already-parsed
|
|
443
|
+
// config object; makes getConfigEngineSync() resolve for the server/plugin/spec paths.
|
|
459
444
|
export function setConfigEngine(engine) {
|
|
460
445
|
_singleton = engine;
|
|
461
446
|
return _singleton;
|
|
462
447
|
}
|
|
463
448
|
|
|
464
|
-
|
|
465
|
-
* Reset singleton (for hot reload)
|
|
466
|
-
*/
|
|
449
|
+
// Called during hot reload to force re-initialization.
|
|
467
450
|
export function resetConfigEngine() {
|
|
468
451
|
_singleton = null;
|
|
469
452
|
}
|
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Configuration Helpers - Utilities for config processing
|
|
3
|
-
* LRUCache, deep cloning, freezing, recursive resolution
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* LRU Cache implementation
|
|
8
|
-
*/
|
|
9
1
|
export class LRUCache {
|
|
10
2
|
constructor(maxSize = 100) {
|
|
11
3
|
this.maxSize = maxSize;
|
|
@@ -39,9 +31,6 @@ export class LRUCache {
|
|
|
39
31
|
}
|
|
40
32
|
}
|
|
41
33
|
|
|
42
|
-
/**
|
|
43
|
-
* Deep freeze object (immutable)
|
|
44
|
-
*/
|
|
45
34
|
export function deepFreeze(obj) {
|
|
46
35
|
if (obj === null || typeof obj !== 'object') return obj;
|
|
47
36
|
if (Object.isFrozen(obj)) return obj;
|
|
@@ -55,9 +44,6 @@ export function deepFreeze(obj) {
|
|
|
55
44
|
return obj;
|
|
56
45
|
}
|
|
57
46
|
|
|
58
|
-
/**
|
|
59
|
-
* Deep clone object
|
|
60
|
-
*/
|
|
61
47
|
export function deepClone(obj) {
|
|
62
48
|
if (obj === null || typeof obj !== 'object') return obj;
|
|
63
49
|
if (Array.isArray(obj)) return obj.map(deepClone);
|
|
@@ -69,10 +55,6 @@ export function deepClone(obj) {
|
|
|
69
55
|
return cloned;
|
|
70
56
|
}
|
|
71
57
|
|
|
72
|
-
/**
|
|
73
|
-
* Recursively resolve string template references in config
|
|
74
|
-
* Replaces ${ref.path} with value from config
|
|
75
|
-
*/
|
|
76
58
|
export function recursiveResolve(value, config) {
|
|
77
59
|
if (typeof value === 'string') {
|
|
78
60
|
return value.replace(/\$\{([^}]+)\}/g, (_, path) => {
|
|
@@ -104,9 +86,6 @@ export function recursiveResolve(value, config) {
|
|
|
104
86
|
return value;
|
|
105
87
|
}
|
|
106
88
|
|
|
107
|
-
/**
|
|
108
|
-
* Merge objects deeply
|
|
109
|
-
*/
|
|
110
89
|
export function deepMerge(target, source) {
|
|
111
90
|
const output = { ...target };
|
|
112
91
|
for (const key of Object.keys(source)) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { AppError } from '@/lib/
|
|
1
|
+
import { AppError } from '@/lib/error-handler';
|
|
2
2
|
import { HTTP } from '@/config/constants';
|
|
3
|
-
import { now } from '@/lib/
|
|
3
|
+
import { now } from '@/lib/id-helpers';
|
|
4
4
|
|
|
5
5
|
const ACTION_FIELD_MAPS = {
|
|
6
6
|
resolve_highlight: { status: 'resolved', color: 'green' },
|
package/src/lib/crud-factory.js
CHANGED
|
@@ -1,23 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
* CRUD Factory - Creates per-entity CRUD handler objects
|
|
3
|
-
* Adapted from moonlanding/src/lib/crud-factory.js
|
|
4
|
-
*/
|
|
1
|
+
// Adapted from moonlanding/src/lib/crud-factory.js
|
|
5
2
|
|
|
6
3
|
import { createCrudHandlers as buildHandlers } from './crud-handlers.js';
|
|
7
4
|
import { getConfigEngineSync } from './config-generator-engine.js';
|
|
8
5
|
|
|
9
|
-
/**
|
|
10
|
-
* Create CRUD handlers for an entity
|
|
11
|
-
* @param {string} entityName
|
|
12
|
-
* @returns {object} Handler functions (GET, POST, PUT, PATCH, DELETE)
|
|
13
|
-
*/
|
|
14
6
|
export function createCrudHandlers(entityName) {
|
|
15
7
|
const configEngine = getConfigEngineSync();
|
|
16
8
|
const spec = configEngine.generateEntitySpec(entityName);
|
|
17
9
|
|
|
18
10
|
return {
|
|
19
11
|
GET: async (request, context) => {
|
|
20
|
-
// Handle list or single get based on presence of id
|
|
21
12
|
const { params } = context;
|
|
22
13
|
const id = params?.id;
|
|
23
14
|
|
|
@@ -55,11 +46,6 @@ export function createCrudHandlers(entityName) {
|
|
|
55
46
|
};
|
|
56
47
|
}
|
|
57
48
|
|
|
58
|
-
/**
|
|
59
|
-
* Create generic controller for an entity
|
|
60
|
-
* @param {string} entityName
|
|
61
|
-
* @returns {object}
|
|
62
|
-
*/
|
|
63
49
|
export function createEntityController(entityName) {
|
|
64
50
|
const configEngine = getConfigEngineSync();
|
|
65
51
|
const spec = configEngine.generateEntitySpec(entityName);
|
|
@@ -72,7 +58,6 @@ export function createEntityController(entityName) {
|
|
|
72
58
|
update: handlers.update,
|
|
73
59
|
delete: handlers.remove,
|
|
74
60
|
|
|
75
|
-
// Custom actions from config
|
|
76
61
|
async customAction(action, request, context) {
|
|
77
62
|
const { params } = context;
|
|
78
63
|
const id = params?.id;
|