@getstrata/core 2.2.17 → 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,17 @@
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
+
11
+ ## 2.2.18
12
+
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.
14
+
3
15
  ## 2.2.17
4
16
 
5
17
  Add explicit development-only migration reset context while preserving guarded rollback and encryption archival; align generated fresh entries and reject production/staging or unsupported reset arguments before provisioning. Replace CSP-blocked error-page JavaScript navigation with safe same-origin links, and correct ambiguous Postgres/SQLite foreign-key messages without changing their HTTP status.
@@ -0,0 +1,10 @@
1
+ import type { MigrationDownContext } from "./types.ts";
2
+ interface MigrationRollbackGuard {
3
+ /** Application-owned history check. It is not evaluated during an explicit fresh reset. */
4
+ hasHistory(): boolean | Promise<boolean>;
5
+ message: string;
6
+ }
7
+ /** Await before destructive DDL. Predicate failures propagate; ordinary rollback remains guarded. */
8
+ declare function guardMigrationRollback(context: MigrationDownContext | undefined, guard: MigrationRollbackGuard): Promise<void>;
9
+ export type { MigrationRollbackGuard };
10
+ export { guardMigrationRollback };
@@ -23,4 +23,5 @@ declare function freshDatabase(db: MigrationDatabase, migrations: Migration[], o
23
23
  destructive?: boolean;
24
24
  }): Promise<void>;
25
25
  export { withMigrationLock } from "./advisoryLock.ts";
26
+ export { guardMigrationRollback, type MigrationRollbackGuard } from "./rollbackGuard.ts";
26
27
  export { ensureMigrationsTable, freshDatabase, getAppliedMigrations, getMigrationStatus, loadMigrationsFromDirectory, migrateDatabase, rollbackDatabase, runPendingMigrations, };
@@ -265,9 +265,38 @@ async function withMigrationLock(db, callback, lockKey = MIGRATION_LOCK_KEY) {
265
265
  await db.unsafe("SELECT pg_advisory_unlock($1)", [lockKey]);
266
266
  }
267
267
  }
268
+ // ../../src/core/database/migrations/rollbackGuard.ts
269
+ async function guardMigrationRollback(context, guard) {
270
+ if (context?.operation === "fresh")
271
+ return;
272
+ if (await guard.hasHistory())
273
+ throw new Error(guard.message);
274
+ }
268
275
 
269
276
  // ../../src/core/database/migrations/runner.ts
270
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
+ }
271
300
  function migrationsNameColumn() {
272
301
  return currentSqlDialect().driver === "mysql" ? "VARCHAR(255) PRIMARY KEY" : "TEXT PRIMARY KEY";
273
302
  }
@@ -309,15 +338,17 @@ async function getAppliedMigrations(db) {
309
338
  }
310
339
  async function loadMigrationsFromDirectory(directory) {
311
340
  const entries = await readdir(directory);
312
- 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();
313
342
  const loadedMigrations = await Promise.all(migrationFiles.map(async (fileName) => {
314
343
  const moduleUrl = pathToFileURL(join(directory, fileName)).href;
315
344
  const module = await import(moduleUrl);
316
345
  return module.default;
317
346
  }));
318
- return loadedMigrations.filter((migration) => migration?.name !== undefined);
347
+ validateMigrations(loadedMigrations, migrationFiles.map((file) => join(directory, file)));
348
+ return loadedMigrations;
319
349
  }
