thatcher 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +398 -0
- package/package.json +73 -0
- package/src/adapters/google-auth.js +148 -0
- package/src/adapters/google-drive.js +209 -0
- package/src/app/api/[entity]/[[...path]]/route.js +33 -0
- package/src/app/api/audit/dashboard/route.js +77 -0
- package/src/app/api/audit/logs/route.js +46 -0
- package/src/app/api/audit/permissions/[id]/route.js +37 -0
- package/src/app/api/audit/permissions/route.js +93 -0
- package/src/app/api/audit/permissions/stats/route.js +29 -0
- package/src/app/api/audit/route.js +79 -0
- package/src/app/api/audit/stats/route.js +18 -0
- package/src/app/api/auth/google/callback/route.js +94 -0
- package/src/app/api/auth/google/route.js +58 -0
- package/src/app/api/auth/login/route.js +121 -0
- package/src/app/api/auth/logout/route.js +52 -0
- package/src/app/api/auth/me/route.js +16 -0
- package/src/app/api/auth/mwr-bridge/route.js +77 -0
- package/src/app/api/auth/password-reset/route.js +65 -0
- package/src/app/api/cron/trigger/route.js +58 -0
- package/src/app/api/csrf-token/route.js +8 -0
- package/src/app/api/debug/config/route.js +22 -0
- package/src/app/api/debug/hooks/route.js +16 -0
- package/src/app/api/debug/plugins/route.js +20 -0
- package/src/app/api/debug/sqlite/route.js +21 -0
- package/src/app/api/debug/sync/route.js +29 -0
- package/src/app/api/debug/workflow/route.js +20 -0
- package/src/app/api/domains/[domain]/route.js +26 -0
- package/src/app/api/domains/route.js +21 -0
- package/src/app/api/email/allocate/batch/route.js +106 -0
- package/src/app/api/email/allocate/route.js +141 -0
- package/src/app/api/email/receive/route.js +158 -0
- package/src/app/api/email/route.js +3 -0
- package/src/app/api/email/send/route.js +77 -0
- package/src/app/api/email/unallocated/route.js +47 -0
- package/src/app/api/files/[id]/route.js +38 -0
- package/src/app/api/health/route.js +95 -0
- package/src/app/api/metrics/route.js +73 -0
- package/src/app/api/monitoring/dashboard/route.js +18 -0
- package/src/cli.js +243 -0
- package/src/config/config-loader.js +112 -0
- package/src/config/constants.js +127 -0
- package/src/config/env.js +164 -0
- package/src/config/spec-helpers.js +232 -0
- package/src/engine.server.js +212 -0
- package/src/index.js +368 -0
- package/src/lib/accessibility.js +162 -0
- package/src/lib/action-factory.js +34 -0
- package/src/lib/action-utils.js +21 -0
- package/src/lib/alert-manager.js +189 -0
- package/src/lib/api-error-wrapper.js +125 -0
- package/src/lib/api-helpers.js +53 -0
- package/src/lib/api.js +82 -0
- package/src/lib/audit-logger-enhanced.js +117 -0
- package/src/lib/audit-logger.js +193 -0
- package/src/lib/auth-middleware.js +102 -0
- package/src/lib/auth-route-helpers.js +83 -0
- package/src/lib/business-rules-engine.js +86 -0
- package/src/lib/compression.js +44 -0
- package/src/lib/config-field-helpers.js +91 -0
- package/src/lib/config-generator-engine.js +445 -0
- package/src/lib/config-helpers.js +120 -0
- package/src/lib/connection-guard.js +79 -0
- package/src/lib/crud-action-helpers.js +34 -0
- package/src/lib/crud-factory.js +83 -0
- package/src/lib/crud-handlers.js +244 -0
- package/src/lib/csrf-protection.js +63 -0
- package/src/lib/database-core.js +258 -0
- package/src/lib/database-migrations.js +96 -0
- package/src/lib/date-utils.js +159 -0
- package/src/lib/db-backup.js +97 -0
- package/src/lib/db-monitor.js +127 -0
- package/src/lib/domain-loader.js +82 -0
- package/src/lib/email-sender.js +100 -0
- package/src/lib/error-boundary.js +134 -0
- package/src/lib/error-handler.js +84 -0
- package/src/lib/error-recovery.js +190 -0
- package/src/lib/error-resilience.js +130 -0
- package/src/lib/errors.js +69 -0
- package/src/lib/events-engine.js +182 -0
- package/src/lib/field-iterator.js +50 -0
- package/src/lib/field-registry.js +68 -0
- package/src/lib/field-types.js +154 -0
- package/src/lib/generic-crud-handler.js +32 -0
- package/src/lib/health-monitor.js +134 -0
- package/src/lib/hook-engine.js +169 -0
- package/src/lib/hot-reload/cache-invalidator.js +115 -0
- package/src/lib/hot-reload/checkpoint.js +95 -0
- package/src/lib/hot-reload/debug-exposure.js +67 -0
- package/src/lib/hot-reload/directory-watcher.js +96 -0
- package/src/lib/hot-reload/index.js +50 -0
- package/src/lib/hot-reload/mutex.js +75 -0
- package/src/lib/hot-reload/promise-container.js +66 -0
- package/src/lib/hot-reload/route-wrapper.js +46 -0
- package/src/lib/hot-reload/safe-error.js +51 -0
- package/src/lib/hot-reload/supervisor.js +161 -0
- package/src/lib/hot-reload/timeout-wrapper.js +52 -0
- package/src/lib/http-methods-factory.js +25 -0
- package/src/lib/index-optimizer.js +96 -0
- package/src/lib/index.js +35 -0
- package/src/lib/list-data-transform.js +39 -0
- package/src/lib/log-aggregator.js +116 -0
- package/src/lib/logger.js +55 -0
- package/src/lib/metrics-collector.js +102 -0
- package/src/lib/minifier.js +19 -0
- package/src/lib/monitoring-init.js +67 -0
- package/src/lib/next-compat.js +80 -0
- package/src/lib/next-polyfills.js +135 -0
- package/src/lib/perf-monitor.js +91 -0
- package/src/lib/progress-components.js +181 -0
- package/src/lib/query-cache.js +126 -0
- package/src/lib/query-engine-write.js +221 -0
- package/src/lib/query-engine.js +399 -0
- package/src/lib/query-perf.js +117 -0
- package/src/lib/query-string-adapter.js +75 -0
- package/src/lib/realtime-server.js +67 -0
- package/src/lib/render-cache.js +61 -0
- package/src/lib/request-tracker.js +43 -0
- package/src/lib/resource-hints.js +29 -0
- package/src/lib/resource-monitor.js +117 -0
- package/src/lib/response-formatter.js +80 -0
- package/src/lib/route-helpers.js +33 -0
- package/src/lib/route-resolver.js +142 -0
- package/src/lib/safe-json.js +8 -0
- package/src/lib/server-bootstrap.js +71 -0
- package/src/lib/stage-pipeline.js +153 -0
- package/src/lib/state-protocol.js +171 -0
- package/src/lib/state-transport-client.js +169 -0
- package/src/lib/state-transport-reconnect.js +121 -0
- package/src/lib/state-transport-server.js +181 -0
- package/src/lib/static-server.js +97 -0
- package/src/lib/status-helpers.js +98 -0
- package/src/lib/universal-handler.js +7 -0
- package/src/lib/utils.js +93 -0
- package/src/lib/validate.js +197 -0
- package/src/lib/validation/business-validators.js +61 -0
- package/src/lib/validation/csrf.js +51 -0
- package/src/lib/validation/file-validators.js +34 -0
- package/src/lib/validation/format-validators.js +106 -0
- package/src/lib/validation/index.js +19 -0
- package/src/lib/validation/rate-limit.js +31 -0
- package/src/lib/validation/security-validators.js +78 -0
- package/src/lib/validation-middleware.js +133 -0
- package/src/lib/validators.js +105 -0
- package/src/lib/with-audit-logging.js +63 -0
- package/src/lib/with-error-handler.js +31 -0
- package/src/lib/workflow-engine.js +250 -0
- package/src/server/server.js +305 -0
- package/src/services/collaborator-role.service.js +205 -0
- package/src/services/email-sender.js +105 -0
- package/src/services/notification-engine.js +110 -0
- package/src/services/permission.service.js +181 -0
- package/src/ui/advanced-search-renderer.js +42 -0
- package/src/ui/advanced-widgets.js +47 -0
- package/src/ui/auth-pages.js +114 -0
- package/src/ui/auth-styles.js +53 -0
- package/src/ui/client.js +99 -0
- package/src/ui/collaboration-dialogs.js +31 -0
- package/src/ui/common-handlers.js +156 -0
- package/src/ui/component-engine.js +103 -0
- package/src/ui/dashboard-renderer.js +150 -0
- package/src/ui/dialog-engine.js +147 -0
- package/src/ui/dialog-factory.js +38 -0
- package/src/ui/engagement-cards.js +76 -0
- package/src/ui/engagement-dialogs.js +100 -0
- package/src/ui/engagement-grid-renderer.js +109 -0
- package/src/ui/entity-renderer.js +176 -0
- package/src/ui/event-delegation.js +108 -0
- package/src/ui/fetch-json.js +24 -0
- package/src/ui/file-dialogs.js +81 -0
- package/src/ui/flexup-report-renderer.js +173 -0
- package/src/ui/format-helpers.js +102 -0
- package/src/ui/global-tags.js +144 -0
- package/src/ui/highlight-threading-renderer.js +176 -0
- package/src/ui/idle-logout.js +156 -0
- package/src/ui/job-management-renderer.js +61 -0
- package/src/ui/layout.js +219 -0
- package/src/ui/letter-dialogs.js +37 -0
- package/src/ui/ml-console-renderer.js +108 -0
- package/src/ui/monitoring-dashboard-client.js +136 -0
- package/src/ui/monitoring-dashboard.js +134 -0
- package/src/ui/notifications-renderer.js +51 -0
- package/src/ui/page-handler-admin.js +121 -0
- package/src/ui/page-handler-helpers.js +111 -0
- package/src/ui/page-handler-reviews.js +165 -0
- package/src/ui/page-handler-rfi.js +42 -0
- package/src/ui/page-handler.js +210 -0
- package/src/ui/password-reset-page.js +146 -0
- package/src/ui/perf-helpers.js +96 -0
- package/src/ui/perf-renderer.js +71 -0
- package/src/ui/permissions-ui.js +163 -0
- package/src/ui/picker-dialogs.js +100 -0
- package/src/ui/render-helpers.js +124 -0
- package/src/ui/renderer.js +35 -0
- package/src/ui/review-comparison-renderer.js +58 -0
- package/src/ui/review-detail-panels.js +71 -0
- package/src/ui/review-detail-renderer.js +170 -0
- package/src/ui/review-detail-script.js +95 -0
- package/src/ui/review-mwr-renderer.js +113 -0
- package/src/ui/review-renderer.js +202 -0
- package/src/ui/review-widgets.js +88 -0
- package/src/ui/review-zone-nav.js +12 -0
- package/src/ui/rfi-detail-renderer.js +191 -0
- package/src/ui/rfi-renderer.js +194 -0
- package/src/ui/rfi-report-renderer.js +56 -0
- package/src/ui/rippleui.css +1 -0
- package/src/ui/settings-renderer-advanced.js +195 -0
- package/src/ui/settings-renderer-advanced2.js +158 -0
- package/src/ui/settings-renderer-teams.js +112 -0
- package/src/ui/settings-renderer.js +166 -0
- package/src/ui/spacing-system.js +155 -0
- package/src/ui/standalone-login.js +109 -0
- package/src/ui/styles.css +2530 -0
- package/src/ui/styles2.css +1602 -0
- package/src/ui/test-page.js +23 -0
- package/src/ui/validation-rules.js +73 -0
- package/src/ui/validation-ui.js +147 -0
- package/src/ui/virtual-scroll.js +107 -0
- package/src/ui/webjsx.js +61 -0
- package/src/ui/widgets.js +152 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query Engine - Read operations
|
|
3
|
+
* Adapted from moonlanding/src/lib/query-engine.js
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getDatabase } from './database-core.js';
|
|
7
|
+
import { getSpec } from '../config/spec-helpers.js';
|
|
8
|
+
import { RECORD_STATUS } from '../config/constants.js';
|
|
9
|
+
|
|
10
|
+
const db = getDatabase();
|
|
11
|
+
const logger = createLogger('[QueryEngine]');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Execute a query with error handling
|
|
15
|
+
* @param {string} sql
|
|
16
|
+
* @param {Array} params
|
|
17
|
+
* @param {object} context
|
|
18
|
+
* @returns {Array}
|
|
19
|
+
*/
|
|
20
|
+
function execQuery(sql, params = [], context = {}) {
|
|
21
|
+
try {
|
|
22
|
+
return db.prepare(sql).all(...params);
|
|
23
|
+
} catch (e) {
|
|
24
|
+
logger.error(`${context.operation || 'Query'} ${context.entity || ''}`, { sql, error: e.message });
|
|
25
|
+
throw new Error(`Database query failed: ${e.message}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Execute a single-row query
|
|
31
|
+
* @param {string} sql
|
|
32
|
+
* @param {Array} params
|
|
33
|
+
* @param {object} context
|
|
34
|
+
* @returns {object|null}
|
|
35
|
+
*/
|
|
36
|
+
function execGet(sql, params = [], context = {}) {
|
|
37
|
+
try {
|
|
38
|
+
return db.prepare(sql).get(...params);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
logger.error(`${context.operation || 'Get'} ${context.entity || ''}`, { sql, error: e.message });
|
|
41
|
+
throw new Error(`Database get failed: ${e.message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get table name for entity (users vs user)
|
|
47
|
+
* @param {object} spec
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
function tableName(spec) {
|
|
51
|
+
return spec.name === 'user' ? 'users' : spec.name;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build SELECT query with joins, where, sorting, pagination
|
|
56
|
+
* @param {object} spec - Entity specification
|
|
57
|
+
* @param {object} where - Where conditions
|
|
58
|
+
* @param {object} options - Query options
|
|
59
|
+
* @returns {{sql: string, params: Array}}
|
|
60
|
+
*/
|
|
61
|
+
function buildSpecQuery(spec, where = {}, options = {}) {
|
|
62
|
+
const tbl = tableName(spec);
|
|
63
|
+
const table = `"${tbl}"`;
|
|
64
|
+
const selects = [`${table}.*`];
|
|
65
|
+
const joins = [];
|
|
66
|
+
|
|
67
|
+
// Add computed fields
|
|
68
|
+
if (spec.computed) {
|
|
69
|
+
Object.entries(spec.computed).forEach(([k, c]) => {
|
|
70
|
+
selects.push(`${c.sql} as "${k}"`);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Add joins for ref fields with display
|
|
75
|
+
Object.entries(spec.fields || {}).forEach(([k, f]) => {
|
|
76
|
+
if (f.type === 'ref' && f.display) {
|
|
77
|
+
const refTbl = f.ref === 'user' ? 'users' : f.ref;
|
|
78
|
+
const alias = `"${refTbl}_${k}"`;
|
|
79
|
+
joins.push(`LEFT JOIN "${refTbl}" ${alias} ON ${table}."${k}" = ${alias}.id`);
|
|
80
|
+
|
|
81
|
+
const displayField = f.display.split('.')[1] || 'name';
|
|
82
|
+
selects.push(`${alias}."${displayField}" as "${k}_display"`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const wc = [];
|
|
87
|
+
const p = [];
|
|
88
|
+
|
|
89
|
+
// Where conditions
|
|
90
|
+
Object.entries(where).forEach(([k, v]) => {
|
|
91
|
+
if (v !== undefined && v !== null) {
|
|
92
|
+
wc.push(`${table}."${k}" = ?`);
|
|
93
|
+
p.push(v);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Exclude soft-deleted by default
|
|
98
|
+
if (spec.fields?.status && !where.status && !options.includeDeleted) {
|
|
99
|
+
wc.push(`${table}."status" != '${RECORD_STATUS.DELETED}'`);
|
|
100
|
+
}
|
|
101
|
+
if (spec.fields?.archived && !where.archived && !options.includeArchived) {
|
|
102
|
+
wc.push(`${table}."archived" = 0`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let sql = `SELECT ${selects.join(', ')} FROM ${table}`;
|
|
106
|
+
if (joins.length) {
|
|
107
|
+
sql += ' ' + joins.join(' ');
|
|
108
|
+
}
|
|
109
|
+
if (wc.length) {
|
|
110
|
+
sql += ` WHERE ` + wc.join(` AND `);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Sorting
|
|
114
|
+
const sort = options.sort || spec.list?.defaultSort;
|
|
115
|
+
if (sort && sort.field && spec.fields?.[sort.field]) {
|
|
116
|
+
const dir = (sort.dir || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
|
117
|
+
sql += ` ORDER BY ${table}."${sort.field}" ${dir}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Limit/offset
|
|
121
|
+
if (options.limit) {
|
|
122
|
+
sql += ` LIMIT ${parseInt(options.limit, 10)}`;
|
|
123
|
+
if (options.offset) {
|
|
124
|
+
sql += ` OFFSET ${parseInt(options.offset, 10)}`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { sql, params: p };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get total count for entity (optionally with where)
|
|
133
|
+
* @param {string} entity
|
|
134
|
+
* @param {object} where
|
|
135
|
+
* @param {object} options
|
|
136
|
+
* @returns {number}
|
|
137
|
+
*/
|
|
138
|
+
export function count(entity, where = {}, options = {}) {
|
|
139
|
+
const spec = getSpec(entity);
|
|
140
|
+
const tbl = tableName(spec);
|
|
141
|
+
const table = `"${tbl}"`;
|
|
142
|
+
const wc = [];
|
|
143
|
+
const p = [];
|
|
144
|
+
|
|
145
|
+
Object.entries(where).forEach(([k, v]) => {
|
|
146
|
+
if (v !== undefined && v !== null) {
|
|
147
|
+
wc.push(`${table}."${k}" = ?`);
|
|
148
|
+
p.push(v);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (spec.fields?.status && !where.status && !options.includeDeleted) {
|
|
153
|
+
wc.push(`${table}."status" != '${RECORD_STATUS.DELETED}'`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const whereClause = wc.length ? `WHERE ${wc.join(' AND ')}` : '';
|
|
157
|
+
const sql = `SELECT COUNT(*) as cnt FROM ${table} ${whereClause}`;
|
|
158
|
+
|
|
159
|
+
const result = execGet(sql, p, { entity, operation: 'Count' });
|
|
160
|
+
return result?.cnt || 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* List all records (with optional where)
|
|
165
|
+
* @param {string} entity
|
|
166
|
+
* @param {object} where
|
|
167
|
+
* @param {object} options
|
|
168
|
+
* @returns {Array}
|
|
169
|
+
*/
|
|
170
|
+
export function list(entity, where = {}, options = {}) {
|
|
171
|
+
const spec = getSpec(entity);
|
|
172
|
+
const { sql, params } = buildSpecQuery(spec, where, options);
|
|
173
|
+
return execQuery(sql, params, { entity, operation: 'List' });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* List with pagination
|
|
178
|
+
* @param {string} entity
|
|
179
|
+
* @param {object} where
|
|
180
|
+
* @param {number} page
|
|
181
|
+
* @param {number|null} pageSize
|
|
182
|
+
* @returns {{items: Array, pagination: object}}
|
|
183
|
+
*/
|
|
184
|
+
export async function listWithPagination(entity, where = {}, page = 1, pageSize = null) {
|
|
185
|
+
const spec = getSpec(entity);
|
|
186
|
+
const tbl = tableName(spec);
|
|
187
|
+
|
|
188
|
+
const paginationCfg = getPaginationConfig(spec);
|
|
189
|
+
const defaultPageSize = spec.list?.pageSize || paginationCfg.default_page_size;
|
|
190
|
+
const finalPageSize = pageSize || defaultPageSize;
|
|
191
|
+
const finalPage = Math.max(1, page);
|
|
192
|
+
|
|
193
|
+
const offset = (finalPage - 1) * finalPageSize;
|
|
194
|
+
|
|
195
|
+
const { sql, params } = buildSpecQuery(spec, where, {
|
|
196
|
+
...options,
|
|
197
|
+
limit: finalPageSize,
|
|
198
|
+
offset,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const items = execQuery(sql, params, { entity, operation: 'ListWithPagination' });
|
|
202
|
+
|
|
203
|
+
const total = count(entity, where, options);
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
items,
|
|
207
|
+
pagination: {
|
|
208
|
+
page: finalPage,
|
|
209
|
+
pageSize: finalPageSize,
|
|
210
|
+
total,
|
|
211
|
+
totalPages: Math.ceil(total / finalPageSize),
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Get single record by ID
|
|
218
|
+
* @param {string} entity
|
|
219
|
+
* @param {string|number} id
|
|
220
|
+
* @returns {object|null}
|
|
221
|
+
*/
|
|
222
|
+
export function get(entity, id) {
|
|
223
|
+
const spec = getSpec(entity);
|
|
224
|
+
const tbl = tableName(spec);
|
|
225
|
+
const sql = `SELECT * FROM "${tbl}" WHERE id = ?`;
|
|
226
|
+
return execGet(sql, [id], { entity, operation: 'Get' });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Get single record by field value
|
|
231
|
+
* @param {string} entity
|
|
232
|
+
* @param {string} field
|
|
233
|
+
* @param {any} value
|
|
234
|
+
* @returns {object|null}
|
|
235
|
+
*/
|
|
236
|
+
export function getBy(entity, field, value) {
|
|
237
|
+
const spec = getSpec(entity);
|
|
238
|
+
const tbl = tableName(spec);
|
|
239
|
+
const sql = `SELECT * FROM "${tbl}" WHERE "${field}" = ? LIMIT 1`;
|
|
240
|
+
return execGet(sql, [value], { entity, operation: 'GetBy' });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Search using FTS
|
|
245
|
+
* @param {string} entity
|
|
246
|
+
* @param {string} query
|
|
247
|
+
* @param {object} where
|
|
248
|
+
* @param {object} options
|
|
249
|
+
* @returns {Array}
|
|
250
|
+
*/
|
|
251
|
+
export function search(entity, query, where = {}, options = {}) {
|
|
252
|
+
const spec = getSpec(entity);
|
|
253
|
+
const tbl = tableName(spec);
|
|
254
|
+
const ftsTable = `${tbl}_fts`;
|
|
255
|
+
|
|
256
|
+
// Check if FTS table exists
|
|
257
|
+
const ftsExists = db.prepare(`
|
|
258
|
+
SELECT name FROM sqlite_master WHERE type='table' AND name=?
|
|
259
|
+
`).get(ftsTable);
|
|
260
|
+
|
|
261
|
+
if (!ftsExists) {
|
|
262
|
+
// Fallback to simple LIKE search
|
|
263
|
+
const { sql, params } = buildSpecQuery(spec, where, options);
|
|
264
|
+
const baseSql = sql.replace('SELECT *', `SELECT *`);
|
|
265
|
+
const searchTerm = `%${query}%`;
|
|
266
|
+
const searchFields = Object.keys(spec.fields || {}).filter(
|
|
267
|
+
f => ['text', 'textarea', 'email'].includes(spec.fields[f].type)
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
if (searchFields.length > 0) {
|
|
271
|
+
const conditions = searchFields.map(f => `"${f}" LIKE ?`).join(' OR ');
|
|
272
|
+
const whereAnd = sql.includes('WHERE') ? ' AND ' : ' WHERE ';
|
|
273
|
+
const fullSql = baseSql + whereAnd + `(${conditions})`;
|
|
274
|
+
const searchParams = [...params, ...searchFields.map(() => searchTerm)];
|
|
275
|
+
return execQuery(fullSql, searchParams, { entity, operation: 'SearchFallback' });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return [];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Use FTS
|
|
282
|
+
const ftsResults = db.prepare(`
|
|
283
|
+
SELECT rowid as id FROM ${ftsTable}
|
|
284
|
+
WHERE ${ftsTable} MATCH ?
|
|
285
|
+
`).all(query);
|
|
286
|
+
|
|
287
|
+
if (!ftsResults.length) return [];
|
|
288
|
+
|
|
289
|
+
const ids = ftsResults.map(r => r.id);
|
|
290
|
+
const idPlaceholders = ids.map(() => '?').join(',');
|
|
291
|
+
const { sql, params } = buildSpecQuery(spec, { ...where, id: { $in: ids } }, options);
|
|
292
|
+
const finalSql = sql.replace('WHERE', `WHERE "${tbl}"."id" IN (${idPlaceholders}) AND `);
|
|
293
|
+
|
|
294
|
+
return execQuery(finalSql, [...ids, ...params], { entity, operation: 'Search' });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Search with pagination
|
|
299
|
+
* @param {string} entity
|
|
300
|
+
* @param {string} query
|
|
301
|
+
* @param {object} where
|
|
302
|
+
* @param {number} page
|
|
303
|
+
* @param {number} pageSize
|
|
304
|
+
* @returns {{items: Array, pagination: object}}
|
|
305
|
+
*/
|
|
306
|
+
export async function searchWithPagination(entity, query, where = {}, page = 1, pageSize = null) {
|
|
307
|
+
const spec = getSpec(entity);
|
|
308
|
+
const paginationCfg = getPaginationConfig(spec);
|
|
309
|
+
const defaultPageSize = spec.list?.pageSize || paginationCfg.default_page_size;
|
|
310
|
+
const finalPageSize = pageSize || defaultPageSize;
|
|
311
|
+
const finalPage = Math.max(1, page);
|
|
312
|
+
|
|
313
|
+
const items = search(entity, query, where, {
|
|
314
|
+
limit: finalPageSize,
|
|
315
|
+
offset: (finalPage - 1) * finalPageSize,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// For total count, we'd need a separate FTS count query - simplified here
|
|
319
|
+
const total = items.length; // Approximate
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
items,
|
|
323
|
+
pagination: {
|
|
324
|
+
page: finalPage,
|
|
325
|
+
pageSize: finalPageSize,
|
|
326
|
+
total,
|
|
327
|
+
totalPages: Math.ceil(total / finalPageSize),
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Get children of a parent entity
|
|
334
|
+
* @param {string} parentEntity
|
|
335
|
+
* @param {string|number} parentId
|
|
336
|
+
* @param {object} childDef
|
|
337
|
+
* @returns {Array}
|
|
338
|
+
*/
|
|
339
|
+
export function getChildren(parentEntity, parentId, childDef) {
|
|
340
|
+
const childSpec = getSpec(childDef.entity);
|
|
341
|
+
const childTbl = tableName(childSpec);
|
|
342
|
+
const fk = childDef.fk || `${parentEntity}_id`;
|
|
343
|
+
|
|
344
|
+
const sql = `SELECT * FROM "${childTbl}" WHERE "${fk}" = ? AND status != 'deleted'`;
|
|
345
|
+
return execQuery(sql, [parentId], { entity: childDef.entity, operation: 'GetChildren' });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Get pagination config from system settings
|
|
350
|
+
* @param {object} spec - Entity spec
|
|
351
|
+
* @returns {{default_page_size: number, max_page_size: number}}
|
|
352
|
+
*/
|
|
353
|
+
function getPaginationConfig(spec) {
|
|
354
|
+
// Try to get from config engine, fallback to defaults
|
|
355
|
+
try {
|
|
356
|
+
// Will be provided by caller or fetched from global config
|
|
357
|
+
return {
|
|
358
|
+
default_page_size: spec.list?.pageSize || 50,
|
|
359
|
+
max_page_size: 500,
|
|
360
|
+
};
|
|
361
|
+
} catch {
|
|
362
|
+
return { default_page_size: 50, max_page_size: 500 };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Simple logger
|
|
368
|
+
*/
|
|
369
|
+
function createLogger(prefix) {
|
|
370
|
+
return {
|
|
371
|
+
error: (msg, meta = {}) => {
|
|
372
|
+
console.error(`${prefix} ${msg}`, meta);
|
|
373
|
+
},
|
|
374
|
+
warn: (msg, meta = {}) => {
|
|
375
|
+
console.warn(`${prefix} ${msg}`, meta);
|
|
376
|
+
},
|
|
377
|
+
info: (msg, meta = {}) => {
|
|
378
|
+
console.info(`${prefix} ${msg}`, meta);
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Run in transaction
|
|
385
|
+
* @param {Function} callback
|
|
386
|
+
* @returns {Promise<any>}
|
|
387
|
+
*/
|
|
388
|
+
export async function withTransaction(callback) {
|
|
389
|
+
const dbInstance = getDatabase();
|
|
390
|
+
try {
|
|
391
|
+
dbInstance.prepare('BEGIN').run();
|
|
392
|
+
const result = await callback();
|
|
393
|
+
dbInstance.prepare('COMMIT').run();
|
|
394
|
+
return result;
|
|
395
|
+
} catch (e) {
|
|
396
|
+
dbInstance.prepare('ROLLBACK').run();
|
|
397
|
+
throw e;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const queryMetrics = new Map();
|
|
2
|
+
const slowQueries = [];
|
|
3
|
+
const MAX_SLOW_QUERIES = 100;
|
|
4
|
+
const SLOW_QUERY_THRESHOLD = 100;
|
|
5
|
+
|
|
6
|
+
export const trackQuery = (sql, duration, params = []) => {
|
|
7
|
+
const key = sql.substring(0, 200);
|
|
8
|
+
|
|
9
|
+
if (!queryMetrics.has(key)) {
|
|
10
|
+
queryMetrics.set(key, {
|
|
11
|
+
sql: key,
|
|
12
|
+
count: 0,
|
|
13
|
+
totalTime: 0,
|
|
14
|
+
minTime: Infinity,
|
|
15
|
+
maxTime: 0,
|
|
16
|
+
times: []
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const metric = queryMetrics.get(key);
|
|
21
|
+
metric.count++;
|
|
22
|
+
metric.totalTime += duration;
|
|
23
|
+
metric.minTime = Math.min(metric.minTime, duration);
|
|
24
|
+
metric.maxTime = Math.max(metric.maxTime, duration);
|
|
25
|
+
metric.times.push(duration);
|
|
26
|
+
|
|
27
|
+
if (metric.times.length > 1000) metric.times.shift();
|
|
28
|
+
|
|
29
|
+
if (duration >= SLOW_QUERY_THRESHOLD) {
|
|
30
|
+
slowQueries.push({
|
|
31
|
+
sql: key,
|
|
32
|
+
duration,
|
|
33
|
+
params: params.slice(0, 10),
|
|
34
|
+
timestamp: Date.now()
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (slowQueries.length > MAX_SLOW_QUERIES) slowQueries.shift();
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const percentile = (arr, p) => {
|
|
42
|
+
if (arr.length === 0) return 0;
|
|
43
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
44
|
+
const index = Math.ceil(sorted.length * p / 100) - 1;
|
|
45
|
+
return sorted[Math.max(0, index)];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const getMetrics = () => {
|
|
49
|
+
const metrics = [];
|
|
50
|
+
|
|
51
|
+
for (const [, metric] of queryMetrics) {
|
|
52
|
+
const avgTime = metric.count > 0 ? metric.totalTime / metric.count : 0;
|
|
53
|
+
const p50 = percentile(metric.times, 50);
|
|
54
|
+
const p95 = percentile(metric.times, 95);
|
|
55
|
+
const p99 = percentile(metric.times, 99);
|
|
56
|
+
|
|
57
|
+
metrics.push({
|
|
58
|
+
sql: metric.sql,
|
|
59
|
+
count: metric.count,
|
|
60
|
+
avgTime: avgTime.toFixed(2),
|
|
61
|
+
minTime: metric.minTime === Infinity ? 0 : metric.minTime.toFixed(2),
|
|
62
|
+
maxTime: metric.maxTime.toFixed(2),
|
|
63
|
+
p50: p50.toFixed(2),
|
|
64
|
+
p95: p95.toFixed(2),
|
|
65
|
+
p99: p99.toFixed(2),
|
|
66
|
+
totalTime: metric.totalTime.toFixed(2)
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return metrics.sort((a, b) => parseFloat(b.totalTime) - parseFloat(a.totalTime));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const getSlowQueries = () => {
|
|
74
|
+
return slowQueries.slice().reverse();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const getSummary = () => {
|
|
78
|
+
const metrics = getMetrics();
|
|
79
|
+
const totalQueries = metrics.reduce((sum, m) => sum + m.count, 0);
|
|
80
|
+
const totalTime = metrics.reduce((sum, m) => sum + parseFloat(m.totalTime), 0);
|
|
81
|
+
const allTimes = [];
|
|
82
|
+
|
|
83
|
+
for (const [, metric] of queryMetrics) {
|
|
84
|
+
allTimes.push(...metric.times);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
totalQueries,
|
|
89
|
+
uniqueQueries: metrics.length,
|
|
90
|
+
totalTime: totalTime.toFixed(2),
|
|
91
|
+
avgTime: totalQueries > 0 ? (totalTime / totalQueries).toFixed(2) : '0',
|
|
92
|
+
p50: percentile(allTimes, 50).toFixed(2),
|
|
93
|
+
p95: percentile(allTimes, 95).toFixed(2),
|
|
94
|
+
p99: percentile(allTimes, 99).toFixed(2),
|
|
95
|
+
slowQueries: slowQueries.length,
|
|
96
|
+
slowQueryThreshold: SLOW_QUERY_THRESHOLD
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const clearMetrics = () => {
|
|
101
|
+
queryMetrics.clear();
|
|
102
|
+
slowQueries.length = 0;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const withPerfTracking = (db, sql, params, executor) => {
|
|
106
|
+
const start = performance.now();
|
|
107
|
+
try {
|
|
108
|
+
const result = executor();
|
|
109
|
+
const duration = performance.now() - start;
|
|
110
|
+
trackQuery(sql, duration, params);
|
|
111
|
+
return result;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
const duration = performance.now() - start;
|
|
114
|
+
trackQuery(sql, duration, params);
|
|
115
|
+
throw e;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query String Adapter - Parse URL query parameters into typed objects
|
|
3
|
+
* Adapted from moonlanding/src/lib/query-string-adapter.js
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parse query string from URL
|
|
8
|
+
* @param {object} request - Fetch Request or NextRequest-like
|
|
9
|
+
* @returns {Promise<{q?: string, page?: number, pageSize?: number, filters?: object, sort?: object}>}
|
|
10
|
+
*/
|
|
11
|
+
export async function parseQuery(request) {
|
|
12
|
+
const url = request.url || (request._url ? `http://localhost${request._url}` : 'http://localhost/');
|
|
13
|
+
const searchParams = new URL(url).searchParams;
|
|
14
|
+
|
|
15
|
+
const result = {
|
|
16
|
+
q: searchParams.get('q') || null,
|
|
17
|
+
page: parseInt(searchParams.get('page') || '1', 10),
|
|
18
|
+
pageSize: parseInt(searchParams.get('pageSize') || searchParams.get('page_size') || '50', 10),
|
|
19
|
+
filters: {},
|
|
20
|
+
sort: null,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Parse filters (filter[key]=value)
|
|
24
|
+
for (const [key, value] of searchParams.entries()) {
|
|
25
|
+
if (key.startsWith('filter_') || key.startsWith('filters[')) {
|
|
26
|
+
const filterKey = key.replace('filter_', '').replace(/filters\[(\w+)\]/, '$1');
|
|
27
|
+
result.filters[filterKey] = coerceValue(value);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Parse sort
|
|
32
|
+
const sortBy = searchParams.get('sort') || searchParams.get('sortBy');
|
|
33
|
+
const sortDir = searchParams.get('dir') || searchParams.get('direction') || 'asc';
|
|
34
|
+
if (sortBy) {
|
|
35
|
+
result.sort = {
|
|
36
|
+
field: sortBy,
|
|
37
|
+
dir: sortDir.toLowerCase() === 'desc' ? 'desc' : 'asc',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Coerce string to appropriate type
|
|
46
|
+
* @param {string} value
|
|
47
|
+
* @returns {any}
|
|
48
|
+
*/
|
|
49
|
+
function coerceValue(value) {
|
|
50
|
+
// Lowercase boolean strings
|
|
51
|
+
if (value === 'true') return true;
|
|
52
|
+
if (value === 'false') return false;
|
|
53
|
+
if (value === 'null' || value === '') return null;
|
|
54
|
+
|
|
55
|
+
// Number?
|
|
56
|
+
const num = Number(value);
|
|
57
|
+
if (!isNaN(num) && value.trim() !== '') return num;
|
|
58
|
+
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get default value for config key
|
|
64
|
+
* @param {string} key
|
|
65
|
+
* @returns {any}
|
|
66
|
+
*/
|
|
67
|
+
export function getDefault(key) {
|
|
68
|
+
const defaults = {
|
|
69
|
+
page: 1,
|
|
70
|
+
pageSize: 50,
|
|
71
|
+
q: null,
|
|
72
|
+
filters: {},
|
|
73
|
+
};
|
|
74
|
+
return defaults[key];
|
|
75
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Realtime Server - WebSocket/Server-Sent Events for live updates
|
|
3
|
+
* Optional component - can be disabled if not needed
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
let subscribers = new Map(); // channel -> Set of callbacks
|
|
7
|
+
let enabled = false;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Initialize realtime (optional)
|
|
11
|
+
* @param {object} server - HTTP server to attach WS endpoint
|
|
12
|
+
*/
|
|
13
|
+
export function initRealtime(server) {
|
|
14
|
+
if (!server) return;
|
|
15
|
+
|
|
16
|
+
// Can be extended with ws library for full WebSocket support
|
|
17
|
+
// For now, provides a polling-based notification system
|
|
18
|
+
enabled = true;
|
|
19
|
+
console.log('[Realtime] Initialized (polling mode)');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Broadcast update to all subscribers of a channel
|
|
24
|
+
* @param {string} channel
|
|
25
|
+
* @param {string} event
|
|
26
|
+
* @param {any} data
|
|
27
|
+
*/
|
|
28
|
+
export function broadcastUpdate(channel, event, data) {
|
|
29
|
+
if (!enabled) return;
|
|
30
|
+
|
|
31
|
+
const channelSubscribers = subscribers.get(channel);
|
|
32
|
+
if (channelSubscribers) {
|
|
33
|
+
const payload = { event, data, timestamp: Date.now() };
|
|
34
|
+
channelSubscribers.forEach(cb => {
|
|
35
|
+
try { cb(payload); } catch {}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Subscribe to updates for a channel
|
|
42
|
+
* @param {string} channel
|
|
43
|
+
* @param {Function} callback
|
|
44
|
+
* @returns {Function} Unsubscribe function
|
|
45
|
+
*/
|
|
46
|
+
export function subscribe(channel, callback) {
|
|
47
|
+
if (!subscribers.has(channel)) {
|
|
48
|
+
subscribers.set(channel, new Set());
|
|
49
|
+
}
|
|
50
|
+
subscribers.get(channel).add(callback);
|
|
51
|
+
|
|
52
|
+
// Return unsubscribe function
|
|
53
|
+
return () => {
|
|
54
|
+
subscribers.get(channel)?.delete(callback);
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get update stats
|
|
60
|
+
* @returns {object}
|
|
61
|
+
*/
|
|
62
|
+
export function getStats() {
|
|
63
|
+
return {
|
|
64
|
+
channels: subscribers.size,
|
|
65
|
+
totalSubscribers: Array.from(subscribers.values()).reduce((sum, set) => sum + set.size, 0),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const cache = new Map()
|
|
2
|
+
const maxSize = 500
|
|
3
|
+
const ttl = 60000
|
|
4
|
+
let hits = 0
|
|
5
|
+
let misses = 0
|
|
6
|
+
|
|
7
|
+
function cacheKey(fn, args) {
|
|
8
|
+
return `${fn.name}:${JSON.stringify(args)}`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function memoize(fn) {
|
|
12
|
+
return function(...args) {
|
|
13
|
+
const key = cacheKey(fn, args)
|
|
14
|
+
const cached = cache.get(key)
|
|
15
|
+
if (cached && Date.now() - cached.ts < ttl) {
|
|
16
|
+
hits++
|
|
17
|
+
return cached.value
|
|
18
|
+
}
|
|
19
|
+
misses++
|
|
20
|
+
const value = fn(...args)
|
|
21
|
+
cache.set(key, { value, ts: Date.now() })
|
|
22
|
+
if (cache.size > maxSize) {
|
|
23
|
+
const first = cache.keys().next().value
|
|
24
|
+
cache.delete(first)
|
|
25
|
+
}
|
|
26
|
+
return value
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function invalidate(pattern) {
|
|
31
|
+
if (!pattern) {
|
|
32
|
+
cache.clear()
|
|
33
|
+
return cache.size
|
|
34
|
+
}
|
|
35
|
+
let count = 0
|
|
36
|
+
for (const key of cache.keys()) {
|
|
37
|
+
if (key.includes(pattern)) {
|
|
38
|
+
cache.delete(key)
|
|
39
|
+
count++
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return count
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getCacheStats() {
|
|
46
|
+
const total = hits + misses
|
|
47
|
+
return {
|
|
48
|
+
size: cache.size,
|
|
49
|
+
hits,
|
|
50
|
+
misses,
|
|
51
|
+
hitRate: total > 0 ? (hits / total * 100).toFixed(2) + '%' : '0%',
|
|
52
|
+
maxSize,
|
|
53
|
+
ttl
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function clearCache() {
|
|
58
|
+
cache.clear()
|
|
59
|
+
hits = 0
|
|
60
|
+
misses = 0
|
|
61
|
+
}
|