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.
Files changed (174) hide show
  1. package/README.md +4 -16
  2. package/package.json +12 -18
  3. package/src/adapters/google-auth.js +4 -43
  4. package/src/adapters/google-drive.js +12 -67
  5. package/src/app/api/audit/dashboard/route.js +14 -11
  6. package/src/app/api/audit/logs/route.js +2 -2
  7. package/src/app/api/audit/permissions/[id]/route.js +6 -3
  8. package/src/app/api/audit/permissions/route.js +10 -7
  9. package/src/app/api/audit/permissions/stats/route.js +8 -5
  10. package/src/app/api/audit/route.js +4 -4
  11. package/src/app/api/audit/stats/route.js +1 -1
  12. package/src/app/api/auth/google/callback/route.js +5 -2
  13. package/src/app/api/auth/google/route.js +4 -1
  14. package/src/app/api/auth/login/route.js +5 -2
  15. package/src/app/api/auth/mwr-bridge/route.js +11 -21
  16. package/src/app/api/auth/password-reset/route.js +15 -14
  17. package/src/app/api/cron/trigger/route.js +4 -1
  18. package/src/app/api/debug/[[...path]]/route.js +6 -5
  19. package/src/app/api/debug/sqlite/route.js +7 -12
  20. package/src/app/api/debug/sync/route.js +5 -5
  21. package/src/app/api/domains/route.js +4 -1
  22. package/src/app/api/email/allocate/batch/route.js +19 -29
  23. package/src/app/api/email/allocate/route.js +16 -17
  24. package/src/app/api/email/receive/route.js +31 -42
  25. package/src/app/api/email/send/route.js +28 -16
  26. package/src/app/api/email/unallocated/route.js +10 -13
  27. package/src/app/api/files/[id]/route.js +4 -1
  28. package/src/app/api/formula/[[...path]]/route.js +0 -4
  29. package/src/app/api/health/route.js +10 -9
  30. package/src/app/api/metrics/route.js +1 -1
  31. package/src/cli.js +4 -4
  32. package/src/config/config-loader.js +1 -21
  33. package/src/config/constants.js +0 -35
  34. package/src/config/env.js +0 -30
  35. package/src/config/spec-helpers.js +6 -95
  36. package/src/engine.js +23 -0
  37. package/src/engine.server.js +8 -78
  38. package/src/index.js +42 -67
  39. package/src/lib/alert-manager.js +7 -3
  40. package/src/lib/api-helpers.js +4 -3
  41. package/src/lib/api.js +10 -10
  42. package/src/lib/audit-logger-enhanced.js +77 -83
  43. package/src/lib/auth-middleware.js +1 -32
  44. package/src/lib/auth-route-helpers.js +5 -0
  45. package/src/lib/busybase-adapter.js +0 -5
  46. package/src/lib/busybase-audit-reads.js +142 -0
  47. package/src/lib/busybase-audit.js +41 -0
  48. package/src/lib/busybase-lucia-adapter.js +72 -0
  49. package/src/lib/busybase-store.js +338 -0
  50. package/src/lib/config-field-helpers.js +0 -17
  51. package/src/lib/config-generator-engine.js +19 -36
  52. package/src/lib/config-helpers.js +0 -21
  53. package/src/lib/crud-action-helpers.js +2 -2
  54. package/src/lib/crud-factory.js +1 -16
  55. package/src/lib/crud-handlers.js +17 -52
  56. package/src/lib/csrf-protection.js +5 -2
  57. package/src/lib/date-utils.js +3 -127
  58. package/src/lib/debug-registry.js +0 -5
  59. package/src/lib/domain-loader.js +4 -1
  60. package/src/lib/email-sender.js +25 -23
  61. package/src/lib/error-handler.js +5 -41
  62. package/src/lib/error-recovery.js +10 -7
  63. package/src/lib/error-resilience.js +7 -5
  64. package/src/lib/events-engine.js +9 -6
  65. package/src/lib/export-sink.js +0 -5
  66. package/src/lib/field-iterator.js +7 -76
  67. package/src/lib/field-registry.js +0 -22
  68. package/src/lib/generic-crud-handler.js +0 -16
  69. package/src/lib/hook-engine.js +5 -53
  70. package/src/lib/hot-reload/checkpoint.js +5 -2
  71. package/src/lib/hot-reload/promise-container.js +7 -3
  72. package/src/lib/hot-reload/route-wrapper.js +4 -1
  73. package/src/lib/hot-reload/supervisor.js +6 -3
  74. package/src/lib/hyperformula-service.js +0 -5
  75. package/src/lib/id-helpers.js +10 -0
  76. package/src/lib/index.js +4 -7
  77. package/src/lib/log-aggregator.js +0 -1
  78. package/src/lib/logger.js +0 -15
  79. package/src/lib/metrics-collector.js +2 -46
  80. package/src/lib/next-compat.js +15 -1
  81. package/src/lib/observability-bootstrap.js +0 -5
  82. package/src/lib/perf-monitor.js +4 -1
  83. package/src/lib/perf-profiler.js +0 -5
  84. package/src/lib/query-string-adapter.js +5 -47
  85. package/src/lib/realtime-server.js +4 -26
  86. package/src/lib/request-tracing.js +0 -5
  87. package/src/lib/resource-monitor.js +4 -1
  88. package/src/lib/response-formatter.js +0 -38
  89. package/src/lib/route-helpers.js +3 -1
  90. package/src/lib/safe-json.js +5 -1
  91. package/src/lib/server-bootstrap.js +10 -9
  92. package/src/lib/stage-pipeline.js +2 -2
  93. package/src/lib/state-transport-client.js +4 -1
  94. package/src/lib/static-server.js +2 -2
  95. package/src/lib/status-helpers.js +2 -51
  96. package/src/lib/tracing.js +0 -5
  97. package/src/lib/utils.js +6 -67
  98. package/src/lib/validate.js +5 -88
  99. package/src/lib/validators.js +0 -38
  100. package/src/lib/with-audit-logging.js +1 -1
  101. package/src/lib/workflow-engine.js +10 -77
  102. package/src/lib/xstate-workflow-engine.js +3 -9
  103. package/src/plugins/index.js +5 -3
  104. package/src/server/server.js +26 -27
  105. package/src/services/collaborator-role.service.js +21 -77
  106. package/src/services/email-sender.js +7 -34
  107. package/src/services/notification-engine.js +12 -33
  108. package/src/services/permission.service.js +10 -73
  109. package/src/ui/advanced-search-renderer.js +3 -2
  110. package/src/ui/advanced-widgets.js +1 -1
  111. package/src/ui/auth-pages.js +10 -9
  112. package/src/ui/auth-styles.js +1 -1
  113. package/src/ui/client.js +1 -1
  114. package/src/ui/component-engine.js +1 -1
  115. package/src/ui/dashboard-renderer.js +5 -5
  116. package/src/ui/dialog-engine.js +6 -9
  117. package/src/ui/dialog-factory.js +2 -2
  118. package/src/ui/engagement-cards.js +7 -7
  119. package/src/ui/engagement-dialogs.js +2 -3
  120. package/src/ui/engagement-grid-renderer.js +20 -19
  121. package/src/ui/entity-renderer.js +56 -42
  122. package/src/ui/file-dialogs.js +25 -9
  123. package/src/ui/flexup-report-renderer.js +21 -18
  124. package/src/ui/format-helpers.js +27 -28
  125. package/src/ui/highlight-threading-renderer.js +9 -9
  126. package/src/ui/job-management-renderer.js +18 -16
  127. package/src/ui/layout.js +51 -25
  128. package/src/ui/ml-console-renderer.js +15 -16
  129. package/src/ui/monitoring-dashboard-client.js +9 -8
  130. package/src/ui/notifications-renderer.js +11 -10
  131. package/src/ui/page-handler-admin.js +32 -23
  132. package/src/ui/page-handler-helpers.js +27 -23
  133. package/src/ui/page-handler-reviews.js +71 -35
  134. package/src/ui/page-handler-rfi.js +28 -14
  135. package/src/ui/page-handler.js +65 -48
  136. package/src/ui/picker-dialogs.js +26 -19
  137. package/src/ui/render-helpers.js +84 -46
  138. package/src/ui/review-comparison-renderer.js +8 -7
  139. package/src/ui/review-detail-panels.js +8 -9
  140. package/src/ui/review-detail-renderer.js +3 -3
  141. package/src/ui/review-detail-script.js +13 -12
  142. package/src/ui/review-mwr-renderer.js +6 -5
  143. package/src/ui/review-renderer.js +7 -6
  144. package/src/ui/review-widgets.js +3 -3
  145. package/src/ui/rfi-detail-renderer.js +37 -20
  146. package/src/ui/rfi-report-renderer.js +14 -9
  147. package/src/ui/settings-renderer-advanced.js +21 -18
  148. package/src/ui/settings-renderer-advanced2.js +20 -19
  149. package/src/ui/settings-renderer-teams.js +4 -4
  150. package/src/ui/settings-renderer.js +4 -4
  151. package/src/ui/spacing-system.js +5 -2
  152. package/src/ui/standalone-login.js +2 -2
  153. package/src/ui/styles.css +2 -1
  154. package/src/ui/styles2.css +75 -5
  155. package/src/ui/test-page.js +4 -4
  156. package/src/ui/widgets.js +20 -15
  157. package/src/lib/audit-logger.js +0 -193
  158. package/src/lib/database-core.js +0 -255
  159. package/src/lib/database-migrations.js +0 -350
  160. package/src/lib/db-backup.js +0 -97
  161. package/src/lib/error-boundary.js +0 -134
  162. package/src/lib/errors.js +0 -69
  163. package/src/lib/health-monitor.js +0 -134
  164. package/src/lib/index-optimizer.js +0 -96
  165. package/src/lib/query-engine-write.js +0 -221
  166. package/src/lib/query-engine.js +0 -446
  167. package/src/ui/collaboration-dialogs.js +0 -31
  168. package/src/ui/global-tags.js +0 -144
  169. package/src/ui/idle-logout.js +0 -156
  170. package/src/ui/letter-dialogs.js +0 -37
  171. package/src/ui/password-reset-page.js +0 -146
  172. package/src/ui/perf-renderer.js +0 -71
  173. package/src/ui/rfi-renderer.js +0 -194
  174. 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
- // Ensure required metadata (label, type defaults)
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
- * Set the singleton config engine directly (used by the bootstrap in index.js,
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/errors';
1
+ import { AppError } from '@/lib/error-handler';
2
2
  import { HTTP } from '@/config/constants';
3
- import { now } from '@/lib/database-core';
3
+ import { now } from '@/lib/id-helpers';
4
4
 
5
5
  const ACTION_FIELD_MAPS = {
6
6
  resolve_highlight: { status: 'resolved', color: 'green' },
@@ -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;