@getstrata/core 2.2.18 → 2.2.20

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @getstrata/core changelog
2
2
 
3
+ ## 2.2.20
4
+
5
+ Validate complete migration definitions before callbacks, locks or database effects; reject malformed exports and duplicate names with source diagnostics. Refuse fresh with missing applied definitions and rollback with incomplete latest batches; preserve partial forward migration and remove blanket history deletion. Cover physical schema/data/history preservation and reset recovery on real Postgres, MySQL and SQLite, plus loader and preflight regressions; document safe missing-file recovery and unchanged atomicity limits.
6
+
7
+ ## 2.2.19
8
+
9
+ Fix explicit development fresh resets with unfinished outbox deliveries while preserving ordinary rollback guards. Record each completed reset down immediately so later refusal leaves recoverable migration progress; document partial-DDL/crash limits and old-history reconciliation. Add independent outbox-state and reset failure/migrate/retry regressions on real Postgres, MySQL and SQLite, history-write/reapplication fault injection, and a documented stateful regression review flow.
10
+
3
11
  ## 2.2.18
4
12
 
5
13
  Add optional typed guardMigrationRollback for application-owned rollback history predicates; skip history reads only during explicit development fresh resets, propagate predicate failures, and reuse the contract for encryption-checkpoint archival protection. Document guard placement and unchanged reset/rollback safety; cover type, runner and real SQLite history regressions.
