@getstrata/core 2.2.16 → 2.2.18

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.18
4
+
5
+ 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.
6
+
7
+ ## 2.2.17
8
+
9
+ 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.
10
+
3
11
  ## 2.2.16
4
12
 
5
13
  Allow managed outbox workers to reuse a configured instance, preserving application tenant resolution, listener registration and delivery settings. Keep generated listeners-only defaults compatible, reject ambiguous worker sources, and cover configured SQL delivery, cancellation, cleanup and TypeScript contracts.
@@ -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 };
@@ -20,6 +20,8 @@ declare function rollbackDatabase(db: MigrationDatabase, migrations: Migration[]
20
20
  declare function freshDatabase(db: MigrationDatabase, migrations: Migration[], options?: {
21
21
  advisoryLock?: boolean;
22
22
  onMigration?: (name: string) => void;
23
+ destructive?: boolean;
23
24
  }): Promise<void>;
24
25
  export { withMigrationLock } from "./advisoryLock.ts";
26
+ export { guardMigrationRollback, type MigrationRollbackGuard } from "./rollbackGuard.ts";
25
27
  export { ensureMigrationsTable, freshDatabase, getAppliedMigrations, getMigrationStatus, loadMigrationsFromDirectory, migrateDatabase, rollbackDatabase, runPendingMigrations, };
@@ -2,14 +2,18 @@ interface MigrationDatabase {
2
2
  unsafe<T = unknown>(query: string, params?: readonly unknown[]): Promise<T[]>;
3
3
  schema?: import("../schema/schema.ts").SchemaBuilder;
4
4
  }
5
+ /** Explicit destructive reset is distinct from an ordinary rollback. */
6
+ interface MigrationDownContext {
7
+ operation: "fresh";
8
+ }
5
9
  interface Migration {
6
10
  name: string;
7
11
  up(db: MigrationDatabase): Promise<void>;
8
- down(db: MigrationDatabase): Promise<void>;
12
+ down(db: MigrationDatabase, context?: MigrationDownContext): Promise<void>;
9
13
  }
10
14
  type MigrationStatus = {
11
15
  name: string;
12
16
  status: "up" | "pending";
13
17
  batch: number | null;
14
18
  };
15
- export type { Migration, MigrationDatabase, MigrationStatus };
19
+ export type { Migration, MigrationDatabase, MigrationDownContext, MigrationStatus };
@@ -362,7 +362,7 @@ function mapSqliteError(error) {
362
362
  return new ConflictError("A record with these values already exists.");
363
363
  }
364
364
  if (code === "SQLITE_CONSTRAINT_FOREIGNKEY") {
365
- return new UnprocessableEntityError("Referenced record does not exist.");
365
+ return new UnprocessableEntityError("Operation conflicts with related records.");
366
366
  }
367
367
  if (code === "SQLITE_CONSTRAINT_NOTNULL") {
368
368
  return new BadRequestError("Required field is missing.");
@@ -407,7 +407,7 @@ function mapDatabaseError(error) {
407
407
  if (error.detail) {
408
408
  console.error("[database] foreign-key violation", error.detail);
409
409
  }
410
- return new UnprocessableEntityError("Referenced record does not exist.");
410
+ return new UnprocessableEntityError("Operation conflicts with related records.");
411
411
  case "23502":
412
412
  if (error.detail) {
413
413
  console.error("[database] not-null violation", error.detail);
@@ -362,7 +362,7 @@ function mapSqliteError(error) {
362
362
  return new ConflictError("A record with these values already exists.");
363
363
  }
364
364
  if (code === "SQLITE_CONSTRAINT_FOREIGNKEY") {
365
- return new UnprocessableEntityError("Referenced record does not exist.");
365
+ return new UnprocessableEntityError("Operation conflicts with related records.");
366
366
  }
367
367
  if (code === "SQLITE_CONSTRAINT_NOTNULL") {
368
368
  return new BadRequestError("Required field is missing.");
@@ -407,7 +407,7 @@ function mapDatabaseError(error) {
407
407
  if (error.detail) {
408
408
  console.error("[database] foreign-key violation", error.detail);
409
409
  }
410
- return new UnprocessableEntityError("Referenced record does not exist.");
410
+ return new UnprocessableEntityError("Operation conflicts with related records.");
411
411
  case "23502":
412
412
  if (error.detail) {
413
413
  console.error("[database] not-null violation", error.detail);
@@ -38,7 +38,7 @@ function mapSqliteError(error) {
38
38
  return new ConflictError("A record with these values already exists.");
39
39
  }
40
40
  if (code === "SQLITE_CONSTRAINT_FOREIGNKEY") {
41
- return new UnprocessableEntityError("Referenced record does not exist.");
41
+ return new UnprocessableEntityError("Operation conflicts with related records.");
42
42
  }
43
43
  if (code === "SQLITE_CONSTRAINT_NOTNULL") {
44
44
  return new BadRequestError("Required field is missing.");
@@ -83,7 +83,7 @@ function mapDatabaseError(error) {
83
83
  if (error.detail) {
84
84
  console.error("[database] foreign-key violation", error.detail);
85
85
  }
86
- return new UnprocessableEntityError("Referenced record does not exist.");
86
+ return new UnprocessableEntityError("Operation conflicts with related records.");
87
87
  case "23502":
88
88
  if (error.detail) {
89
89
  console.error("[database] not-null violation", error.detail);
@@ -228,11 +228,33 @@ var init_dialect = __esm(() => {
228
228
  });
229
229
 
230
230
  // ../../src/core/database/migrations/runner.ts
231
- init_dialect();
232
231
  import { readdir } from "fs/promises";
233
232
  import { join } from "path";
234
233
  import { pathToFileURL } from "url";
235
234
 
235
+ // ../../src/core/runtime/appEnv.ts
236
+ var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
237
+ function normalizeEnvValue(value) {
238
+ return (value ?? "").trim().toLowerCase();
239
+ }
240
+ function isProductionEnv(env = process.env) {
241
+ const appEnv = normalizeEnvValue(env.APP_ENV);
242
+ const nodeEnv = normalizeEnvValue(env.NODE_ENV);
243
+ if (appEnv === "production" || nodeEnv === "production") {
244
+ return true;
245
+ }
246
+ if (appEnv === "") {
247
+ return false;
248
+ }
249
+ return !NON_PRODUCTION_APP_ENVS.has(appEnv);
250
+ }
251
+ function envFlagEnabled(value) {
252
+ return value === "true";
253
+ }
254
+
255
+ // ../../src/core/database/migrations/runner.ts
256
+ init_dialect();
257
+
236
258
  // ../../src/core/database/migrations/advisoryLock.ts
237
259
  var MIGRATION_LOCK_KEY = 42424242;
238
260
  async function withMigrationLock(db, callback, lockKey = MIGRATION_LOCK_KEY) {
@@ -243,6 +265,13 @@ async function withMigrationLock(db, callback, lockKey = MIGRATION_LOCK_KEY) {
243
265
  await db.unsafe("SELECT pg_advisory_unlock($1)", [lockKey]);
244
266
  }
245
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
+ }
246
275
 
247
276
  // ../../src/core/database/migrations/runner.ts
248
277
  var MIGRATIONS_TABLE = "framework_migrations";
@@ -346,13 +375,19 @@ async function rollbackDatabase(db, migrations, options = {}) {
346
375
  return rolledBack;
347
376
  }
348
377
  async function freshDatabase(db, migrations, options = {}) {
378
+ if (options.destructive && isProductionEnv()) {
379
+ throw new Error("Destructive migration reset is only available in development/test environments.");
380
+ }
349
381
  const runFresh = async () => {
350
382
  const applied = await getAppliedMigrations(db);
351
383
  const appliedNames = new Set(applied.map(({ name }) => name));
352
384
  const appliedMigrations = migrations.filter(({ name }) => appliedNames.has(name));
353
385
  for (const migration of [...appliedMigrations].reverse()) {
354
386
  options.onMigration?.(migration.name);
355
- await migration.down(db);
387
+ if (options.destructive)
388
+ await migration.down(db, { operation: "fresh" });
389
+ else
390
+ await migration.down(db);
356
391
  }
357
392
  if (appliedMigrations.length > 0) {
358
393
  await db.unsafe(`DELETE FROM ${MIGRATIONS_TABLE}`);
@@ -370,6 +405,7 @@ export {
370
405
  freshDatabase,
371
406
  getAppliedMigrations,
372
407
  getMigrationStatus,
408
+ guardMigrationRollback,
373
409
  loadMigrationsFromDirectory,
374
410
  migrateDatabase,
375
411
  rollbackDatabase,
@@ -345,12 +345,25 @@ var configuredErrorView = {};
345
345
  function escapeHtml(value) {
346
346
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
347
347
  }
348
+ function errorReturnPath(request) {
349
+ if (!request)
350
+ return "/";
351
+ try {
352
+ const current = new URL(request.url);
353
+ const previous = new URL(request.headers.get("referer") ?? "");
354
+ if (previous.origin !== current.origin || (request.method === "GET" || request.method === "HEAD") && previous.pathname === current.pathname)
355
+ return "/";
356
+ return sanitizeInternalPath(previous.pathname, "/");
357
+ } catch {
358
+ return "/";
359
+ }
360
+ }
348
361
  function renderKernelErrorChrome(input) {
349
362
  const title = escapeHtml(input.title);
350
363
  const message = escapeHtml(input.message);
351
364
  const errorLines = Object.entries(input.errors ?? {}).flatMap(([field, messages]) => messages.map((item) => `${field}: ${item}`)).map((line) => `<li>${escapeHtml(line)}</li>`).join("");
352
365
  const details = errorLines ? `<ul class="error-list">${errorLines}</ul>` : "";
353
- const goBack = input.status === 422 ? `<p><a href="javascript:history.back()">Go back</a></p>` : "";
366
+ const goBack = input.status === 422 ? `<p><a href="${escapeHtml(errorReturnPath(input.request ?? currentRequestMeta3().request))}">Go back</a></p>` : "";
354
367
  return `<!doctype html>
355
368
  <html lang="en">
356
369
  <head>
@@ -162,7 +162,7 @@ function mapSqliteError(error) {
162
162
  return new ConflictError("A record with these values already exists.");
163
163
  }
164
164
  if (code === "SQLITE_CONSTRAINT_FOREIGNKEY") {
165
- return new UnprocessableEntityError("Referenced record does not exist.");
165
+ return new UnprocessableEntityError("Operation conflicts with related records.");
166
166
  }
167
167
  if (code === "SQLITE_CONSTRAINT_NOTNULL") {
168
168
  return new BadRequestError("Required field is missing.");
@@ -207,7 +207,7 @@ function mapDatabaseError(error) {
207
207
  if (error.detail) {
208
208
  console.error("[database] foreign-key violation", error.detail);
209
209
  }
210
- return new UnprocessableEntityError("Referenced record does not exist.");
210
+ return new UnprocessableEntityError("Operation conflicts with related records.");
211
211
  case "23502":
212
212
  if (error.detail) {
213
213
  console.error("[database] not-null violation", error.detail);
@@ -403,12 +403,25 @@ var configuredErrorView = {};
403
403
  function escapeHtml(value) {
404
404
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
405
405
  }
406
+ function errorReturnPath(request) {
407
+ if (!request)
408
+ return "/";
409
+ try {
410
+ const current = new URL(request.url);
411
+ const previous = new URL(request.headers.get("referer") ?? "");
412
+ if (previous.origin !== current.origin || (request.method === "GET" || request.method === "HEAD") && previous.pathname === current.pathname)
413
+ return "/";
414
+ return sanitizeInternalPath(previous.pathname, "/");
415
+ } catch {
416
+ return "/";
417
+ }
418
+ }
406
419
  function renderKernelErrorChrome(input) {
407
420
  const title = escapeHtml(input.title);
408
421
  const message = escapeHtml(input.message);
409
422
  const errorLines = Object.entries(input.errors ?? {}).flatMap(([field, messages]) => messages.map((item) => `${field}: ${item}`)).map((line) => `<li>${escapeHtml(line)}</li>`).join("");
410
423
  const details = errorLines ? `<ul class="error-list">${errorLines}</ul>` : "";
411
- const goBack = input.status === 422 ? `<p><a href="javascript:history.back()">Go back</a></p>` : "";
424
+ const goBack = input.status === 422 ? `<p><a href="${escapeHtml(errorReturnPath(input.request ?? currentRequestMeta2().request))}">Go back</a></p>` : "";
412
425
  return `<!doctype html>
413
426
  <html lang="en">
414
427
  <head>
@@ -399,12 +399,25 @@ var configuredErrorView = {};
399
399
  function escapeHtml(value) {
400
400
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
401
401
  }
402
+ function errorReturnPath(request) {
403
+ if (!request)
404
+ return "/";
405
+ try {
406
+ const current = new URL(request.url);
407
+ const previous = new URL(request.headers.get("referer") ?? "");
408
+ if (previous.origin !== current.origin || (request.method === "GET" || request.method === "HEAD") && previous.pathname === current.pathname)
409
+ return "/";
410
+ return sanitizeInternalPath(previous.pathname, "/");
411
+ } catch {
412
+ return "/";
413
+ }
414
+ }
402
415
  function renderKernelErrorChrome(input) {
403
416
  const title = escapeHtml(input.title);
404
417
  const message = escapeHtml(input.message);
405
418
  const errorLines = Object.entries(input.errors ?? {}).flatMap(([field, messages]) => messages.map((item) => `${field}: ${item}`)).map((line) => `<li>${escapeHtml(line)}</li>`).join("");
406
419
  const details = errorLines ? `<ul class="error-list">${errorLines}</ul>` : "";
407
- const goBack = input.status === 422 ? `<p><a href="javascript:history.back()">Go back</a></p>` : "";
420
+ const goBack = input.status === 422 ? `<p><a href="${escapeHtml(errorReturnPath(input.request ?? currentRequestMeta3().request))}">Go back</a></p>` : "";
408
421
  return `<!doctype html>
409
422
  <html lang="en">
410
423
  <head>
@@ -284,12 +284,25 @@ var configuredErrorView = {};
284
284
  function escapeHtml(value) {
285
285
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
286
286
  }
287
+ function errorReturnPath(request) {
288
+ if (!request)
289
+ return "/";
290
+ try {
291
+ const current = new URL(request.url);
292
+ const previous = new URL(request.headers.get("referer") ?? "");
293
+ if (previous.origin !== current.origin || (request.method === "GET" || request.method === "HEAD") && previous.pathname === current.pathname)
294
+ return "/";
295
+ return sanitizeInternalPath(previous.pathname, "/");
296
+ } catch {
297
+ return "/";
298
+ }
299
+ }
287
300
  function renderKernelErrorChrome(input) {
288
301
  const title = escapeHtml(input.title);
289
302
  const message = escapeHtml(input.message);
290
303
  const errorLines = Object.entries(input.errors ?? {}).flatMap(([field, messages]) => messages.map((item) => `${field}: ${item}`)).map((line) => `<li>${escapeHtml(line)}</li>`).join("");
291
304
  const details = errorLines ? `<ul class="error-list">${errorLines}</ul>` : "";
292
- const goBack = input.status === 422 ? `<p><a href="javascript:history.back()">Go back</a></p>` : "";
305
+ const goBack = input.status === 422 ? `<p><a href="${escapeHtml(errorReturnPath(input.request ?? currentRequestMeta3().request))}">Go back</a></p>` : "";
293
306
  return `<!doctype html>
294
307
  <html lang="en">
295
308
  <head>
package/dist/index.js CHANGED
@@ -3215,7 +3215,7 @@ function mapSqliteError(error) {
3215
3215
  return new ConflictError("A record with these values already exists.");
3216
3216
  }
3217
3217
  if (code === "SQLITE_CONSTRAINT_FOREIGNKEY") {
3218
- return new UnprocessableEntityError("Referenced record does not exist.");
3218
+ return new UnprocessableEntityError("Operation conflicts with related records.");
3219
3219
  }
3220
3220
  if (code === "SQLITE_CONSTRAINT_NOTNULL") {
3221
3221
  return new BadRequestError("Required field is missing.");
@@ -3260,7 +3260,7 @@ function mapDatabaseError(error) {
3260
3260
  if (error.detail) {
3261
3261
  console.error("[database] foreign-key violation", error.detail);
3262
3262
  }
3263
- return new UnprocessableEntityError("Referenced record does not exist.");
3263
+ return new UnprocessableEntityError("Operation conflicts with related records.");
3264
3264
  case "23502":
3265
3265
  if (error.detail) {
3266
3266
  console.error("[database] not-null violation", error.detail);
@@ -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
@@ -7164,8 +7174,14 @@ function createFieldEncryptionRotationMigration(name, driver, options = {}) {
7164
7174
  if (options.rls)
7165
7175
  await db.unsafe(enableTenantRlsSql(checkpointTable.name));
7166
7176
  },
7167
- async down() {
7168
- throw new Error("Encryption rotation checkpoints require explicit administrative archival.");
7177
+ async down(db, context) {
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
+ });
7169
7185
  }
7170
7186
  };
7171
7187
  }
@@ -7520,10 +7536,10 @@ async function withMigrationLock(db, callback, lockKey = MIGRATION_LOCK_KEY) {
7520
7536
  }
7521
7537
  }
7522
7538
  // ../../src/core/database/migrations/runner.ts
7523
- init_dialect();
7524
7539
  import { readdir } from "fs/promises";
7525
7540
  import { join } from "path";
7526
7541
  import { pathToFileURL } from "url";
7542
+ init_dialect();
7527
7543
  var MIGRATIONS_TABLE = "framework_migrations";
7528
7544
  function migrationsNameColumn() {
7529
7545
  return currentSqlDialect().driver === "mysql" ? "VARCHAR(255) PRIMARY KEY" : "TEXT PRIMARY KEY";
@@ -7625,13 +7641,19 @@ async function rollbackDatabase(db, migrations, options = {}) {
7625
7641
  return rolledBack;
7626
7642
  }
7627
7643
  async function freshDatabase(db, migrations, options = {}) {
7644
+ if (options.destructive && isProductionEnv()) {
7645
+ throw new Error("Destructive migration reset is only available in development/test environments.");
7646
+ }
7628
7647
  const runFresh = async () => {
7629
7648
  const applied = await getAppliedMigrations(db);
7630
7649
  const appliedNames = new Set(applied.map(({ name }) => name));
7631
7650
  const appliedMigrations = migrations.filter(({ name }) => appliedNames.has(name));
7632
7651
  for (const migration of [...appliedMigrations].reverse()) {
7633
7652
  options.onMigration?.(migration.name);
7634
- await migration.down(db);
7653
+ if (options.destructive)
7654
+ await migration.down(db, { operation: "fresh" });
7655
+ else
7656
+ await migration.down(db);
7635
7657
  }
7636
7658
  if (appliedMigrations.length > 0) {
7637
7659
  await db.unsafe(`DELETE FROM ${MIGRATIONS_TABLE}`);
@@ -12339,12 +12361,25 @@ function configureWebErrorView(options) {
12339
12361
  function escapeHtml(value) {
12340
12362
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
12341
12363
  }
12364
+ function errorReturnPath(request) {
12365
+ if (!request)
12366
+ return "/";
12367
+ try {
12368
+ const current = new URL(request.url);
12369
+ const previous = new URL(request.headers.get("referer") ?? "");
12370
+ if (previous.origin !== current.origin || (request.method === "GET" || request.method === "HEAD") && previous.pathname === current.pathname)
12371
+ return "/";
12372
+ return sanitizeInternalPath(previous.pathname, "/");
12373
+ } catch {
12374
+ return "/";
12375
+ }
12376
+ }
12342
12377
  function renderKernelErrorChrome(input) {
12343
12378
  const title = escapeHtml(input.title);
12344
12379
  const message = escapeHtml(input.message);
12345
12380
  const errorLines = Object.entries(input.errors ?? {}).flatMap(([field, messages]) => messages.map((item) => `${field}: ${item}`)).map((line) => `<li>${escapeHtml(line)}</li>`).join("");
12346
12381
  const details = errorLines ? `<ul class="error-list">${errorLines}</ul>` : "";
12347
- const goBack = input.status === 422 ? `<p><a href="javascript:history.back()">Go back</a></p>` : "";
12382
+ const goBack = input.status === 422 ? `<p><a href="${escapeHtml(errorReturnPath(input.request ?? currentRequestMeta().request))}">Go back</a></p>` : "";
12348
12383
  return `<!doctype html>
12349
12384
  <html lang="en">
12350
12385
  <head>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/core",
3
- "version": "2.2.16",
3
+ "version": "2.2.18",
4
4
  "description": "Strata Bun framework public API",
5
5
  "type": "module",
6
6
  "license": "MIT",