@getstrata/core 2.0.2 → 2.0.3

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.
Files changed (32) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/core/database/dialect.d.ts +4 -0
  3. package/dist/core/database/mysqlConnection.d.ts +12 -2
  4. package/dist/core/database/sqliteConnection.d.ts +2 -1
  5. package/dist/core/database/transaction.d.ts +2 -2
  6. package/dist/core/database/transactionControl.d.ts +2 -1
  7. package/dist/core/events/outbox/index.d.ts +54 -0
  8. package/dist/core/events/outbox/migration.d.ts +7 -0
  9. package/dist/core/http/requestMetaContext.d.ts +2 -0
  10. package/dist/core/http/response.d.ts +2 -1
  11. package/dist/core/security/safeFetch.d.ts +1 -0
  12. package/dist/entries/audit/exportAuditLogs.js +119 -17
  13. package/dist/entries/auth/basicAuthGuard.js +18 -0
  14. package/dist/entries/auth/oauth/oidcProvider.js +100 -17
  15. package/dist/entries/auth/oauth/providers.js +100 -17
  16. package/dist/entries/auth/oauth/samlProvider.js +18 -0
  17. package/dist/entries/auth/oneTimeToken.js +18 -0
  18. package/dist/entries/auth/passwordLogin.js +18 -0
  19. package/dist/entries/auth/saml/samlServiceProvider.js +18 -0
  20. package/dist/entries/auth/scimAuthMiddleware.js +19 -0
  21. package/dist/entries/database/migrations.js +18 -0
  22. package/dist/entries/database/sqliteConnection.js +101 -7
  23. package/dist/entries/events/outbox.js +1 -0
  24. package/dist/entries/http/response.js +39 -5
  25. package/dist/entries/jobs/exportAuditLogsJob.js +119 -17
  26. package/dist/entries/security/safeFetch.js +100 -17
  27. package/dist/entries/tenant/databaseTenantContext.js +19 -0
  28. package/dist/entries/tenant/tenantDatabaseScope.js +19 -0
  29. package/dist/entries/tracing/tracingMiddleware.js +100 -17
  30. package/dist/framework/public-api.d.ts +3 -1
  31. package/dist/index.js +591 -50
  32. package/package.json +6 -1