@@ -275,6 +275,28 @@ async function guardMigrationRollback(context, guard) {
275
275
 
276
276
  // ../../src/core/database/migrations/runner.ts
277
277
  var MIGRATIONS_TABLE = "framework_migrations";
278
+ function validateMigrations(migrations, sources) {
279
+ if (!Array.isArray(migrations))
280
+ throw new Error("Migrations must be an array of definitions.");
281
+ const names = new Map;
282
+ for (const [index, migration] of migrations.entries()) {
283
+ const source = sources?.[index] ?? `migration at index ${index}`;
284
+ if (!migration || typeof migration !== "object" || Array.isArray(migration) || typeof migration.name !== "string" || migration.name.trim().length === 0 || typeof migration.up !== "function" || typeof migration.down !== "function") {
285
+ throw new Error(`Invalid ${source}: expected a migration with a non-empty string name and up/down functions.`);
286
+ }
287
+ const previous = names.get(migration.name);
288
+ if (previous !== undefined)
289
+ throw new Error(`Duplicate migration name "${migration.name}" in ${previous} and ${source}.`);
290
+ names.set(migration.name, source);
291
+ }
292
+ }
293
+ function requireMigrationDefinitions(applied, migrations, operation) {
294
+ const names = new Set(migrations.map(({ name }) => name));
295
+ const missing = applied.filter(({ name }) => !names.has(name)).map(({ name }) => name);
296
+ if (missing.length > 0) {
297
+ throw new Error(`Cannot ${operation}: missing applied migration definitions: ${missing.join(", ")}. Restore the migration files before retrying; history has not been removed.`);
298
+ }
299
+ }
278
300
  function migrationsNameColumn() {
279
301
  return currentSqlDialect().driver === "mysql" ? "VARCHAR(255) PRIMARY KEY" : "TEXT PRIMARY KEY";
280
302
  }
@@ -316,15 +338,17 @@ async function getAppliedMigrations(db) {
316
338
  }
317
339
  async function loadMigrationsFromDirectory(directory) {
318
340
  const entries = await readdir(directory);
319
- const migrationFiles = entries.filter((entry) => (entry.endsWith(".ts") || entry.endsWith(".js")) && entry !== "types.ts" && entry !== "index.ts" && entry !== "runner.ts").sort();
341
+ const migrationFiles = entries.filter((entry) => (entry.endsWith(".ts") || entry.endsWith(".js")) && !entry.endsWith(".d.ts") && !["types", "index", "runner", "advisoryLock", "migrationDatabase"].some((name) => entry === `${name}.ts` || entry === `${name}.js`)).sort();
320
342
  const loadedMigrations = await Promise.all(migrationFiles.map(async (fileName) => {
321
343
  const moduleUrl = pathToFileURL(join(directory, fileName)).href;
322
344
  const module = await import(moduleUrl);
323
345
  return module.default;
324
346
  }));
325
- return loadedMigrations.filter((migration) => migration?.name !== undefined);
347
+ validateMigrations(loadedMigrations, migrationFiles.map((file) => join(directory, file)));
348
+ return loadedMigrations;
326
349
  }
327
350
  async function getMigrationStatus(db, migrations) {
351
+ validateMigrations(migrations);
328
352
  const applied = await getAppliedMigrations(db);
329
353
  const appliedByName = new Map(applied.map(({ name, batch }) => [name, Number(batch)]));
330
354
  return migrations.map(({ name }) => ({
@@ -334,6 +358,7 @@ async function getMigrationStatus(db, migrations) {
334
358
  }));
335
359
  }
336
360
  async function runPendingMigrations(db, migrations, options = {}) {
361
+ validateMigrations(migrations);
337
362
  const applied = await getAppliedMigrations(db);
338
363
  const appliedNames = new Set(applied.map(({ name }) => name));
339
364
  const nextBatch = applied.reduce((currentMax, { batch }) => Math.max(currentMax, Number(batch)), 0) + 1;
@@ -349,6 +374,7 @@ async function runPendingMigrations(db, migrations, options = {}) {
349
374
  return pendingMigrations.length;
350
375
  }
351
376
  async function migrateDatabase(db, migrations, options = {}) {
377
+ validateMigrations(migrations);
352
378
  const { advisoryLock = false, onMigration } = options;
353
379
  if (advisoryLock) {
354
380
  return withMigrationLock(db, () => runPendingMigrations(db, migrations, { onMigration }));
@@ -356,11 +382,13 @@ async function migrateDatabase(db, migrations, options = {}) {
356
382
  return runPendingMigrations(db, migrations, { onMigration });
357
383
  }
358
384
  async function rollbackDatabase(db, migrations, options = {}) {
385
+ validateMigrations(migrations);
359
386
  const applied = await getAppliedMigrations(db);
360
387
  if (applied.length === 0) {
361
388
  return 0;
362
389
  }
363
390
  const lastBatch = applied.reduce((currentMax, { batch }) => Math.max(currentMax, Number(batch)), 0);
391
+ requireMigrationDefinitions(applied.filter(({ batch }) => Number(batch) === lastBatch), migrations, "roll back latest batch");
364
392
  const migrationsToRollback = new Set(applied.filter(({ batch }) => Number(batch) === lastBatch).map(({ name }) => name));
365
393
  let rolledBack = 0;
366
394
  for (const migration of [...migrations].reverse()) {
@@ -378,8 +406,10 @@ async function freshDatabase(db, migrations, options = {}) {
378
406
  if (options.destructive && isProductionEnv()) {
379
407
  throw new Error("Destructive migration reset is only available in development/test environments.");
380
408
  }
409
+ validateMigrations(migrations);
381
410
  const runFresh = async () => {
382
411
  const applied = await getAppliedMigrations(db);
412
+ requireMigrationDefinitions(applied, migrations, "reset database");
383
413
  const appliedNames = new Set(applied.map(({ name }) => name));
384
414
  const appliedMigrations = migrations.filter(({ name }) => appliedNames.has(name));
385
415
  for (const migration of [...appliedMigrations].reverse()) {
@@ -388,9 +418,7 @@ async function freshDatabase(db, migrations, options = {}) {
388
418
  await migration.down(db, { operation: "fresh" });
389
419
  else
390
420
  await migration.down(db);
391
- }
392
- if (appliedMigrations.length > 0) {
393
- await db.unsafe(`DELETE FROM ${MIGRATIONS_TABLE}`);
421
+ await db.unsafe(`DELETE FROM ${MIGRATIONS_TABLE} WHERE name = ${currentSqlDialect().placeholder(1)}`, [migration.name]);
394
422
  }
395
423
  await runPendingMigrations(db, migrations, options);
396
424
  };
package/dist/index.js CHANGED
@@ -7541,6 +7541,28 @@ import { join } from "path";
7541
7541
  import { pathToFileURL } from "url";
7542
7542
  init_dialect();
7543
7543
  var MIGRATIONS_TABLE = "framework_migrations";
7544
+ function validateMigrations(migrations, sources) {
7545
+ if (!Array.isArray(migrations))
7546
+ throw new Error("Migrations must be an array of definitions.");
7547
+ const names = new Map;
7548
+ for (const [index, migration] of migrations.entries()) {
7549
+ const source = sources?.[index] ?? `migration at index ${index}`;
7550
+ if (!migration || typeof migration !== "object" || Array.isArray(migration) || typeof migration.name !== "string" || migration.name.trim().length === 0 || typeof migration.up !== "function" || typeof migration.down !== "function") {
7551
+ throw new Error(`Invalid ${source}: expected a migration with a non-empty string name and up/down functions.`);
7552
+ }
7553
+ const previous = names.get(migration.name);
7554
+ if (previous !== undefined)
7555
+ throw new Error(`Duplicate migration name "${migration.name}" in ${previous} and ${source}.`);
7556
+ names.set(migration.name, source);
7557
+ }
7558
+ }
7559
+ function requireMigrationDefinitions(applied, migrations, operation) {
7560
+ const names = new Set(migrations.map(({ name }) => name));
7561
+ const missing = applied.filter(({ name }) => !names.has(name)).map(({ name }) => name);
7562
+ if (missing.length > 0) {
7563
+ throw new Error(`Cannot ${operation}: missing applied migration definitions: ${missing.join(", ")}. Restore the migration files before retrying; history has not been removed.`);
7564
+ }
7565
+ }
7544
7566
  function migrationsNameColumn() {
7545
7567
  return currentSqlDialect().driver === "mysql" ? "VARCHAR(255) PRIMARY KEY" : "TEXT PRIMARY KEY";
7546
7568
  }
@@ -7582,15 +7604,17 @@ async function getAppliedMigrations(db) {
7582
7604
  }
7583
7605
  async function loadMigrationsFromDirectory(directory) {
7584
7606
  const entries = await readdir(directory);
7585
- const migrationFiles = entries.filter((entry) => (entry.endsWith(".ts") || entry.endsWith(".js")) && entry !== "types.ts" && entry !== "index.ts" && entry !== "runner.ts").sort();
7607
+ const migrationFiles = entries.filter((entry) => (entry.endsWith(".ts") || entry.endsWith(".js")) && !entry.endsWith(".d.ts") && !["types", "index", "runner", "advisoryLock", "migrationDatabase"].some((name) => entry === `${name}.ts` || entry === `${name}.js`)).sort();
7586
7608
  const loadedMigrations = await Promise.all(migrationFiles.map(async (fileName) => {
7587
7609
  const moduleUrl = pathToFileURL(join(directory, fileName)).href;
7588
7610
  const module = await import(moduleUrl);
7589
7611
  return module.default;
7590
7612
  }));
7591
- return loadedMigrations.filter((migration) => migration?.name !== undefined);
7613
+ validateMigrations(loadedMigrations, migrationFiles.map((file) => join(directory, file)));
7614
+ return loadedMigrations;
7592
7615
  }
7593
7616
  async function getMigrationStatus(db, migrations) {
7617
+ validateMigrations(migrations);
7594
7618
  const applied = await getAppliedMigrations(db);
7595
7619
  const appliedByName = new Map(applied.map(({ name, batch }) => [name, Number(batch)]));
7596
7620
  return migrations.map(({ name }) => ({
@@ -7600,6 +7624,7 @@ async function getMigrationStatus(db, migrations) {
7600
7624
  }));
7601
7625
  }
7602
7626
  async function runPendingMigrations(db, migrations, options = {}) {
7627
+ validateMigrations(migrations);
7603
7628
  const applied = await getAppliedMigrations(db);
7604
7629
  const appliedNames = new Set(applied.map(({ name }) => name));
7605
7630
  const nextBatch = applied.reduce((currentMax, { batch }) => Math.max(currentMax, Number(batch)), 0) + 1;
@@ -7615,6 +7640,7 @@ async function runPendingMigrations(db, migrations, options = {}) {
7615
7640
  return pendingMigrations.length;
7616
7641
  }
7617
7642
  async function migrateDatabase(db, migrations, options = {}) {
7643
+ validateMigrations(migrations);
7618
7644
  const { advisoryLock = false, onMigration } = options;
7619
7645
  if (advisoryLock) {
7620
7646
  return withMigrationLock(db, () => runPendingMigrations(db, migrations, { onMigration }));
@@ -7622,11 +7648,13 @@ async function migrateDatabase(db, migrations, options = {}) {
7622
7648
  return runPendingMigrations(db, migrations, { onMigration });
7623
7649
  }
7624
7650
  async function rollbackDatabase(db, migrations, options = {}) {
7651
+ validateMigrations(migrations);
7625
7652
  const applied = await getAppliedMigrations(db);
7626
7653
  if (applied.length === 0) {
7627
7654
  return 0;
7628
7655
  }
7629
7656
  const lastBatch = applied.reduce((currentMax, { batch }) => Math.max(currentMax, Number(batch)), 0);
7657
+ requireMigrationDefinitions(applied.filter(({ batch }) => Number(batch) === lastBatch), migrations, "roll back latest batch");
7630
7658
  const migrationsToRollback = new Set(applied.filter(({ batch }) => Number(batch) === lastBatch).map(({ name }) => name));
7631
7659
  let rolledBack = 0;
7632
7660
  for (const migration of [...migrations].reverse()) {
@@ -7644,8 +7672,10 @@ async function freshDatabase(db, migrations, options = {}) {
7644
7672
  if (options.destructive && isProductionEnv()) {
7645
7673
  throw new Error("Destructive migration reset is only available in development/test environments.");
7646
7674
  }
7675
+ validateMigrations(migrations);
7647
7676
  const runFresh = async () => {
7648
7677
  const applied = await getAppliedMigrations(db);
7678
+ requireMigrationDefinitions(applied, migrations, "reset database");
7649
7679
  const appliedNames = new Set(applied.map(({ name }) => name));
7650
7680
  const appliedMigrations = migrations.filter(({ name }) => appliedNames.has(name));
7651
7681
  for (const migration of [...appliedMigrations].reverse()) {
@@ -7654,9 +7684,7 @@ async function freshDatabase(db, migrations, options = {}) {
7654
7684
  await migration.down(db, { operation: "fresh" });
7655
7685
  else
7656
7686
  await migration.down(db);
7657
- }
7658
- if (appliedMigrations.length > 0) {
7659
- await db.unsafe(`DELETE FROM ${MIGRATIONS_TABLE}`);
7687
+ await db.unsafe(`DELETE FROM ${MIGRATIONS_TABLE} WHERE name = ${currentSqlDialect().placeholder(1)}`, [migration.name]);
7660
7688
  }
7661
7689
  await runPendingMigrations(db, migrations, options);
7662
7690
  };
@@ -10312,10 +10340,14 @@ function createOutboxMigration(name, driver, options = {}) {
10312
10340
  SELECT 1 FROM strata_outbox_event WHERE id = strata_outbox_delivery.event_id))`);
10313
10341
  }
10314
10342
  },
10315
- async down(db) {
10316
- const [row] = await db.unsafe("SELECT COUNT(*) AS pending FROM strata_outbox_delivery WHERE status <> 'completed'");
10317
- if (Number(row?.pending) > 0)
10318
- throw new Error("Cannot drop outbox tables while undelivered events remain.");
10343
+ async down(db, context) {
10344
+ await guardMigrationRollback(context, {
10345
+ async hasHistory() {
10346
+ const [row] = await db.unsafe("SELECT COUNT(*) AS pending FROM strata_outbox_delivery WHERE status <> 'completed'");
10347
+ return Number(row?.pending) > 0;
10348
+ },
10349
+ message: "Cannot drop outbox tables while undelivered events remain."
10350
+ });
10319
10351
  await db.unsafe("DROP TABLE strata_outbox_delivery");
10320
10352
  await db.unsafe("DROP TABLE strata_outbox_event");
10321
10353
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/core",
3
- "version": "2.2.18",
3
+ "version": "2.2.20",
4
4
  "description": "Strata Bun framework public API",
5
5
  "type": "module",
6
6
  "license": "MIT",