thatcher 1.0.6 → 1.0.33

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 +29 -88
  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 +14 -5
  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
@@ -1,193 +0,0 @@
1
- import { getDatabase, genId, now } from '@/lib/database-core';
2
- import { Mutex } from '@/lib/hot-reload/mutex';
3
-
4
- const db = getDatabase();
5
- const auditMutex = new Mutex('audit-logger');
6
-
7
- export const ACTIVITY_TYPES = {
8
- RFI_QUESTION_VIEW: 'rfi_question_view',
9
- RFI_QUESTION_RESPOND: 'rfi_question_respond',
10
- RFI_QUESTION_COMMENT: 'rfi_question_comment',
11
- RFI_QUESTION_CREATE: 'rfi_question_create',
12
- RFI_QUESTION_UPDATE: 'rfi_question_update',
13
- RFI_QUESTION_DELETE: 'rfi_question_delete',
14
- RFI_QUESTION_ASSIGN: 'rfi_question_assign',
15
- RFI_QUESTION_DEADLINE: 'rfi_question_deadline',
16
- HIGHLIGHT_CREATE: 'highlight_create',
17
- HIGHLIGHT_RESOLVE: 'highlight_resolve',
18
- HIGHLIGHT_REOPEN: 'highlight_reopen',
19
- HIGHLIGHT_DELETE: 'highlight_delete',
20
- REVIEW_CREATE: 'review_create',
21
- REVIEW_UPDATE: 'review_update',
22
- REVIEW_ARCHIVE: 'review_archive',
23
- COLLABORATOR_ADD: 'collaborator_add',
24
- COLLABORATOR_REMOVE: 'collaborator_remove',
25
- PERMISSION_CHANGE: 'permission_change',
26
- ROLE_CHANGE: 'role_change',
27
- };
28
-
29
- const parseJson = (val) => val ? JSON.parse(val) : null;
30
-
31
- const parseRow = (row) => row ? {
32
- ...row,
33
- old_permissions: parseJson(row.old_permissions),
34
- new_permissions: parseJson(row.new_permissions),
35
- metadata: parseJson(row.metadata),
36
- before_state: parseJson(row.before_state),
37
- after_state: parseJson(row.after_state),
38
- } : null;
39
-
40
- export const logAction = (entityType, entityId, action, userId, beforeState, afterState) => {
41
- const id = genId();
42
- const timestamp = now();
43
- db.prepare(`
44
- INSERT INTO audit_logs (id, entity_type, entity_id, action, user_id, before_state, after_state, created_at)
45
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
46
- `).run(id, entityType, entityId, action, userId || null,
47
- beforeState ? JSON.stringify(beforeState) : null,
48
- afterState ? JSON.stringify(afterState) : null,
49
- timestamp
50
- );
51
- return { id, timestamp };
52
- };
53
-
54
- export const getAuditHistory = (filters = {}, page = 1, pageSize = 50) => {
55
- const wc = [], params = [];
56
- if (filters.entityType) { wc.push('entity_type = ?'); params.push(filters.entityType); }
57
- if (filters.entityId) { wc.push('entity_id = ?'); params.push(filters.entityId); }
58
- if (filters.userId) { wc.push('user_id = ?'); params.push(filters.userId); }
59
- if (filters.action) { wc.push('action = ?'); params.push(filters.action); }
60
- if (filters.fromDate) { wc.push('created_at >= ?'); params.push(filters.fromDate); }
61
- if (filters.toDate) { wc.push('created_at <= ?'); params.push(filters.toDate); }
62
- const where = wc.length ? 'WHERE ' + wc.join(' AND ') : '';
63
- const { count: total } = db.prepare(`SELECT COUNT(*) as count FROM audit_logs ${where}`).get(...params);
64
- const offset = (page - 1) * pageSize;
65
- const items = db.prepare(`SELECT * FROM audit_logs ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
66
- return {
67
- items: items.map(i => ({
68
- id: i.id, entityType: i.entity_type, entityId: i.entity_id, action: i.action,
69
- userId: i.user_id, beforeState: parseJson(i.before_state), afterState: parseJson(i.after_state), createdAt: i.created_at,
70
- })),
71
- pagination: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) },
72
- };
73
- };
74
-
75
- export const getEntityAuditTrail = (entityType, entityId) => {
76
- return db.prepare(`SELECT * FROM audit_logs WHERE entity_type = ? AND entity_id = ? ORDER BY created_at DESC`).all(entityType, entityId)
77
- .map(i => ({ id: i.id, action: i.action, userId: i.user_id, beforeState: parseJson(i.before_state), afterState: parseJson(i.after_state), createdAt: i.created_at }));
78
- };
79
-
80
- const statsSql = (col) => `SELECT ${col}, COUNT(*) as count FROM audit_logs WHERE created_at >= ? AND created_at <= ? GROUP BY ${col} ORDER BY count DESC`;
81
- export const getActionStats = (fromDate, toDate) => db.prepare(statsSql('action')).all(fromDate, toDate);
82
- export const getUserStats = (fromDate, toDate) => db.prepare(statsSql('user_id')).all(fromDate, toDate);
83
-
84
- export const logPermissionChange = ({
85
- userId, entityType, entityId, action, oldPermissions = null, newPermissions = null,
86
- reason = null, reasonCode = 'other', affectedUserId = null, ipAddress = null, sessionId = null, metadata = null,
87
- }) => {
88
- if (!userId || !entityType || !entityId || !action) throw new Error('Missing required audit fields');
89
- const auditId = genId();
90
- const timestamp = now();
91
- db.prepare(`
92
- INSERT INTO permission_audit (id, user_id, entity_type, entity_id, action, old_permissions, new_permissions,
93
- reason, reason_code, timestamp, ip_address, session_id, affected_user_id, metadata, created_at, updated_at, created_by, updated_by)
94
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
95
- `).run(auditId, userId, entityType, entityId, action,
96
- oldPermissions ? JSON.stringify(oldPermissions) : null, newPermissions ? JSON.stringify(newPermissions) : null,
97
- reason, reasonCode, timestamp, ipAddress, sessionId, affectedUserId,
98
- metadata ? JSON.stringify(metadata) : null, timestamp, timestamp, userId, userId
99
- );
100
- return auditId;
101
- };
102
-
103
- export const auditPermissionChange = async ({ user, entityType, entityId, action, oldPermissions = null,
104
- newPermissions = null, affectedUserId = null, reason = null, reasonCode = 'admin_action', metadata = null }) => {
105
- return auditMutex.runExclusive(() => {
106
- try {
107
- logPermissionChange({ userId: user.id, entityType, entityId, action, oldPermissions, newPermissions,
108
- reason: reason || `Permission ${action}`, reasonCode, affectedUserId, metadata });
109
- } catch (error) {
110
- console.error('[Audit] Failed to log permission change:', error);
111
- }
112
- });
113
- };
114
-
115
- export const auditRoleChange = async ({ user, targetUserId, oldRole, newRole, reason = null, metadata = null }) => {
116
- await auditPermissionChange({ user, entityType: 'user', entityId: targetUserId, action: 'role_change',
117
- oldPermissions: { role: oldRole }, newPermissions: { role: newRole },
118
- affectedUserId: targetUserId, reason: reason || `Role changed from ${oldRole} to ${newRole}`,
119
- reasonCode: 'role_change', metadata: { ...metadata, old_role: oldRole, new_role: newRole } });
120
- };
121
-
122
- export const auditCollaboratorAdded = async ({ user, reviewId, collaboratorId, collaboratorUserId, permissions = null, reason = null, metadata = null }) => {
123
- await auditPermissionChange({ user, entityType: 'review', entityId: reviewId, action: 'grant',
124
- newPermissions: permissions || { access: 'collaborator' }, affectedUserId: collaboratorUserId,
125
- reason: reason || 'Collaborator added to review', reasonCode: 'collaborator_added',
126
- metadata: { ...metadata, collaborator_id: collaboratorId, review_id: reviewId } });
127
- };
128
-
129
- export const auditCollaboratorRemoved = async ({ user, reviewId, collaboratorId, collaboratorUserId, permissions = null, reason = null, metadata = null }) => {
130
- await auditPermissionChange({ user, entityType: 'review', entityId: reviewId, action: 'revoke',
131
- oldPermissions: permissions || { access: 'collaborator' }, affectedUserId: collaboratorUserId,
132
- reason: reason || 'Collaborator removed from review', reasonCode: 'collaborator_removed',
133
- metadata: { ...metadata, collaborator_id: collaboratorId, review_id: reviewId } });
134
- };
135
-
136
- export const auditLifecycleTransition = async ({ user, entityType, entityId, fromStage, toStage, metadata = null }) => {
137
- await auditPermissionChange({ user, entityType, entityId, action: 'modify',
138
- oldPermissions: { stage: fromStage }, newPermissions: { stage: toStage },
139
- reason: `Lifecycle transition: ${fromStage} -> ${toStage}`, reasonCode: 'lifecycle_transition',
140
- metadata: { ...metadata, from_stage: fromStage, to_stage: toStage } });
141
- };
142
-
143
- export const auditPermissionModify = async ({ user, entityType, entityId, oldPermissions, newPermissions,
144
- affectedUserId = null, reason = null, reasonCode = 'admin_action', metadata = null }) => {
145
- await auditPermissionChange({ user, entityType, entityId, action: 'modify',
146
- oldPermissions, newPermissions, affectedUserId, reason: reason || 'Permissions modified', reasonCode, metadata });
147
- };
148
-
149
- export const getPermissionAuditTrail = ({ entityType, entityId, userId, affectedUserId, limit = 100, offset = 0 }) => {
150
- let sql = 'SELECT * FROM permission_audit WHERE 1=1';
151
- const params = [];
152
- if (entityType) { sql += ' AND entity_type = ?'; params.push(entityType); }
153
- if (entityId) { sql += ' AND entity_id = ?'; params.push(entityId); }
154
- if (userId) { sql += ' AND user_id = ?'; params.push(userId); }
155
- if (affectedUserId) { sql += ' AND affected_user_id = ?'; params.push(affectedUserId); }
156
- sql += ' ORDER BY timestamp DESC LIMIT ? OFFSET ?';
157
- return db.prepare(sql).all(...params, limit, offset).map(parseRow);
158
- };
159
-
160
- export const getPermissionAuditById = (auditId) => parseRow(db.prepare('SELECT * FROM permission_audit WHERE id = ?').get(auditId));
161
- export const getPermissionAuditStats = () => db.prepare(`SELECT COUNT(*) as total_audits, COUNT(DISTINCT user_id) as unique_users, COUNT(DISTINCT entity_type) as entity_types, MIN(timestamp) as earliest_change, MAX(timestamp) as latest_change FROM permission_audit`).get();
162
- export const getPermissionAuditBreakdown = (field) => db.prepare(`SELECT ${field}, COUNT(*) as count FROM permission_audit GROUP BY ${field} ORDER BY count DESC`).all();
163
-
164
- export const searchPermissionAudit = (searchTerm, limit = 100) => {
165
- const pattern = `%${searchTerm}%`;
166
- return db.prepare(`SELECT * FROM permission_audit WHERE reason LIKE ? OR entity_type LIKE ? OR entity_id LIKE ? ORDER BY timestamp DESC LIMIT ?`)
167
- .all(pattern, pattern, pattern, limit).map(parseRow);
168
- };
169
-
170
- export const getPermissionAuditByDateRange = (startDate, endDate, limit = 100) =>
171
- db.prepare(`SELECT * FROM permission_audit WHERE timestamp >= ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT ?`).all(startDate, endDate, limit).map(parseRow);
172
-
173
- export const exportPermissionAuditCSV = async (filters = {}) => {
174
- const trail = getPermissionAuditTrail({ ...filters, limit: 10000 });
175
- const h = 'Timestamp,Changed By,Entity Type,Entity ID,Action,Reason Code,Reason,Affected User,IP Address';
176
- const rows = trail.map(a => [new Date(a.timestamp * 1000).toISOString(), a.user_id, a.entity_type,
177
- a.entity_id, a.action, a.reason_code, a.reason || '', a.affected_user_id || '', a.ip_address || '']
178
- .map(c => `"${c}"`).join(','));
179
- return [h, ...rows].join('\n');
180
- };
181
-
182
- export const logQuestionActivity = (questionId, action, userId, details = null) => {
183
- return logAction('rfi_question', questionId, action, userId, null, details);
184
- };
185
-
186
- export const getPermissionDiff = (oldPerms, newPerms) => {
187
- const diff = { added: [], removed: [], unchanged: [] };
188
- const oldSet = new Set(Array.isArray(oldPerms) ? oldPerms : []);
189
- const newSet = new Set(Array.isArray(newPerms) ? newPerms : []);
190
- for (const p of newSet) { if (!oldSet.has(p)) diff.added.push(p); else diff.unchanged.push(p); }
191
- for (const p of oldSet) { if (!newSet.has(p)) diff.removed.push(p); }
192
- return diff;
193
- };
@@ -1,255 +0,0 @@
1
- /**
2
- * Database Core - SQLite initialization and schema management
3
- * Adapted from moonlanding/src/lib/database-core.js
4
- */
5
-
6
- import Database from 'better-sqlite3';
7
- import fs from 'fs';
8
- import path from 'path';
9
- import { runMigrations } from './database-migrations.js';
10
-
11
- const SQL_TYPES = {
12
- id: 'TEXT PRIMARY KEY',
13
- text: 'TEXT',
14
- textarea: 'TEXT',
15
- email: 'TEXT',
16
- int: 'INTEGER',
17
- decimal: 'REAL',
18
- bool: 'INTEGER',
19
- date: 'INTEGER',
20
- timestamp: 'INTEGER',
21
- json: 'TEXT',
22
- image: 'TEXT',
23
- ref: 'TEXT',
24
- enum: 'TEXT',
25
- };
26
-
27
- let db = null;
28
- let migrationComplete = false;
29
- const moduleCache = new Map();
30
-
31
- /**
32
- * Get or create database instance
33
- * @param {string} dbPath - Path to SQLite database file
34
- * @returns {Database} better-sqlite3 database instance
35
- */
36
- export function getDatabase(dbPath = null) {
37
- if (db && !dbPath) return db;
38
-
39
- const DB_PATH = dbPath || path.resolve(process.cwd(), 'data', 'app.db');
40
- const dataDir = path.dirname(DB_PATH);
41
-
42
- if (!fs.existsSync(dataDir)) {
43
- fs.mkdirSync(dataDir, { recursive: true });
44
- }
45
-
46
- db = new Database(DB_PATH);
47
- db.pragma('journal_mode = WAL');
48
- const BUSY_TIMEOUT_MS = process.env.DATABASE_BUSY_TIMEOUT_MS || '5000';
49
- db.pragma(`busy_timeout = ${BUSY_TIMEOUT_MS}`);
50
- db.pragma('synchronous = NORMAL');
51
- db.pragma('foreign_keys = ON');
52
- db.pragma('auto_vacuum = INCREMENTAL');
53
-
54
- return db;
55
- }
56
-
57
- /**
58
- * Generate unique ID using nanoid-like algorithm
59
- * @returns {string}
60
- */
61
- export function genId() {
62
- // Simple but effective ID generation
63
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
64
- }
65
-
66
- /**
67
- * Get current Unix timestamp (seconds)
68
- * @returns {number}
69
- */
70
- export function now() {
71
- return Math.floor(Date.now() / 1000);
72
- }
73
-
74
- /**
75
- * Build column definition from field spec
76
- * @param {string} key - Field name
77
- * @param {object} field - Field definition
78
- * @returns {string} SQL column definition
79
- */
80
- function buildColumnDef(key, field) {
81
- let col = `"${key}" ${SQL_TYPES[field.type] || 'TEXT'}`;
82
-
83
- if (field.required && field.type !== 'id') {
84
- col += ' NOT NULL';
85
- }
86
-
87
- if (field.unique) {
88
- col += ' UNIQUE';
89
- }
90
-
91
- if (field.default !== undefined) {
92
- if (typeof field.default === 'string' || typeof field.default === 'number' || typeof field.default === 'boolean') {
93
- const defaultVal = typeof field.default === 'string'
94
- ? `'${field.default.replace(/'/g, "''")}'`
95
- : field.default;
96
- col += ` DEFAULT ${defaultVal}`;
97
- }
98
- }
99
-
100
- return col;
101
- }
102
-
103
- /**
104
- * Run migrations to create/update tables from entity specs
105
- * @param {object} configEngine - Config engine with entity specs
106
- */
107
- export function migrate(configEngine) {
108
- if (migrationComplete) return;
109
-
110
- const dbInstance = getDatabase();
111
- let specsToUse = {};
112
-
113
- try {
114
- const allEntities = configEngine.getAllEntities();
115
- for (const entityName of allEntities) {
116
- specsToUse[entityName] = configEngine.generateEntitySpec(entityName);
117
- }
118
- } catch (e) {
119
- console.error('[Database] Failed to get specs from ConfigEngine during migration:', e.message);
120
- return;
121
- }
122
-
123
- // Create tables
124
- for (const spec of Object.values(specsToUse)) {
125
- if (!spec) continue;
126
-
127
- const tableName = spec.name === 'user' ? 'users' : spec.name;
128
- const columns = [];
129
- const foreignKeys = [];
130
-
131
- // Build columns from fields
132
- for (const [key, field] of Object.entries(spec.fields || {})) {
133
- columns.push(buildColumnDef(key, field));
134
-
135
- if (field.type === 'ref' && field.ref) {
136
- const refTable = field.ref === 'user' ? 'users' : field.ref;
137
- foreignKeys.push(`FOREIGN KEY ("${key}") REFERENCES "${refTable}"(id)`);
138
- }
139
- }
140
-
141
- const fkPart = foreignKeys.length ? (',\n' + foreignKeys.join(',\n')) : '';
142
- const sql = `CREATE TABLE IF NOT EXISTS "${tableName}" (${columns.join(',\n')}${fkPart})`;
143
-
144
- try {
145
- dbInstance.exec(sql);
146
- } catch (e) {
147
- console.error(`[Database] Table creation failed for ${tableName}:`, e.message, '\nSQL:', sql);
148
- throw e;
149
- }
150
-
151
- // Add new columns (ALTER TABLE for schema evolution)
152
- try {
153
- const existingCols = new Set(
154
- dbInstance.prepare(`PRAGMA table_info("${tableName}")`).all().map(c => c.name)
155
- );
156
-
157
- for (const [key, field] of Object.entries(spec.fields || {})) {
158
- if (!existingCols.has(key)) {
159
- let colType = SQL_TYPES[field.type] || 'TEXT';
160
- let alterSql = `ALTER TABLE "${tableName}" ADD COLUMN "${key}" ${colType}`;
161
-
162
- if (field.default !== undefined && (typeof field.default === 'string' || typeof field.default === 'number' || typeof field.default === 'boolean')) {
163
- const defaultVal = typeof field.default === 'string'
164
- ? `'${field.default.replace(/'/g, "''")}'`
165
- : field.default;
166
- alterSql += ` DEFAULT ${defaultVal}`;
167
- }
168
-
169
- dbInstance.exec(alterSql);
170
- }
171
- }
172
- } catch (e) {
173
- console.error(`[Database] Column migration failed for ${tableName}:`, e.message);
174
- }
175
- }
176
-
177
- // Create indexes
178
- for (const spec of Object.values(specsToUse)) {
179
- if (!spec) continue;
180
-
181
- const tableName = spec.name === 'user' ? 'users' : spec.name;
182
- const searchFields = [];
183
-
184
- for (const [key, field] of Object.entries(spec.fields || {})) {
185
- if (field.type === 'ref' || field.sortable || field.search) {
186
- try {
187
- dbInstance.exec(`CREATE INDEX IF NOT EXISTS idx_${tableName}_${key} ON "${tableName}"("${key}")`);
188
- } catch (e) {
189
- console.error(`[Database] Index creation failed for ${tableName}.${key}:`, e.message);
190
- }
191
- }
192
-
193
- if (field.search || key === 'name' || key === 'description') {
194
- searchFields.push(`"${key}"`);
195
- }
196
- }
197
-
198
- // Create FTS virtual table for search
199
- if (searchFields.length > 0) {
200
- try {
201
- dbInstance.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS ${tableName}_fts USING fts5(${searchFields.join(', ')}, content="${tableName}", content_rowid=id)`);
202
- } catch (e) {
203
- console.error(`[Database] FTS table creation failed for ${tableName}:`, e.message);
204
- }
205
- }
206
- }
207
-
208
- // Run additional migrations
209
- runCustomMigrations(dbInstance, specsToUse);
210
-
211
- migrationComplete = true;
212
- }
213
-
214
- /**
215
- * Run custom migration logic (triggers, data migrations, etc.)
216
- * @param {Database} dbInstance
217
- * @param {object} specs
218
- */
219
- function runCustomMigrations(dbInstance, specs) {
220
- // Delegate to the dedicated migrations module (ported from moonlanding):
221
- // idempotent triggers, activity_log + notification tables, etc.
222
- try {
223
- runMigrations(dbInstance);
224
- } catch (e) {
225
- console.error('[Database] Custom migrations failed:', e.message);
226
- }
227
- }
228
-
229
- /**
230
- * Ensure database is initialized (lazy migration)
231
- */
232
- export function ensureInitialized(configEngine) {
233
- if (!migrationComplete) {
234
- migrate(configEngine);
235
- }
236
- }
237
-
238
- /**
239
- * Reset database (for testing)
240
- */
241
- export function resetDatabase() {
242
- const DB_PATH = path.resolve(process.cwd(), 'data', 'app.db');
243
- try {
244
- if (db) db.close();
245
- if (fs.existsSync(DB_PATH)) fs.unlinkSync(DB_PATH);
246
- if (fs.existsSync(DB_PATH + '-wal')) fs.unlinkSync(DB_PATH + '-wal');
247
- if (fs.existsSync(DB_PATH + '-shm')) fs.unlinkSync(DB_PATH + '-shm');
248
- db = null;
249
- migrationComplete = false;
250
- } catch (e) {
251
- console.error('[Database] Reset failed:', e.message);
252
- }
253
- }
254
-
255
- export { SQL_TYPES };