package/dist/index.js CHANGED
@@ -221,6 +221,12 @@ var init_dialect = __esm(() => {
221
221
  nowExpression() {
222
222
  return "NOW()";
223
223
  },
224
+ epochMillisecondsExpression() {
225
+ return "CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)";
226
+ },
227
+ rowLockClause(skipLocked = false) {
228
+ return ` FOR UPDATE${skipLocked ? " SKIP LOCKED" : ""}`;
229
+ },
224
230
  timestampValue(value) {
225
231
  return value.toISOString();
226
232
  },
@@ -259,6 +265,12 @@ var init_dialect = __esm(() => {
259
265
  nowExpression() {
260
266
  return "CURRENT_TIMESTAMP";
261
267
  },
268
+ epochMillisecondsExpression() {
269
+ return "CAST(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 AS UNSIGNED)";
270
+ },
271
+ rowLockClause(skipLocked = false) {
272
+ return ` FOR UPDATE${skipLocked ? " SKIP LOCKED" : ""}`;
273
+ },
262
274
  timestampValue(value) {
263
275
  return value.toISOString().slice(0, 19).replace("T", " ");
264
276
  },
@@ -297,6 +309,12 @@ var init_dialect = __esm(() => {
297
309
  nowExpression() {
298
310
  return "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
299
311
  },
312
+ epochMillisecondsExpression() {
313
+ return "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
314
+ },
315
+ rowLockClause() {
316
+ return "";
317
+ },
300
318
  timestampValue(value) {
301
319
  return value.toISOString();
302
320
  },
@@ -446,6 +464,9 @@ function unwrapTransactionRollback(error, depth = 0) {
446
464
  }
447
465
  return unwrapTransactionRollback(error.cause, depth + 1);
448
466
  }
467
+ function hasActiveTransaction() {
468
+ return hasActiveDatabaseConnection() && (transactionScopes.getStore()?.rollback.length ?? 0) > 0;
469
+ }
449
470
  function requestTransactionRollback() {
450
471
  const state = transactionScopes.getStore();
451
472
  const index = state ? state.rollback.length - 1 : -1;
@@ -499,6 +520,7 @@ async function runWithTransactionScope(callback) {
499
520
  var TransactionRollback, transactionScopes;
500
521
  var init_transactionControl = __esm(() => {
501
522
  init_asyncContextStore();
523
+ init_connectionContext();
502
524
  TransactionRollback = class TransactionRollback extends Error {
503
525
  result;
504
526
  constructor(result) {
@@ -711,6 +733,11 @@ var init_transaction = __esm(() => {
711
733
  });
712
734
 
713
735
  // ../../src/core/tenant/databaseTenantContext.ts
736
+ async function applyBypassToTransaction(transaction, bypass) {
737
+ await transaction.unsafe(`SELECT set_config('app.bypass_rls', $1, true)`, [
738
+ bypass ? "true" : "false"
739
+ ]);
740
+ }
714
741
  function stringifyBypassIdentifier(identifier) {
715
742
  if (typeof identifier === "number") {
716
743
  return String(identifier);
@@ -763,6 +790,9 @@ async function runWithScopedTenantTransaction(apply, callback) {
763
790
  });
764
791
  }));
765
792
  }
793
+ async function runWithMigrationBypass(callback) {
794
+ return await runWithScopedTenantTransaction((transaction) => applyBypassToTransaction(transaction, true), callback);
795
+ }
766
796
  async function runWithMigrationBypassForIdentifier(identifier, callback) {
767
797
  assertBypassIdentifier(identifier);
768
798
  return await runWithScopedTenantTransaction((transaction) => applyIdentifierToTransaction(transaction, identifier), callback);
@@ -2349,50 +2379,133 @@ function resetDnsLookupForTests() {
2349
2379
 
2350
2380
  // ../../src/core/security/safeFetch.ts
2351
2381
  var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
2382
+ function untilAborted(work, signal) {
2383
+ signal.throwIfAborted();
2384
+ return new Promise((resolve, reject) => {
2385
+ const onAbort = () => reject(signal.reason);
2386
+ signal.addEventListener("abort", onAbort, { once: true });
2387
+ work.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
2388
+ });
2389
+ }
2390
+ function preserveResponseMetadata(response, original) {
2391
+ Object.defineProperties(response, {
2392
+ url: { value: original.url },
2393
+ redirected: { value: original.redirected },
2394
+ type: { value: original.type }
2395
+ });
2396
+ const clone = response.clone.bind(response);
2397
+ response.clone = () => preserveResponseMetadata(clone(), original);
2398
+ return response;
2399
+ }
2400
+ function withBodyDeadline(response, signal, release) {
2401
+ if (!response.body) {
2402
+ release();
2403
+ return response;
2404
+ }
2405
+ const reader = response.body.getReader();
2406
+ let stopped = false;
2407
+ let bodyController;
2408
+ const stop = (reason, errorBody) => {
2409
+ if (stopped)
2410
+ return;
2411
+ stopped = true;
2412
+ release();
2413
+ signal.removeEventListener("abort", onAbort);
2414
+ if (errorBody)
2415
+ bodyController.error(reason);
2416
+ reader.cancel(reason).catch(() => {}).finally(() => reader.releaseLock());
2417
+ };
2418
+ const onAbort = () => stop(signal.reason, true);
2419
+ const body = new ReadableStream({
2420
+ start(controller) {
2421
+ bodyController = controller;
2422
+ signal.addEventListener("abort", onAbort, { once: true });
2423
+ if (signal.aborted)
2424
+ onAbort();
2425
+ },
2426
+ async pull(controller) {
2427
+ try {
2428
+ const result = await untilAborted(reader.read(), signal);
2429
+ if (stopped)
2430
+ return;
2431
+ if (result.done) {
2432
+ stopped = true;
2433
+ release();
2434
+ signal.removeEventListener("abort", onAbort);
2435
+ reader.releaseLock();
2436
+ controller.close();
2437
+ } else {
2438
+ controller.enqueue(result.value);
2439
+ }
2440
+ } catch (error) {
2441
+ stop(error, true);
2442
+ }
2443
+ },
2444
+ cancel(reason) {
2445
+ stop(reason, false);
2446
+ }
2447
+ }, { highWaterMark: 0 });
2448
+ return preserveResponseMetadata(new Response(body, {
2449
+ status: response.status,
2450
+ statusText: response.statusText,
2451
+ headers: response.headers
2452
+ }), response);
2453
+ }
2352
2454
  async function safeFetch(input, init = {}, options = {}) {
2455
+ init.signal?.throwIfAborted();
2353
2456
  const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
2457
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
2458
+ throw new RangeError("safeFetch timeoutMs must be a positive finite number.");
2459
+ }
2354
2460
  const maxRedirects = options.maxRedirects ?? 0;
2355
- const resolveDns = options.resolveDns ?? true;
2356
2461
  const urlOptions = {
2357
2462
  allowHttp: options.allowHttp,
2358
- resolveDns,
2463
+ resolveDns: options.resolveDns ?? true,
2359
2464
  allowPrivate: options.allowPrivate
2360
2465
  };
2361
2466
  const controller = new AbortController;
2362
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
2467
+ const forwardAbort = () => controller.abort(init.signal?.reason);
2468
+ init.signal?.addEventListener("abort", forwardAbort, { once: true });
2469
+ const timeout = setTimeout(() => controller.abort(new DOMException("The operation timed out.", "TimeoutError")), timeoutMs);
2470
+ const release = () => {
2471
+ clearTimeout(timeout);
2472
+ init.signal?.removeEventListener("abort", forwardAbort);
2473
+ };
2363
2474
  try {
2364
- let current = await resolveSafeOutboundTarget(input, urlOptions);
2475
+ let current = await untilAborted(resolveSafeOutboundTarget(input, urlOptions), controller.signal);
2365
2476
  let redirectCount = 0;
2366
2477
  while (true) {
2478
+ controller.signal.throwIfAborted();
2367
2479
  const address = current.addresses[0];
2368
2480
  const fetchUrl = address ? pinUrlToAddress(current.url, address).toString() : current.url.toString();
2369
2481
  const headers = new Headers(init.headers);
2370
- if (address && !headers.has("host")) {
2482
+ if (address && !headers.has("host"))
2371
2483
  headers.set("Host", current.url.host);
2372
- }
2373
2484
  const fetchInit = {
2374
2485
  ...init,
2375
2486
  headers,
2376
2487
  signal: controller.signal,
2377
2488
  redirect: "manual"
2378
2489
  };
2379
- if (address) {
2490
+ if (address)
2380
2491
  fetchInit.tls = { serverName: current.url.hostname };
2381
- }
2382
- const response = await fetch(fetchUrl, fetchInit);
2492
+ const response = await untilAborted(fetch(fetchUrl, fetchInit), controller.signal);
2383
2493
  if (response.status >= 300 && response.status < 400) {
2384
2494
  const location = response.headers.get("location");
2385
- if (!location || redirectCount >= maxRedirects) {
2386
- return response;
2495
+ if (location && redirectCount < maxRedirects) {
2496
+ if (response.body)
2497
+ await untilAborted(response.body.cancel(), controller.signal);
2498
+ current = await untilAborted(resolveSafeOutboundTarget(new URL(location, current.url).toString(), urlOptions), controller.signal);
2499
+ redirectCount++;
2500
+ continue;
2387
2501
  }
2388
- current = await resolveSafeOutboundTarget(new URL(location, current.url).toString(), urlOptions);
2389
- redirectCount += 1;
2390
- continue;
2391
2502
  }
2392
- return response;
2503
+ return withBodyDeadline(response, controller.signal, release);
2393
2504
  }
2394
- } finally {
2395
- clearTimeout(timeout);
2505
+ } catch (error) {
2506
+ controller.abort(error);
2507
+ release();
2508
+ throw error;
2396
2509
  }
2397
2510
  }
2398
2511
 
@@ -7269,6 +7382,7 @@ function registerModelRepository(model, repository) {
7269
7382
  return model;
7270
7383
  }
7271
7384
  // ../../src/core/database/mysqlConnection.ts
7385
+ init_connectionContext();
7272
7386
  var MYSQL_SESSION_UTC = "SET time_zone = '+00:00'";
7273
7387
  var mysqlModule;
7274
7388
  var mysqlPending;
@@ -7324,18 +7438,71 @@ function rowsFromResult(result) {
7324
7438
  }
7325
7439
  return [];
7326
7440
  }
7327
- function createMysqlConnectionFromPool(pool) {
7328
- return {
7441
+ async function executeMysql(target, query, params) {
7442
+ const [result] = params.length === 0 && target.query ? await target.query(query) : await target.execute(query, [...params]);
7443
+ return rowsFromResult(result);
7444
+ }
7445
+ function createMysqlAdapter(getPool, close) {
7446
+ const transactions = new WeakSet;
7447
+ const connection = {
7329
7448
  async unsafe(query, params = []) {
7330
- const [result] = await pool.execute(query, [...params]);
7331
- return rowsFromResult(result);
7449
+ const active = getActiveDatabaseConnection(connection);
7450
+ if (transactions.has(active))
7451
+ return active.unsafe(query, params);
7452
+ return executeMysql(await getPool(), query, params);
7332
7453
  },
7333
- async close() {
7334
- if (typeof pool.end === "function") {
7335
- await pool.end();
7454
+ async begin(callback) {
7455
+ if (transactions.has(getActiveDatabaseConnection(connection))) {
7456
+ throw new Error("Use runInTransaction for nested MySQL transactions.");
7336
7457
  }
7337
- }
7458
+ const pool = await getPool();
7459
+ if (!pool.getConnection)
7460
+ throw new Error("MySQL transactions require a pool with getConnection().");
7461
+ const reserved = await pool.getConnection();
7462
+ let open = false;
7463
+ let discard = false;
7464
+ const transaction = {
7465
+ async unsafe(query, params = []) {
7466
+ if (!open)
7467
+ throw new Error("MySQL transaction is no longer active.");
7468
+ return executeMysql(reserved, query, params);
7469
+ }
7470
+ };
7471
+ transactions.add(transaction);
7472
+ try {
7473
+ await reserved.beginTransaction();
7474
+ open = true;
7475
+ const result = await runWithDatabaseConnection(transaction, () => callback(transaction));
7476
+ await reserved.commit();
7477
+ return result;
7478
+ } catch (error) {
7479
+ if (open) {
7480
+ try {
7481
+ await reserved.rollback();
7482
+ } catch (rollbackError) {
7483
+ discard = true;
7484
+ reserved.destroy();
7485
+ throw new AggregateError([error, rollbackError], "MySQL rollback failed; connection discarded.");
7486
+ }
7487
+ } else {
7488
+ discard = true;
7489
+ reserved.destroy();
7490
+ }
7491
+ throw error;
7492
+ } finally {
7493
+ open = false;
7494
+ if (!discard)
7495
+ reserved.release();
7496
+ }
7497
+ },
7498
+ close
7338
7499
  };
7500
+ return connection;
7501
+ }
7502
+ function createMysqlConnectionFromPool(pool) {
7503
+ return createMysqlAdapter(async () => pool, async () => {
7504
+ await pool.end?.();
7505
+ });
7339
7506
  }
7340
7507
  function pinSessionToUtc(connection) {
7341
7508
  connection.query(MYSQL_SESSION_UTC, (error) => {
@@ -7368,23 +7535,16 @@ function createMysqlConnection(url) {
7368
7535
  }
7369
7536
  return poolPending;
7370
7537
  }
7371
- return {
7372
- async unsafe(query, params = []) {
7373
- const [result] = await (await ensurePool()).execute(query, [...params]);
7374
- return rowsFromResult(result);
7375
- },
7376
- async close() {
7377
- if (!poolPending) {
7378
- return;
7379
- }
7380
- const pending = poolPending;
7381
- poolPending = undefined;
7382
- const pool = await pending.catch(() => {
7383
- return;
7384
- });
7385
- await pool?.end();
7386
- }
7387
- };
7538
+ return createMysqlAdapter(ensurePool, async () => {
7539
+ if (!poolPending)
7540
+ return;
7541
+ const pending = poolPending;
7542
+ poolPending = undefined;
7543
+ const pool = await pending.catch(() => {
7544
+ return;
7545
+ });
7546
+ await pool?.end();
7547
+ });
7388
7548
  }
7389
7549
  // ../../src/core/database/namedConnections.ts
7390
7550
  init_connectionContext();
@@ -8046,6 +8206,7 @@ async function runSeedersFromDirectory(directory, db, options) {
8046
8206
  return seeders.length;
8047
8207
  }
8048
8208
  // ../../src/core/database/sqliteConnection.ts
8209
+ init_connectionContext();
8049
8210
  import { Database } from "bun:sqlite";
8050
8211
  function isRowReturning(sql) {
8051
8212
  const upper = sql.replace(/\s+/g, " ").trim().toUpperCase();
@@ -8065,20 +8226,70 @@ function createSqliteConnection(filename) {
8065
8226
  db.exec("PRAGMA journal_mode = WAL");
8066
8227
  db.exec("PRAGMA synchronous = NORMAL");
8067
8228
  }
8068
- return {
8229
+ let tail = Promise.resolve();
8230
+ let pending = 0;
8231
+ let closed = false;
8232
+ const transactions = new WeakSet;
8233
+ function execute(query, params) {
8234
+ const statement = db.query(query);
8235
+ const args = [...params];
8236
+ if (isRowReturning(query))
8237
+ return statement.all(...args);
8238
+ statement.run(...args);
8239
+ return [];
8240
+ }
8241
+ function enqueue(operation) {
8242
+ if (closed)
8243
+ return Promise.reject(new Error("SQLite connection is closed."));
8244
+ pending++;
8245
+ const result = tail.then(operation).finally(() => {
8246
+ pending--;
8247
+ });
8248
+ tail = result.then(() => {}, () => {});
8249
+ return result;
8250
+ }
8251
+ const connection = {
8069
8252
  async unsafe(query, params = []) {
8070
- const statement = db.query(query);
8071
- const args = [...params];
8072
- if (isRowReturning(query)) {
8073
- return statement.all(...args);
8253
+ const active = getActiveDatabaseConnection(connection);
8254
+ if (transactions.has(active))
8255
+ return active.unsafe(query, params);
8256
+ return enqueue(() => execute(query, params));
8257
+ },
8258
+ async begin(callback) {
8259
+ if (transactions.has(getActiveDatabaseConnection(connection))) {
8260
+ throw new Error("Use runInTransaction for nested SQLite transactions.");
8074
8261
  }
8075
- statement.run(...args);
8076
- return [];
8262
+ return enqueue(async () => {
8263
+ db.exec("BEGIN IMMEDIATE");
8264
+ let open = true;
8265
+ const transaction = {
8266
+ async unsafe(query, params = []) {
8267
+ if (!open)
8268
+ throw new Error("SQLite transaction is no longer active.");
8269
+ return execute(query, params);
8270
+ }
8271
+ };
8272
+ transactions.add(transaction);
8273
+ try {
8274
+ const result = await runWithDatabaseConnection(transaction, () => callback(transaction));
8275
+ db.exec("COMMIT");
8276
+ return result;
8277
+ } catch (error) {
8278
+ db.exec("ROLLBACK");
8279
+ throw error;
8280
+ } finally {
8281
+ open = false;
8282
+ }
8283
+ });
8077
8284
  },
8078
8285
  close() {
8286
+ if (pending > 0)
8287
+ throw new Error("Drain SQLite operations before closing the connection.");
8288
+ closed = true;
8079
8289
  db.close();
8080
8290
  }
8081
8291
  };
8292
+ return connection;
8082
8293
  }
8083
8294
  // ../../src/core/database/table.ts
8084
8295
  function defineTable(definition) {
@@ -8089,6 +8300,333 @@ function defineTable(definition) {
8089
8300
  init_transaction();
8090
8301
  init_eventBus();
8091
8302
 
8303
+ // ../../src/core/events/outbox/index.ts
8304
+ init_dialect();
8305
+ init_repositoryConnection();
8306
+ init_transaction();
8307
+ init_databaseTenantContext();
8308
+ init_tenantContext();
8309
+ init_tenantDatabaseScope();
8310
+
8311
+ // ../../src/core/events/outbox/migration.ts
8312
+ function createOutboxMigration(name, driver, options = {}) {
8313
+ const identity = driver === "mysql" ? " CHARACTER SET ascii COLLATE ascii_bin" : "";
8314
+ const payloadType = driver === "mysql" ? "MEDIUMTEXT" : "TEXT";
8315
+ const inlineIndex = driver === "mysql" ? ", INDEX strata_outbox_due (status, available_at, event_id, listener_name), INDEX strata_outbox_expired (status, lease_until, event_id, listener_name)" : "";
8316
+ return {
8317
+ name,
8318
+ async up(db) {
8319
+ await db.unsafe(`CREATE TABLE IF NOT EXISTS strata_outbox_event (
8320
+ id VARCHAR(64)${identity} PRIMARY KEY, name VARCHAR(128)${identity} NOT NULL, version INTEGER NOT NULL CHECK (version > 0),
8321
+ tenant_id BIGINT, payload ${payloadType} NOT NULL, created_at BIGINT NOT NULL,
8322
+ publication_token VARCHAR(64)${identity} NOT NULL)`);
8323
+ await db.unsafe(`CREATE TABLE IF NOT EXISTS strata_outbox_delivery (
8324
+ event_id VARCHAR(64)${identity} NOT NULL,
8325
+ listener_name VARCHAR(128)${identity} NOT NULL, status VARCHAR(16) NOT NULL CHECK (status IN ('pending', 'processing', 'failed', 'completed')),
8326
+ attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), max_attempts INTEGER NOT NULL CHECK (max_attempts > 0),
8327
+ available_at BIGINT NOT NULL, lease_token VARCHAR(64)${identity}, lease_until BIGINT,
8328
+ completed_at BIGINT, error_code VARCHAR(64), PRIMARY KEY (event_id, listener_name)${inlineIndex},
8329
+ FOREIGN KEY (event_id) REFERENCES strata_outbox_event(id) ON DELETE CASCADE)`);
8330
+ if (driver !== "mysql") {
8331
+ await db.unsafe("CREATE INDEX IF NOT EXISTS strata_outbox_due ON strata_outbox_delivery (status, available_at, event_id, listener_name)");
8332
+ await db.unsafe("CREATE INDEX IF NOT EXISTS strata_outbox_expired ON strata_outbox_delivery (status, lease_until, event_id, listener_name)");
8333
+ }
8334
+ if (driver === "pgsql" && options.rls) {
8335
+ await db.unsafe(`ALTER TABLE strata_outbox_event ENABLE ROW LEVEL SECURITY`);
8336
+ await db.unsafe(`ALTER TABLE strata_outbox_event FORCE ROW LEVEL SECURITY`);
8337
+ await db.unsafe(`DROP POLICY IF EXISTS strata_outbox_scope ON strata_outbox_event`);
8338
+ await db.unsafe(`CREATE POLICY strata_outbox_scope ON strata_outbox_event USING (
8339
+ current_setting('app.bypass_rls', true) = 'true' OR
8340
+ tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint OR
8341
+ (tenant_id IS NULL AND NULLIF(current_setting('app.tenant_id', true), '') IS NULL))`);
8342
+ await db.unsafe(`ALTER TABLE strata_outbox_delivery ENABLE ROW LEVEL SECURITY`);
8343
+ await db.unsafe(`ALTER TABLE strata_outbox_delivery FORCE ROW LEVEL SECURITY`);
8344
+ await db.unsafe(`DROP POLICY IF EXISTS strata_outbox_scope ON strata_outbox_delivery`);
8345
+ await db.unsafe(`CREATE POLICY strata_outbox_scope ON strata_outbox_delivery USING (
8346
+ current_setting('app.bypass_rls', true) = 'true' OR EXISTS (
8347
+ SELECT 1 FROM strata_outbox_event WHERE id = strata_outbox_delivery.event_id))`);
8348
+ }
8349
+ },
8350
+ async down(db) {
8351
+ const [row] = await db.unsafe("SELECT COUNT(*) AS pending FROM strata_outbox_delivery WHERE status <> 'completed'");
8352
+ if (Number(row?.pending) > 0)
8353
+ throw new Error("Cannot drop outbox tables while undelivered events remain.");
8354
+ await db.unsafe("DROP TABLE strata_outbox_delivery");
8355
+ await db.unsafe("DROP TABLE strata_outbox_event");
8356
+ }
8357
+ };
8358
+ }
8359
+
8360
+ // ../../src/core/events/outbox/index.ts
8361
+ function positiveInteger(value, name) {
8362
+ if (!Number.isSafeInteger(value) || value < 1)
8363
+ throw new Error(`${name} must be a positive integer.`);
8364
+ return value;
8365
+ }
8366
+ function identifier(value, max) {
8367
+ if (!value || value.length > max || !/^[\w.:-]+$/.test(value))
8368
+ throw new Error("Invalid durable event/listener identifier.");
8369
+ return value;
8370
+ }
8371
+ function placeholders(count) {
8372
+ return Array.from({ length: count }, (_, index) => currentSqlDialect().placeholder(index + 1));
8373
+ }
8374
+ async function clock() {
8375
+ const [row] = await repositoryConnection.unsafe(`SELECT ${currentSqlDialect().epochMillisecondsExpression()} AS now_ms`);
8376
+ const now = Number(row?.now_ms);
8377
+ if (!Number.isSafeInteger(now))
8378
+ throw new Error("Database did not return a valid outbox clock.");
8379
+ return now;
8380
+ }
8381
+ async function privileged(work) {
8382
+ if (hasActiveTransaction())
8383
+ throw new Error("Outbox coordination must run outside business transactions.");
8384
+ for (let attempt = 0;; attempt++) {
8385
+ try {
8386
+ return await runWithMigrationBypass(() => runInTransaction(work));
8387
+ } catch (error) {
8388
+ const failure = error !== null && typeof error === "object" ? error : {};
8389
+ const code = String(failure.errno ?? failure.code);
8390
+ if (attempt >= 4 || !["40001", "40P01", "1213"].includes(code))
8391
+ throw error;
8392
+ await Bun.sleep(10 * (attempt + 1));
8393
+ }
8394
+ }
8395
+ }
8396
+
8397
+ class SqlOutbox {
8398
+ listeners = new Map;
8399
+ leaseMs;
8400
+ maxAttempts;
8401
+ retryDelayMs;
8402
+ maxPayloadBytes;
8403
+ tenantResolver;
8404
+ constructor(options) {
8405
+ this.leaseMs = positiveInteger(options.leaseMs ?? 60000, "leaseMs");
8406
+ if (this.leaseMs < 30)
8407
+ throw new Error("leaseMs must be at least 30 milliseconds.");
8408
+ this.maxAttempts = positiveInteger(options.maxAttempts ?? 5, "maxAttempts");
8409
+ this.retryDelayMs = positiveInteger(options.retryDelayMs ?? 1000, "retryDelayMs");
8410
+ this.maxPayloadBytes = positiveInteger(options.maxPayloadBytes ?? 65536, "maxPayloadBytes");
8411
+ this.tenantResolver = options.resolveTenant ?? resolveTenant;
8412
+ for (const listener of options.listeners) {
8413
+ identifier(listener.name, 128);
8414
+ identifier(listener.event, 128);
8415
+ if (this.listeners.has(listener.name))
8416
+ throw new Error("Durable listener names must be unique.");
8417
+ this.listeners.set(listener.name, listener);
8418
+ }
8419
+ }
8420
+ async publish(name, payload, options = {}) {
8421
+ if (!hasActiveTransaction())
8422
+ throw new Error("Durable publication requires an active business transaction.");
8423
+ return runInTransaction(() => this.persist(name, payload, options));
8424
+ }
8425
+ async persist(name, payload, options) {
8426
+ identifier(name, 128);
8427
+ const id = identifier(options.id ?? crypto.randomUUID(), 64);
8428
+ const version = positiveInteger(options.version ?? 1, "version");
8429
+ const serialized = JSON.stringify(payload);
8430
+ if (serialized === undefined || new TextEncoder().encode(serialized).byteLength > this.maxPayloadBytes)
8431
+ throw new Error("Invalid or oversized durable payload.");
8432
+ const listeners = [...this.listeners.values()].filter((listener) => listener.event === name);
8433
+ if (listeners.length === 0)
8434
+ throw new Error("No durable listeners registered for this event.");
8435
+ const tenantId = currentTenant()?.id ?? null;
8436
+ if (tenantId !== null)
8437
+ positiveInteger(tenantId, "tenantId");
8438
+ const token = crypto.randomUUID();
8439
+ const now = await clock();
8440
+ await repositoryConnection.unsafe(`INSERT INTO strata_outbox_event (id, name, version, tenant_id, payload, created_at, publication_token)
8441
+ VALUES (${placeholders(7).join(", ")})${currentSqlDialect().upsertSuffix(["id"], [])}`, [id, name, version, tenantId, serialized, now, token]);
8442
+ const [existing] = await repositoryConnection.unsafe(`SELECT * FROM strata_outbox_event WHERE id = ${placeholders(1)[0]}`, [id]);
8443
+ if (!existing || existing.name !== name || Number(existing.version) !== version || (existing.tenant_id === null ? null : Number(existing.tenant_id)) !== tenantId || existing.payload !== serialized)
8444
+ throw new Error("Conflicting durable event ID.");
8445
+ if (existing.publication_token !== token)
8446
+ return id;
8447
+ for (const listener of listeners) {
8448
+ await repositoryConnection.unsafe(`INSERT INTO strata_outbox_delivery (event_id, listener_name, status, max_attempts, available_at)
8449
+ VALUES (${placeholders(5).join(", ")})`, [id, listener.name, "pending", this.maxAttempts, now]);
8450
+ }
8451
+ return id;
8452
+ }
8453
+ async claim() {
8454
+ return privileged(async () => {
8455
+ const now = await clock();
8456
+ const p = placeholders(2);
8457
+ const columns = "event_id, listener_name, attempts, max_attempts";
8458
+ const [expired] = await repositoryConnection.unsafe(`SELECT ${columns} FROM strata_outbox_delivery WHERE status = 'processing' AND lease_until <= ${p[0]}
8459
+ ORDER BY lease_until, event_id, listener_name LIMIT 1${currentSqlDialect().rowLockClause(true)}`, [now]);
8460
+ const delivery = expired ?? (await repositoryConnection.unsafe(`SELECT ${columns} FROM strata_outbox_delivery WHERE status = 'pending' AND available_at <= ${p[0]}
8461
+ ORDER BY available_at, event_id, listener_name LIMIT 1${currentSqlDialect().rowLockClause(true)}`, [now]))[0];
8462
+ if (!delivery)
8463
+ return null;
8464
+ const [event] = await repositoryConnection.unsafe(`SELECT * FROM strata_outbox_event WHERE id = ${p[0]}`, [delivery.event_id]);
8465
+ if (!event)
8466
+ throw new Error("Outbox event missing for delivery.");
8467
+ const candidate = { ...event, ...delivery };
8468
+ const token = crypto.randomUUID();
8469
+ const exhausted = Number(candidate.attempts) >= Number(candidate.max_attempts);
8470
+ const q = placeholders(9);
8471
+ await repositoryConnection.unsafe(`UPDATE strata_outbox_delivery SET status = ${q[0]}, lease_token = ${q[1]}, lease_until = ${q[2]},
8472
+ attempts = ${q[3]}, error_code = ${exhausted ? "'attempts_exhausted'" : "error_code"} WHERE event_id = ${q[4]} AND listener_name = ${q[5]} AND
8473
+ ((status = 'pending' AND available_at <= ${q[6]}) OR (status = 'processing' AND lease_until <= ${q[7]})) AND attempts = ${q[8]}`, [
8474
+ exhausted ? "failed" : "processing",
8475
+ token,
8476
+ exhausted ? null : now + this.leaseMs,
8477
+ Number(candidate.attempts) + (exhausted ? 0 : 1),
8478
+ candidate.id,
8479
+ candidate.listener_name,
8480
+ now,
8481
+ now,
8482
+ candidate.attempts
8483
+ ]);
8484
+ const [owner] = await repositoryConnection.unsafe(`SELECT lease_token FROM strata_outbox_delivery WHERE event_id = ${p[0]} AND listener_name = ${p[1]}${currentSqlDialect().rowLockClause()}`, [candidate.id, candidate.listener_name]);
8485
+ if (owner?.lease_token !== token)
8486
+ return null;
8487
+ if (exhausted)
8488
+ return "exhausted";
8489
+ return { ...candidate, attempts: Number(candidate.attempts) + 1, lease_token: token };
8490
+ });
8491
+ }
8492
+ async renew(claim) {
8493
+ return privileged(async () => {
8494
+ const now = await clock();
8495
+ const p = placeholders(5);
8496
+ await repositoryConnection.unsafe(`UPDATE strata_outbox_delivery SET lease_until = ${p[0]} WHERE event_id = ${p[1]}
8497
+ AND listener_name = ${p[2]} AND lease_token = ${p[3]} AND status = 'processing' AND lease_until > ${p[4]}`, [now + this.leaseMs, claim.id, claim.listener_name, claim.lease_token, now]);
8498
+ const q = placeholders(3);
8499
+ const [owner] = await repositoryConnection.unsafe(`SELECT lease_until FROM strata_outbox_delivery WHERE event_id = ${q[0]} AND listener_name = ${q[1]} AND lease_token = ${q[2]} AND status = 'processing'${currentSqlDialect().rowLockClause()}`, [claim.id, claim.listener_name, claim.lease_token]);
8500
+ return Number(owner?.lease_until) > now;
8501
+ });
8502
+ }
8503
+ async finish(claim, success) {
8504
+ await privileged(async () => {
8505
+ const now = await clock();
8506
+ const status = success ? "completed" : claim.attempts >= Number(claim.max_attempts) ? "failed" : "pending";
8507
+ const delay = Math.min(this.retryDelayMs * 2 ** Math.min(claim.attempts - 1, 16), 300000);
8508
+ const p = placeholders(9);
8509
+ await repositoryConnection.unsafe(`UPDATE strata_outbox_delivery SET status = ${p[0]}, available_at = ${p[1]}, completed_at = ${p[2]},
8510
+ error_code = ${p[3]}, lease_until = NULL WHERE event_id = ${p[4]} AND listener_name = ${p[5]}
8511
+ AND lease_token = ${p[6]} AND status = 'processing' AND lease_until > ${p[7]} AND attempts = ${p[8]}`, [
8512
+ status,
8513
+ now + delay,
8514
+ success ? now : null,
8515
+ success ? null : "listener_failed",
8516
+ claim.id,
8517
+ claim.listener_name,
8518
+ claim.lease_token,
8519
+ now,
8520
+ claim.attempts
8521
+ ]);
8522
+ });
8523
+ }
8524
+ async processNext(options = {}) {
8525
+ if (hasActiveTransaction())
8526
+ throw new Error("Outbox workers must run outside business transactions.");
8527
+ options.signal?.throwIfAborted();
8528
+ const claim = await this.claim();
8529
+ if (!claim)
8530
+ return false;
8531
+ if (claim === "exhausted")
8532
+ return true;
8533
+ const controller = new AbortController;
8534
+ const forward = () => controller.abort(options.signal?.reason);
8535
+ options.signal?.addEventListener("abort", forward, { once: true });
8536
+ if (options.signal?.aborted)
8537
+ forward();
8538
+ let renewal;
8539
+ let watchdog = setTimeout(() => controller.abort(new Error("Outbox lease expired.")), this.leaseMs);
8540
+ const heartbeat = setInterval(() => {
8541
+ if (renewal || controller.signal.aborted)
8542
+ return;
8543
+ renewal = (async () => {
8544
+ try {
8545
+ if (!await this.renew(claim))
8546
+ controller.abort(new Error("Outbox ownership lost."));
8547
+ else {
8548
+ clearTimeout(watchdog);
8549
+ watchdog = setTimeout(() => controller.abort(new Error("Outbox lease expired.")), this.leaseMs);
8550
+ }
8551
+ } catch {
8552
+ controller.abort(new Error("Outbox lease renewal failed."));
8553
+ } finally {
8554
+ renewal = undefined;
8555
+ }
8556
+ })();
8557
+ }, Math.max(10, Math.floor(this.leaseMs / 3)));
8558
+ let success = false;
8559
+ try {
8560
+ const listener = this.listeners.get(claim.listener_name);
8561
+ if (!listener || listener.event !== claim.name)
8562
+ throw new Error("Durable listener is not registered.");
8563
+ const event = {
8564
+ id: claim.id,
8565
+ name: claim.name,
8566
+ version: Number(claim.version),
8567
+ tenantId: claim.tenant_id === null ? null : Number(claim.tenant_id),
8568
+ payload: JSON.parse(claim.payload)
8569
+ };
8570
+ const tenant = event.tenantId === null ? null : await privileged(() => this.tenantResolver(Number(event.tenantId)));
8571
+ if (event.tenantId !== null && (!tenant || tenant.id !== event.tenantId))
8572
+ throw new Error("Outbox tenant could not be resolved.");
8573
+ const handle = async () => {
8574
+ controller.signal.throwIfAborted();
8575
+ await listener.handle(event, { signal: controller.signal });
8576
+ controller.signal.throwIfAborted();
8577
+ };
8578
+ if (tenant)
8579
+ await runWithTenantDatabase(tenant, handle);
8580
+ else
8581
+ await handle();
8582
+ success = true;
8583
+ } catch {
8584
+ success = false;
8585
+ } finally {
8586
+ clearInterval(heartbeat);
8587
+ await renewal;
8588
+ clearTimeout(watchdog);
8589
+ options.signal?.removeEventListener("abort", forward);
8590
+ }
8591
+ await this.finish(claim, success && !controller.signal.aborted);
8592
+ return true;
8593
+ }
8594
+ async work(options) {
8595
+ const pollMs = positiveInteger(options.pollMs ?? 1000, "pollMs");
8596
+ while (!options.signal.aborted) {
8597
+ try {
8598
+ if (await this.processNext({ signal: options.signal }))
8599
+ continue;
8600
+ } catch (error) {
8601
+ if (options.signal.aborted)
8602
+ return;
8603
+ throw error;
8604
+ }
8605
+ await new Promise((resolve) => {
8606
+ const done = () => {
8607
+ clearTimeout(timer);
8608
+ options.signal.removeEventListener("abort", done);
8609
+ resolve();
8610
+ };
8611
+ const timer = setTimeout(done, pollMs);
8612
+ options.signal.addEventListener("abort", done, { once: true });
8613
+ if (options.signal.aborted)
8614
+ done();
8615
+ });
8616
+ }
8617
+ }
8618
+ async replay(eventId, listenerName) {
8619
+ identifier(eventId, 64);
8620
+ identifier(listenerName, 128);
8621
+ await privileged(async () => {
8622
+ const now = await clock();
8623
+ const p = placeholders(3);
8624
+ await repositoryConnection.unsafe(`UPDATE strata_outbox_delivery SET status = 'pending', attempts = 0, available_at = ${p[0]},
8625
+ lease_token = NULL, lease_until = NULL, completed_at = NULL, error_code = NULL
8626
+ WHERE event_id = ${p[1]} AND listener_name = ${p[2]} AND status = 'failed'`, [now, eventId, listenerName]);
8627
+ });
8628
+ }
8629
+ }
8092
8630
  // ../../src/core/mail/mailer.ts
8093
8631
  function resolveSmtpConfig() {
8094
8632
  const host = process.env.MAIL_HOST?.trim();
@@ -9895,7 +10433,7 @@ function logServerError(error, mappedError) {
9895
10433
  });
9896
10434
  }
9897
10435
  async function webErrorResponse(error, request) {
9898
- if (!request || !isViewsEnabled() || requestPrefersJson(request)) {
10436
+ if (currentRequestMeta().errorFormat === "json" || !request || !isViewsEnabled() || requestPrefersJson(request)) {
9899
10437
  return null;
9900
10438
  }
9901
10439
  const mappedError = toHttpError(error) ?? mapDatabaseError(error);
@@ -12408,6 +12946,7 @@ export {
12408
12946
  Schedule,
12409
12947
  Schema,
12410
12948
  ServiceContainer,
12949
+ SqlOutbox,
12411
12950
  SqliteGrammar,
12412
12951
  StorageManager,
12413
12952
  SyncQueue,
@@ -12502,6 +13041,7 @@ export {
12502
13041
  createMysqlPool,
12503
13042
  createNotificationDispatcher,
12504
13043
  createOidcHandshake,
13044
+ createOutboxMigration,
12505
13045
  createProductionQueue,
12506
13046
  createQueue,
12507
13047
  createQueueWorker,
@@ -12567,6 +13107,7 @@ export {
12567
13107
  grammarForDriver,
12568
13108
  guestCanViewResource,
12569
13109
  hasActiveDatabaseConnection,
13110
+ hasActiveTransaction,
12570
13111
  hasMany,
12571
13112
  hasManyThrough,
12572
13113
  hasMinimumOrgRole2 as hasMinimumOrgRole,