320
350
  async function getMigrationStatus(db, migrations) {
351
+ validateMigrations(migrations);
321
352
  const applied = await getAppliedMigrations(db);
322
353
  const appliedByName = new Map(applied.map(({ name, batch }) => [name, Number(batch)]));
323
354
  return migrations.map(({ name }) => ({
@@ -327,6 +358,7 @@ async function getMigrationStatus(db, migrations) {
327
358
  }));
328
359
  }
329
360
  async function runPendingMigrations(db, migrations, options = {}) {
361
+ validateMigrations(migrations);
330
362
  const applied = await getAppliedMigrations(db);
331
363
  const appliedNames = new Set(applied.map(({ name }) => name));
332
364
  const nextBatch = applied.reduce((currentMax, { batch }) => Math.max(currentMax, Number(batch)), 0) + 1;
@@ -342,6 +374,7 @@ async function runPendingMigrations(db, migrations, options = {}) {
342
374
  return pendingMigrations.length;
343
375
  }
344
376
  async function migrateDatabase(db, migrations, options = {}) {
377
+ validateMigrations(migrations);
345
378
  const { advisoryLock = false, onMigration } = options;
346
379
  if (advisoryLock) {
347
380
  return withMigrationLock(db, () => runPendingMigrations(db, migrations, { onMigration }));
@@ -349,11 +382,13 @@ async function migrateDatabase(db, migrations, options = {}) {
349
382
  return runPendingMigrations(db, migrations, { onMigration });
350
383
  }
351
384
  async function rollbackDatabase(db, migrations, options = {}) {
385
+ validateMigrations(migrations);
352
386
  const applied = await getAppliedMigrations(db);
353
387
  if (applied.length === 0) {
354
388
  return 0;
355
389
  }
356
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");
357
392
  const migrationsToRollback = new Set(applied.filter(({ batch }) => Number(batch) === lastBatch).map(({ name }) => name));
358
393
  let rolledBack = 0;
359
394
  for (const migration of [...migrations].reverse()) {
@@ -371,8 +406,10 @@ async function freshDatabase(db, migrations, options = {}) {
371
406
  if (options.destructive && isProductionEnv()) {
372
407
  throw new Error("Destructive migration reset is only available in development/test environments.");
373
408
  }
409
+ validateMigrations(migrations);
374
410
  const runFresh = async () => {
375
411
  const applied = await getAppliedMigrations(db);
412
+ requireMigrationDefinitions(applied, migrations, "reset database");
376
413
  const appliedNames = new Set(applied.map(({ name }) => name));
377
414
  const appliedMigrations = migrations.filter(({ name }) => appliedNames.has(name));
378
415
  for (const migration of [...appliedMigrations].reverse()) {
@@ -381,9 +418,7 @@ async function freshDatabase(db, migrations, options = {}) {
381
418
  await migration.down(db, { operation: "fresh" });
382
419
  else
383
420
  await migration.down(db);
384
- }
385
- if (appliedMigrations.length > 0) {
386
- await db.unsafe(`DELETE FROM ${MIGRATIONS_TABLE}`);
421
+ await db.unsafe(`DELETE FROM ${MIGRATIONS_TABLE} WHERE name = ${currentSqlDialect().placeholder(1)}`, [migration.name]);
387
422
  }
388
423
  await runPendingMigrations(db, migrations, options);
389
424
  };
@@ -398,6 +433,7 @@ export {
398
433
  freshDatabase,
399
434
  getAppliedMigrations,
400
435
  getMigrationStatus,
436
+ guardMigrationRollback,
401
437
  loadMigrationsFromDirectory,
402
438
  migrateDatabase,
403
439
  rollbackDatabase,
package/dist/index.js CHANGED
@@ -6369,6 +6369,16 @@ var baseRepository_default = BaseRepository;
6369
6369
 
6370
6370
  // ../../src/core/crypto/fieldEncryptionRotation.ts
6371
6371
  init_boundConnection();
6372
+
6373
+ // ../../src/core/database/migrations/rollbackGuard.ts
6374
+ async function guardMigrationRollback(context, guard) {
6375
+ if (context?.operation === "fresh")
6376
+ return;
6377
+ if (await guard.hasHistory())
6378
+ throw new Error(guard.message);
6379
+ }
6380
+
6381
+ // ../../src/core/crypto/fieldEncryptionRotation.ts
6372
6382
  init_repositoryConnection();
6373
6383
 
6374
6384
  // ../../src/core/database/schema/columnDefinition.ts
@@ -7165,13 +7175,13 @@ function createFieldEncryptionRotationMigration(name, driver, options = {}) {
7165
7175
  await db.unsafe(enableTenantRlsSql(checkpointTable.name));
7166
7176
  },
7167
7177
  async down(db, context) {
7168
- if (context?.operation === "fresh") {
7169
- await Schema.run(db, driver, (schema) => {
7170
- schema.drop(checkpointTable.name);
7171
- });
7172
- return;
7173
- }
7174
- throw new Error("Encryption rotation checkpoints require explicit administrative archival.");
7178
+ await guardMigrationRollback(context, {
7179
+ hasHistory: () => true,
7180
+ message: "Encryption rotation checkpoints require explicit administrative archival."
7181
+ });
7182
+ await Schema.run(db, driver, (schema) => {
7183
+ schema.drop(checkpointTable.name);
7184
+ });
7175
7185
  }
7176
7186
  };
7177
7187
  }
@@ -7531,6 +7541,28 @@ import { join } from "path";
7531
7541
  import { pathToFileURL } from "url";
7532
7542
  init_dialect();
7533
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
+ }
7534
7566
  function migrationsNameColumn() {
7535
7567
  return currentSqlDialect().driver === "mysql" ? "VARCHAR(255) PRIMARY KEY" : "TEXT PRIMARY KEY";
7536
7568
  }
@@ -7572,15 +7604,17 @@ async function getAppliedMigrations(db) {
7572
7604
  }
7573
7605
  async function loadMigrationsFromDirectory(directory) {
7574
7606
  const entries = await readdir(directory);
7575
- 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();
7576
7608
  const loadedMigrations = await Promise.all(migrationFiles.map(async (fileName) => {
7577
7609
  const moduleUrl = pathToFileURL(join(directory, fileName)).href;
7578
7610
  const module = await import(moduleUrl);
7579
7611
  return module.default;
7580
7612
  }));
7581
- return loadedMigrations.filter((migration) => migration?.name !== undefined);
7613
+ validateMigrations(loadedMigrations, migrationFiles.map((file) => join(directory, file)));
7614
+ return loadedMigrations;
7582
7615
  }
7583
7616
  async function getMigrationStatus(db, migrations) {
7617
+ validateMigrations(migrations);
7584
7618
  const applied = await getAppliedMigrations(db);
7585
7619
  const appliedByName = new Map(applied.map(({ name, batch }) => [name, Number(batch)]));
7586
7620
  return migrations.map(({ name }) => ({
@@ -7590,6 +7624,7 @@ async function getMigrationStatus(db, migrations) {
7590
7624
  }));
7591
7625
  }
7592
7626
  async function runPendingMigrations(db, migrations, options = {}) {
7627
+ validateMigrations(migrations);
7593
7628
  const applied = await getAppliedMigrations(db);
7594
7629
  const appliedNames = new Set(applied.map(({ name }) => name));
7595
7630
  const nextBatch = applied.reduce((currentMax, { batch }) => Math.max(currentMax, Number(batch)), 0) + 1;
@@ -7605,6 +7640,7 @@ async function runPendingMigrations(db, migrations, options = {}) {
7605
7640
  return pendingMigrations.length;
7606
7641
  }
7607
7642
  async function migrateDatabase(db, migrations, options = {}) {
7643
+ validateMigrations(migrations);
7608
7644
  const { advisoryLock = false, onMigration } = options;
7609
7645
  if (advisoryLock) {
7610
7646
  return withMigrationLock(db, () => runPendingMigrations(db, migrations, { onMigration }));
@@ -7612,11 +7648,13 @@ async function migrateDatabase(db, migrations, options = {}) {
7612
7648
  return runPendingMigrations(db, migrations, { onMigration });
7613
7649
  }
7614
7650
  async function rollbackDatabase(db, migrations, options = {}) {
7651
+ validateMigrations(migrations);
7615
7652
  const applied = await getAppliedMigrations(db);
7616
7653
  if (applied.length === 0) {
7617
7654
  return 0;
7618
7655
  }
7619
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");
7620
7658
  const migrationsToRollback = new Set(applied.filter(({ batch }) => Number(batch) === lastBatch).map(({ name }) => name));
7621
7659
  let rolledBack = 0;
7622
7660
  for (const migration of [...migrations].reverse()) {
@@ -7634,8 +7672,10 @@ async function freshDatabase(db, migrations, options = {}) {
7634
7672
  if (options.destructive && isProductionEnv()) {
7635
7673
  throw new Error("Destructive migration reset is only available in development/test environments.");
7636
7674
  }
7675
+ validateMigrations(migrations);
7637
7676
  const runFresh = async () => {
7638
7677
  const applied = await getAppliedMigrations(db);
7678
+ requireMigrationDefinitions(applied, migrations, "reset database");
7639
7679
  const appliedNames = new Set(applied.map(({ name }) => name));
7640
7680
  const appliedMigrations = migrations.filter(({ name }) => appliedNames.has(name));
7641
7681
  for (const migration of [...appliedMigrations].reverse()) {
@@ -7644,9 +7684,7 @@ async function freshDatabase(db, migrations, options = {}) {
7644
7684
  await migration.down(db, { operation: "fresh" });
7645
7685
  else
7646
7686
  await migration.down(db);
7647
- }
7648
- if (appliedMigrations.length > 0) {
7649
- await db.unsafe(`DELETE FROM ${MIGRATIONS_TABLE}`);
7687
+ await db.unsafe(`DELETE FROM ${MIGRATIONS_TABLE} WHERE name = ${currentSqlDialect().placeholder(1)}`, [migration.name]);
7650
7688
  }
7651
7689
  await runPendingMigrations(db, migrations, options);
7652
7690
  };
@@ -10302,10 +10340,14 @@ function createOutboxMigration(name, driver, options = {}) {
10302
10340
  SELECT 1 FROM strata_outbox_event WHERE id = strata_outbox_delivery.event_id))`);
10303
10341
  }
10304
10342
  },
10305
- async down(db) {
10306
- const [row] = await db.unsafe("SELECT COUNT(*) AS pending FROM strata_outbox_delivery WHERE status <> 'completed'");
10307
- if (Number(row?.pending) > 0)
10308
- 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
+ });
10309
10351
  await db.unsafe("DROP TABLE strata_outbox_delivery");
10310
10352
  await db.unsafe("DROP TABLE strata_outbox_event");
10311
10353
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/core",
3
- "version": "2.2.17",
3
+ "version": "2.2.20",
4
4
  "description": "Strata Bun framework public API",
5
5
  "type": "module",
6
6
  "license": "MIT",