@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
|
|
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
|
-
|
|
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
|
-
|
|
7169
|
-
|
|
7170
|
-
|
|
7171
|
-
|
|
7172
|
-
|
|
7173
|
-
|
|
7174
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
10307
|
-
|
|
10308
|
-
|
|
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
|
}
|