@eldrin-project/eldrin-app-core 0.0.1

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/dist/index.cjs ADDED
@@ -0,0 +1,582 @@
1
+ 'use strict';
2
+
3
+ var react = require('react');
4
+ var promises = require('fs/promises');
5
+ var fs = require('fs');
6
+ var path = require('path');
7
+ var jsxRuntime = require('react/jsx-runtime');
8
+
9
+ // src/app/createApp.tsx
10
+
11
+ // src/migrations/checksum.ts
12
+ var CHECKSUM_PREFIX = "sha256:";
13
+ async function calculateChecksum(content, options = {}) {
14
+ const encoder = new TextEncoder();
15
+ const data = encoder.encode(content);
16
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
17
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
18
+ const hex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
19
+ return options.prefixed ? `${CHECKSUM_PREFIX}${hex}` : hex;
20
+ }
21
+ async function calculatePrefixedChecksum(content) {
22
+ return calculateChecksum(content, { prefixed: true });
23
+ }
24
+ async function verifyChecksum(content, expectedChecksum) {
25
+ const normalizedExpected = expectedChecksum.startsWith(CHECKSUM_PREFIX) ? expectedChecksum.slice(CHECKSUM_PREFIX.length) : expectedChecksum;
26
+ const actualChecksum = await calculateChecksum(content);
27
+ return actualChecksum === normalizedExpected;
28
+ }
29
+
30
+ // src/migrations/sql-parser.ts
31
+ function parseSQLStatements(sql) {
32
+ const statements = [];
33
+ let current = "";
34
+ let inString = false;
35
+ let stringChar = "";
36
+ let i = 0;
37
+ const lines = sql.split("\n");
38
+ const cleanedLines = [];
39
+ for (const line of lines) {
40
+ let cleanLine = "";
41
+ let lineInString = false;
42
+ let lineStringChar = "";
43
+ for (let j = 0; j < line.length; j++) {
44
+ const char = line[j];
45
+ const nextChar = line[j + 1];
46
+ if (!lineInString) {
47
+ if (char === "-" && nextChar === "-") {
48
+ break;
49
+ }
50
+ if (char === "'" || char === '"') {
51
+ lineInString = true;
52
+ lineStringChar = char;
53
+ }
54
+ } else {
55
+ if (char === lineStringChar) {
56
+ if (nextChar === lineStringChar) {
57
+ cleanLine += char;
58
+ j++;
59
+ cleanLine += line[j];
60
+ continue;
61
+ }
62
+ lineInString = false;
63
+ }
64
+ }
65
+ cleanLine += char;
66
+ }
67
+ cleanedLines.push(cleanLine);
68
+ }
69
+ const cleanedSql = cleanedLines.join("\n");
70
+ for (i = 0; i < cleanedSql.length; i++) {
71
+ const char = cleanedSql[i];
72
+ if (!inString) {
73
+ if (char === "'" || char === '"') {
74
+ inString = true;
75
+ stringChar = char;
76
+ current += char;
77
+ } else if (char === ";") {
78
+ const trimmed2 = current.trim();
79
+ if (trimmed2.length > 0) {
80
+ statements.push(trimmed2);
81
+ }
82
+ current = "";
83
+ } else {
84
+ current += char;
85
+ }
86
+ } else {
87
+ current += char;
88
+ if (char === stringChar) {
89
+ const nextChar = cleanedSql[i + 1];
90
+ if (nextChar === stringChar) {
91
+ i++;
92
+ current += nextChar;
93
+ } else {
94
+ inString = false;
95
+ }
96
+ }
97
+ }
98
+ }
99
+ const trimmed = current.trim();
100
+ if (trimmed.length > 0) {
101
+ statements.push(trimmed);
102
+ }
103
+ return statements;
104
+ }
105
+ function isValidMigrationFilename(filename) {
106
+ if (!filename.endsWith(".sql") || filename.endsWith(".rollback.sql")) {
107
+ return false;
108
+ }
109
+ const pattern = /^\d{14}-[a-z0-9-]+\.sql$/;
110
+ return pattern.test(filename);
111
+ }
112
+ function isValidRollbackFilename(filename) {
113
+ if (!filename.endsWith(".rollback.sql")) {
114
+ return false;
115
+ }
116
+ const pattern = /^\d{14}-[a-z0-9-]+\.rollback\.sql$/;
117
+ return pattern.test(filename);
118
+ }
119
+ function extractTimestamp(filename) {
120
+ const match = filename.match(/^(\d{14})-/);
121
+ return match ? match[1] : null;
122
+ }
123
+ function getRollbackFilename(migrationFilename) {
124
+ return migrationFilename.replace(/\.sql$/, ".rollback.sql");
125
+ }
126
+
127
+ // src/migrations/runner.ts
128
+ var CREATE_MIGRATIONS_TABLE = `
129
+ CREATE TABLE IF NOT EXISTS _eldrin_migrations (
130
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
131
+ filename TEXT NOT NULL UNIQUE,
132
+ checksum TEXT NOT NULL,
133
+ executed_at INTEGER NOT NULL,
134
+ execution_time_ms INTEGER
135
+ )
136
+ `;
137
+ var defaultLogger = () => {
138
+ };
139
+ async function runMigrations(db, options = {}) {
140
+ const { migrations = [], skipChecksumVerification = false, onLog = defaultLogger } = options;
141
+ const log = (message, level = "info") => {
142
+ onLog(message, level);
143
+ };
144
+ const result = {
145
+ success: true,
146
+ executed: 0,
147
+ migrations: []
148
+ };
149
+ try {
150
+ log("Creating migrations tracking table if not exists...");
151
+ await db.batch([db.prepare(CREATE_MIGRATIONS_TABLE)]);
152
+ const validMigrations = migrations.filter((m) => isValidMigrationFilename(m.name)).sort((a, b) => a.name.localeCompare(b.name));
153
+ if (validMigrations.length === 0) {
154
+ log("No migration files found");
155
+ return result;
156
+ }
157
+ log(`Found ${validMigrations.length} migration file(s)`);
158
+ const { results: executed } = await db.prepare("SELECT filename, checksum FROM _eldrin_migrations ORDER BY filename").all();
159
+ const executedMap = new Map(executed?.map((e) => [e.filename, e.checksum]) ?? []);
160
+ if (!skipChecksumVerification) {
161
+ for (const migration of validMigrations) {
162
+ const storedChecksum = executedMap.get(migration.name);
163
+ if (storedChecksum) {
164
+ const currentChecksum = await calculateChecksum(migration.content);
165
+ if (currentChecksum !== storedChecksum) {
166
+ const error = new Error(
167
+ `Migration ${migration.name} has been modified after execution! Expected checksum: ${storedChecksum}, got: ${currentChecksum}`
168
+ );
169
+ log(error.message, "error");
170
+ return {
171
+ success: false,
172
+ executed: 0,
173
+ migrations: [],
174
+ error: {
175
+ migration: migration.name,
176
+ message: error.message
177
+ }
178
+ };
179
+ }
180
+ }
181
+ }
182
+ }
183
+ for (const [filename] of executedMap) {
184
+ const hasFile = validMigrations.some((m) => m.name === filename);
185
+ if (!hasFile) {
186
+ log(`Warning: Orphaned migration in database: ${filename}`, "warn");
187
+ }
188
+ }
189
+ const pending = validMigrations.filter((m) => !executedMap.has(m.name));
190
+ if (pending.length === 0) {
191
+ log("No pending migrations");
192
+ return result;
193
+ }
194
+ log(`Found ${pending.length} pending migration(s)`);
195
+ for (const migration of pending) {
196
+ log(`Executing migration: ${migration.name}`);
197
+ const startTime = Date.now();
198
+ try {
199
+ const statements = parseSQLStatements(migration.content);
200
+ if (statements.length === 0) {
201
+ log(`Warning: Migration ${migration.name} contains no SQL statements`, "warn");
202
+ continue;
203
+ }
204
+ const checksum = await calculateChecksum(migration.content);
205
+ const batch = [
206
+ // Execute all statements from the migration
207
+ ...statements.map((sql) => db.prepare(sql)),
208
+ // Record execution in tracking table
209
+ db.prepare(
210
+ `INSERT INTO _eldrin_migrations
211
+ (filename, checksum, executed_at, execution_time_ms)
212
+ VALUES (?, ?, ?, ?)`
213
+ ).bind(migration.name, checksum, Date.now(), Date.now() - startTime)
214
+ ];
215
+ await db.batch(batch);
216
+ const executionTime = Date.now() - startTime;
217
+ log(`Migration ${migration.name} completed (${executionTime}ms)`);
218
+ result.executed++;
219
+ result.migrations.push({
220
+ name: migration.name,
221
+ executionTimeMs: executionTime
222
+ });
223
+ } catch (error) {
224
+ const errorMessage = error instanceof Error ? error.message : String(error);
225
+ log(`Migration ${migration.name} failed: ${errorMessage}`, "error");
226
+ return {
227
+ success: false,
228
+ executed: result.executed,
229
+ migrations: result.migrations,
230
+ error: {
231
+ migration: migration.name,
232
+ message: errorMessage
233
+ }
234
+ };
235
+ }
236
+ }
237
+ log(`Successfully executed ${result.executed} migration(s)`);
238
+ return result;
239
+ } catch (error) {
240
+ const errorMessage = error instanceof Error ? error.message : String(error);
241
+ log(`Migration system error: ${errorMessage}`, "error");
242
+ return {
243
+ success: false,
244
+ executed: result.executed,
245
+ migrations: result.migrations,
246
+ error: {
247
+ migration: "system",
248
+ message: errorMessage
249
+ }
250
+ };
251
+ }
252
+ }
253
+ async function getMigrationStatus(db, migrations) {
254
+ await db.batch([db.prepare(CREATE_MIGRATIONS_TABLE)]);
255
+ const { results: executed } = await db.prepare("SELECT * FROM _eldrin_migrations ORDER BY filename").all();
256
+ const executedMap = new Map(executed?.map((e) => [e.filename, e]) ?? []);
257
+ const validMigrations = migrations.filter((m) => isValidMigrationFilename(m.name));
258
+ const pending = validMigrations.filter((m) => !executedMap.has(m.name)).map((m) => m.name).sort();
259
+ const orphaned = [];
260
+ for (const [filename] of executedMap) {
261
+ if (!validMigrations.some((m) => m.name === filename)) {
262
+ orphaned.push(filename);
263
+ }
264
+ }
265
+ const checksumMismatches = [];
266
+ for (const migration of validMigrations) {
267
+ const record = executedMap.get(migration.name);
268
+ if (record) {
269
+ const currentChecksum = await calculateChecksum(migration.content);
270
+ if (currentChecksum !== record.checksum) {
271
+ checksumMismatches.push(migration.name);
272
+ }
273
+ }
274
+ }
275
+ return {
276
+ pending,
277
+ executed: executed ?? [],
278
+ orphaned,
279
+ checksumMismatches
280
+ };
281
+ }
282
+
283
+ // src/migrations/rollback.ts
284
+ async function rollbackMigrations(db, options) {
285
+ const { rollbackFiles, targetMigration, onLog = () => {
286
+ } } = options;
287
+ const log = (message, level = "info") => {
288
+ onLog(message, level);
289
+ };
290
+ const result = {
291
+ success: true,
292
+ rolledBack: []
293
+ };
294
+ try {
295
+ const { results: executed } = await db.prepare("SELECT filename FROM _eldrin_migrations ORDER BY filename DESC").all();
296
+ if (!executed || executed.length === 0) {
297
+ log("No migrations to rollback");
298
+ return result;
299
+ }
300
+ let toRollback;
301
+ if (targetMigration) {
302
+ const targetIndex = executed.findIndex((m) => m.filename === targetMigration);
303
+ if (targetIndex === -1) {
304
+ return {
305
+ success: false,
306
+ rolledBack: [],
307
+ error: {
308
+ migration: targetMigration,
309
+ message: `Target migration not found: ${targetMigration}`
310
+ }
311
+ };
312
+ }
313
+ toRollback = executed.slice(0, targetIndex).map((m) => m.filename);
314
+ } else {
315
+ toRollback = [executed[0].filename];
316
+ }
317
+ if (toRollback.length === 0) {
318
+ log("No migrations to rollback");
319
+ return result;
320
+ }
321
+ const rollbackMap = new Map(
322
+ rollbackFiles.filter((f) => isValidRollbackFilename(f.name)).map((f) => [f.name, f])
323
+ );
324
+ for (const migrationFilename of toRollback) {
325
+ const rollbackFilename = getRollbackFilename(migrationFilename);
326
+ const rollbackFile = rollbackMap.get(rollbackFilename);
327
+ if (!rollbackFile) {
328
+ return {
329
+ success: false,
330
+ rolledBack: result.rolledBack,
331
+ error: {
332
+ migration: migrationFilename,
333
+ message: `Rollback file not found: ${rollbackFilename}`
334
+ }
335
+ };
336
+ }
337
+ log(`Rolling back: ${migrationFilename}`);
338
+ try {
339
+ const statements = parseSQLStatements(rollbackFile.content);
340
+ if (statements.length === 0) {
341
+ log(`Warning: Rollback file ${rollbackFilename} contains no SQL statements`, "warn");
342
+ }
343
+ const batch = [
344
+ ...statements.map((sql) => db.prepare(sql)),
345
+ db.prepare("DELETE FROM _eldrin_migrations WHERE filename = ?").bind(migrationFilename)
346
+ ];
347
+ await db.batch(batch);
348
+ log(`Rolled back: ${migrationFilename}`);
349
+ result.rolledBack.push(migrationFilename);
350
+ } catch (error) {
351
+ const errorMessage = error instanceof Error ? error.message : String(error);
352
+ log(`Rollback failed for ${migrationFilename}: ${errorMessage}`, "error");
353
+ return {
354
+ success: false,
355
+ rolledBack: result.rolledBack,
356
+ error: {
357
+ migration: migrationFilename,
358
+ message: errorMessage
359
+ }
360
+ };
361
+ }
362
+ }
363
+ log(`Successfully rolled back ${result.rolledBack.length} migration(s)`);
364
+ return result;
365
+ } catch (error) {
366
+ const errorMessage = error instanceof Error ? error.message : String(error);
367
+ log(`Rollback system error: ${errorMessage}`, "error");
368
+ return {
369
+ success: false,
370
+ rolledBack: result.rolledBack,
371
+ error: {
372
+ migration: "system",
373
+ message: errorMessage
374
+ }
375
+ };
376
+ }
377
+ }
378
+ async function readMigrationFiles(dir) {
379
+ if (!fs.existsSync(dir)) {
380
+ return [];
381
+ }
382
+ const files = await promises.readdir(dir);
383
+ const sqlFiles = files.filter((f) => isValidMigrationFilename(f)).sort();
384
+ const migrations = [];
385
+ for (const file of sqlFiles) {
386
+ const content = await promises.readFile(path.resolve(dir, file), "utf-8");
387
+ migrations.push({
388
+ name: path.basename(file),
389
+ content
390
+ });
391
+ }
392
+ return migrations;
393
+ }
394
+ async function generateMigrationManifest(options) {
395
+ const { migrationsDir, database } = options;
396
+ const files = await readMigrationFiles(migrationsDir);
397
+ const migrations = await Promise.all(
398
+ files.map(async (file) => {
399
+ const timestamp = extractTimestamp(file.name);
400
+ const checksum = await calculatePrefixedChecksum(file.content);
401
+ return {
402
+ id: timestamp || file.name.replace(".sql", ""),
403
+ file: file.name,
404
+ checksum
405
+ };
406
+ })
407
+ );
408
+ return {
409
+ manifest: {
410
+ database,
411
+ migrations
412
+ },
413
+ files
414
+ };
415
+ }
416
+ async function validateMigrationManifest(manifest, files) {
417
+ const errors = [];
418
+ for (const entry of manifest.migrations) {
419
+ const file = files.find((f) => f.name === entry.file);
420
+ if (!file) {
421
+ errors.push(`Missing file: ${entry.file}`);
422
+ continue;
423
+ }
424
+ const actualChecksum = await calculatePrefixedChecksum(file.content);
425
+ if (actualChecksum !== entry.checksum) {
426
+ errors.push(
427
+ `Checksum mismatch for ${entry.file}: expected ${entry.checksum}, got ${actualChecksum}`
428
+ );
429
+ }
430
+ const timestamp = extractTimestamp(file.name);
431
+ if (timestamp !== entry.id) {
432
+ errors.push(
433
+ `ID mismatch for ${entry.file}: expected ${timestamp}, got ${entry.id}`
434
+ );
435
+ }
436
+ }
437
+ for (const file of files) {
438
+ if (!manifest.migrations.some((m) => m.file === file.name)) {
439
+ errors.push(`File not in manifest: ${file.name}`);
440
+ }
441
+ }
442
+ return {
443
+ valid: errors.length === 0,
444
+ errors
445
+ };
446
+ }
447
+ var EldrinDatabaseContext = react.createContext(null);
448
+ function DatabaseProvider({
449
+ db,
450
+ migrationsComplete,
451
+ migrationResult,
452
+ children
453
+ }) {
454
+ const value = {
455
+ db,
456
+ migrationsComplete,
457
+ migrationResult
458
+ };
459
+ return /* @__PURE__ */ jsxRuntime.jsx(EldrinDatabaseContext.Provider, { value, children });
460
+ }
461
+ function useDatabase() {
462
+ const context = react.useContext(EldrinDatabaseContext);
463
+ if (context === null) {
464
+ throw new Error("useDatabase must be used within a DatabaseProvider (via createApp)");
465
+ }
466
+ return context.db;
467
+ }
468
+ function useDatabaseContext() {
469
+ const context = react.useContext(EldrinDatabaseContext);
470
+ if (context === null) {
471
+ throw new Error("useDatabaseContext must be used within a DatabaseProvider (via createApp)");
472
+ }
473
+ return context;
474
+ }
475
+ function useMigrationsComplete() {
476
+ const context = react.useContext(EldrinDatabaseContext);
477
+ return context?.migrationsComplete ?? false;
478
+ }
479
+
480
+ // src/app/createApp.tsx
481
+ function createApp(options) {
482
+ const { name, root: RootComponent, migrations = [], onMigrationsComplete, onMigrationError } = options;
483
+ const state = {
484
+ db: null,
485
+ migrationsComplete: false,
486
+ migrationResult: void 0,
487
+ mountedElement: void 0,
488
+ reactRoot: void 0
489
+ };
490
+ async function bootstrap(props) {
491
+ const db = props.db ?? null;
492
+ state.db = db;
493
+ if (db && migrations.length > 0) {
494
+ try {
495
+ const result = await runMigrations(db, {
496
+ migrations,
497
+ onLog: (message, level) => {
498
+ const prefix = `[${name}]`;
499
+ if (level === "error") {
500
+ console.error(prefix, message);
501
+ } else if (level === "warn") {
502
+ console.warn(prefix, message);
503
+ } else {
504
+ console.log(prefix, message);
505
+ }
506
+ }
507
+ });
508
+ state.migrationResult = result;
509
+ if (result.success) {
510
+ state.migrationsComplete = true;
511
+ onMigrationsComplete?.(result);
512
+ } else {
513
+ const error = new Error(result.error?.message ?? "Migration failed");
514
+ onMigrationError?.(error);
515
+ throw error;
516
+ }
517
+ } catch (error) {
518
+ state.migrationsComplete = false;
519
+ const err = error instanceof Error ? error : new Error(String(error));
520
+ onMigrationError?.(err);
521
+ throw err;
522
+ }
523
+ } else {
524
+ state.migrationsComplete = true;
525
+ }
526
+ }
527
+ async function mount(props) {
528
+ const domElement = props.domElement ?? document.getElementById(`app-${name}`);
529
+ if (!domElement) {
530
+ throw new Error(`No DOM element found for app "${name}". Expected element with id="app-${name}" or domElement prop.`);
531
+ }
532
+ state.mountedElement = domElement;
533
+ const rootElement = react.createElement(RootComponent, props.customProps ?? {});
534
+ const appElement = react.createElement(DatabaseProvider, {
535
+ db: state.db,
536
+ migrationsComplete: state.migrationsComplete,
537
+ migrationResult: state.migrationResult,
538
+ children: rootElement
539
+ });
540
+ const ReactDOM = await import('react-dom/client');
541
+ const root = ReactDOM.createRoot(domElement);
542
+ root.render(appElement);
543
+ state.reactRoot = root;
544
+ }
545
+ async function unmount(_props) {
546
+ if (state.reactRoot) {
547
+ state.reactRoot.unmount();
548
+ state.reactRoot = void 0;
549
+ }
550
+ if (state.mountedElement) {
551
+ state.mountedElement.innerHTML = "";
552
+ state.mountedElement = void 0;
553
+ }
554
+ }
555
+ return {
556
+ bootstrap,
557
+ mount,
558
+ unmount
559
+ };
560
+ }
561
+
562
+ exports.CHECKSUM_PREFIX = CHECKSUM_PREFIX;
563
+ exports.DatabaseProvider = DatabaseProvider;
564
+ exports.calculateChecksum = calculateChecksum;
565
+ exports.calculatePrefixedChecksum = calculatePrefixedChecksum;
566
+ exports.createApp = createApp;
567
+ exports.extractTimestamp = extractTimestamp;
568
+ exports.generateMigrationManifest = generateMigrationManifest;
569
+ exports.getMigrationStatus = getMigrationStatus;
570
+ exports.getRollbackFilename = getRollbackFilename;
571
+ exports.isValidMigrationFilename = isValidMigrationFilename;
572
+ exports.isValidRollbackFilename = isValidRollbackFilename;
573
+ exports.parseSQLStatements = parseSQLStatements;
574
+ exports.rollbackMigrations = rollbackMigrations;
575
+ exports.runMigrations = runMigrations;
576
+ exports.useDatabase = useDatabase;
577
+ exports.useDatabaseContext = useDatabaseContext;
578
+ exports.useMigrationsComplete = useMigrationsComplete;
579
+ exports.validateMigrationManifest = validateMigrationManifest;
580
+ exports.verifyChecksum = verifyChecksum;
581
+ //# sourceMappingURL=index.cjs.map
582
+ //# sourceMappingURL=index.cjs.map