@getstrata/core 2.0.2 → 2.0.4
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 +8 -0
- package/dist/core/database/baseRepository.d.ts +2 -0
- package/dist/core/database/dialect.d.ts +4 -0
- package/dist/core/database/mysqlConnection.d.ts +12 -2
- package/dist/core/database/sqliteConnection.d.ts +2 -1
- package/dist/core/database/transaction.d.ts +2 -2
- package/dist/core/database/transactionControl.d.ts +2 -1
- package/dist/core/events/eventBus.d.ts +4 -0
- package/dist/core/events/outbox/index.d.ts +55 -0
- package/dist/core/events/outbox/migration.d.ts +7 -0
- package/dist/core/http/requestMetaContext.d.ts +2 -0
- package/dist/core/http/response.d.ts +2 -1
- package/dist/core/openapi/registeredRoute.d.ts +40 -1
- package/dist/core/security/safeFetch.d.ts +1 -0
- package/dist/entries/audit/exportAuditLogs.js +137 -17
- package/dist/entries/auth/basicAuthGuard.js +18 -0
- package/dist/entries/auth/oauth/oidcProvider.js +100 -17
- package/dist/entries/auth/oauth/providers.js +100 -17
- package/dist/entries/auth/oauth/samlProvider.js +18 -0
- package/dist/entries/auth/oneTimeToken.js +18 -0
- package/dist/entries/auth/passwordLogin.js +18 -0
- package/dist/entries/auth/saml/samlServiceProvider.js +18 -0
- package/dist/entries/auth/scimAuthMiddleware.js +37 -0
- package/dist/entries/database/migrations.js +18 -0
- package/dist/entries/database/sqliteConnection.js +101 -7
- package/dist/entries/events/outbox.js +1 -0
- package/dist/entries/http/response.js +39 -5
- package/dist/entries/jobs/exportAuditLogsJob.js +137 -17
- package/dist/entries/openapi/generator.js +42 -12
- package/dist/entries/openapi/validate.js +10 -0
- package/dist/entries/security/safeFetch.js +100 -17
- package/dist/entries/tenant/databaseTenantContext.js +37 -0
- package/dist/entries/tenant/tenantDatabaseScope.js +37 -0
- package/dist/entries/tracing/tracingMiddleware.js +100 -17
- package/dist/framework/public-api.d.ts +3 -1
- package/dist/index.js +649 -60
- 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) {
|
|
@@ -513,6 +535,24 @@ var init_transactionControl = __esm(() => {
|
|
|
513
535
|
// ../../src/core/events/eventBus.ts
|
|
514
536
|
class EventBus {
|
|
515
537
|
constructor() {}
|
|
538
|
+
transactionalListeners = new Map;
|
|
539
|
+
listenTransactional(event, listener) {
|
|
540
|
+
const handlers = this.transactionalListeners.get(event) ?? new Set;
|
|
541
|
+
handlers.add(listener);
|
|
542
|
+
this.transactionalListeners.set(event, handlers);
|
|
543
|
+
return () => {
|
|
544
|
+
handlers.delete(listener);
|
|
545
|
+
if (!handlers.size)
|
|
546
|
+
this.transactionalListeners.delete(event);
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
hasTransactionalListeners(event) {
|
|
550
|
+
return (this.transactionalListeners.get(event)?.size ?? 0) > 0;
|
|
551
|
+
}
|
|
552
|
+
async dispatchTransactional(event, payload) {
|
|
553
|
+
for (const listener of this.transactionalListeners.get(event) ?? [])
|
|
554
|
+
await listener(payload);
|
|
555
|
+
}
|
|
516
556
|
listeners = new Map;
|
|
517
557
|
on(event, listener) {
|
|
518
558
|
return this.listen(event, listener);
|
|
@@ -599,6 +639,7 @@ async function dispatchQueuedModelEvents(queued) {
|
|
|
599
639
|
}
|
|
600
640
|
}
|
|
601
641
|
async function dispatchModelEvent(event, payload) {
|
|
642
|
+
await eventBus.dispatchTransactional(event, payload);
|
|
602
643
|
const state = deferredModelEvents.getStore();
|
|
603
644
|
const frame = state?.frames[state.frames.length - 1];
|
|
604
645
|
if (frame) {
|
|
@@ -711,6 +752,11 @@ var init_transaction = __esm(() => {
|
|
|
711
752
|
});
|
|
712
753
|
|
|
713
754
|
// ../../src/core/tenant/databaseTenantContext.ts
|
|
755
|
+
async function applyBypassToTransaction(transaction, bypass) {
|
|
756
|
+
await transaction.unsafe(`SELECT set_config('app.bypass_rls', $1, true)`, [
|
|
757
|
+
bypass ? "true" : "false"
|
|
758
|
+
]);
|
|
759
|
+
}
|
|
714
760
|
function stringifyBypassIdentifier(identifier) {
|
|
715
761
|
if (typeof identifier === "number") {
|
|
716
762
|
return String(identifier);
|
|
@@ -763,6 +809,9 @@ async function runWithScopedTenantTransaction(apply, callback) {
|
|
|
763
809
|
});
|
|
764
810
|
}));
|
|
765
811
|
}
|
|
812
|
+
async function runWithMigrationBypass(callback) {
|
|
813
|
+
return await runWithScopedTenantTransaction((transaction) => applyBypassToTransaction(transaction, true), callback);
|
|
814
|
+
}
|
|
766
815
|
async function runWithMigrationBypassForIdentifier(identifier, callback) {
|
|
767
816
|
assertBypassIdentifier(identifier);
|
|
768
817
|
return await runWithScopedTenantTransaction((transaction) => applyIdentifierToTransaction(transaction, identifier), callback);
|
|
@@ -2349,50 +2398,133 @@ function resetDnsLookupForTests() {
|
|
|
2349
2398
|
|
|
2350
2399
|
// ../../src/core/security/safeFetch.ts
|
|
2351
2400
|
var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
|
|
2401
|
+
function untilAborted(work, signal) {
|
|
2402
|
+
signal.throwIfAborted();
|
|
2403
|
+
return new Promise((resolve, reject) => {
|
|
2404
|
+
const onAbort = () => reject(signal.reason);
|
|
2405
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2406
|
+
work.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
|
2407
|
+
});
|
|
2408
|
+
}
|
|
2409
|
+
function preserveResponseMetadata(response, original) {
|
|
2410
|
+
Object.defineProperties(response, {
|
|
2411
|
+
url: { value: original.url },
|
|
2412
|
+
redirected: { value: original.redirected },
|
|
2413
|
+
type: { value: original.type }
|
|
2414
|
+
});
|
|
2415
|
+
const clone = response.clone.bind(response);
|
|
2416
|
+
response.clone = () => preserveResponseMetadata(clone(), original);
|
|
2417
|
+
return response;
|
|
2418
|
+
}
|
|
2419
|
+
function withBodyDeadline(response, signal, release) {
|
|
2420
|
+
if (!response.body) {
|
|
2421
|
+
release();
|
|
2422
|
+
return response;
|
|
2423
|
+
}
|
|
2424
|
+
const reader = response.body.getReader();
|
|
2425
|
+
let stopped = false;
|
|
2426
|
+
let bodyController;
|
|
2427
|
+
const stop = (reason, errorBody) => {
|
|
2428
|
+
if (stopped)
|
|
2429
|
+
return;
|
|
2430
|
+
stopped = true;
|
|
2431
|
+
release();
|
|
2432
|
+
signal.removeEventListener("abort", onAbort);
|
|
2433
|
+
if (errorBody)
|
|
2434
|
+
bodyController.error(reason);
|
|
2435
|
+
reader.cancel(reason).catch(() => {}).finally(() => reader.releaseLock());
|
|
2436
|
+
};
|
|
2437
|
+
const onAbort = () => stop(signal.reason, true);
|
|
2438
|
+
const body = new ReadableStream({
|
|
2439
|
+
start(controller) {
|
|
2440
|
+
bodyController = controller;
|
|
2441
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2442
|
+
if (signal.aborted)
|
|
2443
|
+
onAbort();
|
|
2444
|
+
},
|
|
2445
|
+
async pull(controller) {
|
|
2446
|
+
try {
|
|
2447
|
+
const result = await untilAborted(reader.read(), signal);
|
|
2448
|
+
if (stopped)
|
|
2449
|
+
return;
|
|
2450
|
+
if (result.done) {
|
|
2451
|
+
stopped = true;
|
|
2452
|
+
release();
|
|
2453
|
+
signal.removeEventListener("abort", onAbort);
|
|
2454
|
+
reader.releaseLock();
|
|
2455
|
+
controller.close();
|
|
2456
|
+
} else {
|
|
2457
|
+
controller.enqueue(result.value);
|
|
2458
|
+
}
|
|
2459
|
+
} catch (error) {
|
|
2460
|
+
stop(error, true);
|
|
2461
|
+
}
|
|
2462
|
+
},
|
|
2463
|
+
cancel(reason) {
|
|
2464
|
+
stop(reason, false);
|
|
2465
|
+
}
|
|
2466
|
+
}, { highWaterMark: 0 });
|
|
2467
|
+
return preserveResponseMetadata(new Response(body, {
|
|
2468
|
+
status: response.status,
|
|
2469
|
+
statusText: response.statusText,
|
|
2470
|
+
headers: response.headers
|
|
2471
|
+
}), response);
|
|
2472
|
+
}
|
|
2352
2473
|
async function safeFetch(input, init = {}, options = {}) {
|
|
2474
|
+
init.signal?.throwIfAborted();
|
|
2353
2475
|
const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
2476
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
2477
|
+
throw new RangeError("safeFetch timeoutMs must be a positive finite number.");
|
|
2478
|
+
}
|
|
2354
2479
|
const maxRedirects = options.maxRedirects ?? 0;
|
|
2355
|
-
const resolveDns = options.resolveDns ?? true;
|
|
2356
2480
|
const urlOptions = {
|
|
2357
2481
|
allowHttp: options.allowHttp,
|
|
2358
|
-
resolveDns,
|
|
2482
|
+
resolveDns: options.resolveDns ?? true,
|
|
2359
2483
|
allowPrivate: options.allowPrivate
|
|
2360
2484
|
};
|
|
2361
2485
|
const controller = new AbortController;
|
|
2362
|
-
const
|
|
2486
|
+
const forwardAbort = () => controller.abort(init.signal?.reason);
|
|
2487
|
+
init.signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
2488
|
+
const timeout = setTimeout(() => controller.abort(new DOMException("The operation timed out.", "TimeoutError")), timeoutMs);
|
|
2489
|
+
const release = () => {
|
|
2490
|
+
clearTimeout(timeout);
|
|
2491
|
+
init.signal?.removeEventListener("abort", forwardAbort);
|
|
2492
|
+
};
|
|
2363
2493
|
try {
|
|
2364
|
-
let current = await resolveSafeOutboundTarget(input, urlOptions);
|
|
2494
|
+
let current = await untilAborted(resolveSafeOutboundTarget(input, urlOptions), controller.signal);
|
|
2365
2495
|
let redirectCount = 0;
|
|
2366
2496
|
while (true) {
|
|
2497
|
+
controller.signal.throwIfAborted();
|
|
2367
2498
|
const address = current.addresses[0];
|
|
2368
2499
|
const fetchUrl = address ? pinUrlToAddress(current.url, address).toString() : current.url.toString();
|
|
2369
2500
|
const headers = new Headers(init.headers);
|
|
2370
|
-
if (address && !headers.has("host"))
|
|
2501
|
+
if (address && !headers.has("host"))
|
|
2371
2502
|
headers.set("Host", current.url.host);
|
|
2372
|
-
}
|
|
2373
2503
|
const fetchInit = {
|
|
2374
2504
|
...init,
|
|
2375
2505
|
headers,
|
|
2376
2506
|
signal: controller.signal,
|
|
2377
2507
|
redirect: "manual"
|
|
2378
2508
|
};
|
|
2379
|
-
if (address)
|
|
2509
|
+
if (address)
|
|
2380
2510
|
fetchInit.tls = { serverName: current.url.hostname };
|
|
2381
|
-
|
|
2382
|
-
const response = await fetch(fetchUrl, fetchInit);
|
|
2511
|
+
const response = await untilAborted(fetch(fetchUrl, fetchInit), controller.signal);
|
|
2383
2512
|
if (response.status >= 300 && response.status < 400) {
|
|
2384
2513
|
const location = response.headers.get("location");
|
|
2385
|
-
if (
|
|
2386
|
-
|
|
2514
|
+
if (location && redirectCount < maxRedirects) {
|
|
2515
|
+
if (response.body)
|
|
2516
|
+
await untilAborted(response.body.cancel(), controller.signal);
|
|
2517
|
+
current = await untilAborted(resolveSafeOutboundTarget(new URL(location, current.url).toString(), urlOptions), controller.signal);
|
|
2518
|
+
redirectCount++;
|
|
2519
|
+
continue;
|
|
2387
2520
|
}
|
|
2388
|
-
current = await resolveSafeOutboundTarget(new URL(location, current.url).toString(), urlOptions);
|
|
2389
|
-
redirectCount += 1;
|
|
2390
|
-
continue;
|
|
2391
2521
|
}
|
|
2392
|
-
return response;
|
|
2522
|
+
return withBodyDeadline(response, controller.signal, release);
|
|
2393
2523
|
}
|
|
2394
|
-
}
|
|
2395
|
-
|
|
2524
|
+
} catch (error) {
|
|
2525
|
+
controller.abort(error);
|
|
2526
|
+
release();
|
|
2527
|
+
throw error;
|
|
2396
2528
|
}
|
|
2397
2529
|
}
|
|
2398
2530
|
|
|
@@ -3697,6 +3829,9 @@ class ConfigStore {
|
|
|
3697
3829
|
return this.values.has(key);
|
|
3698
3830
|
}
|
|
3699
3831
|
}
|
|
3832
|
+
// ../../src/core/database/baseRepository.ts
|
|
3833
|
+
init_eventBus();
|
|
3834
|
+
|
|
3700
3835
|
// ../../src/core/events/index.ts
|
|
3701
3836
|
init_deferredModelEvents();
|
|
3702
3837
|
init_eventBus();
|
|
@@ -3715,6 +3850,9 @@ function buildPaginationMeta(input) {
|
|
|
3715
3850
|
};
|
|
3716
3851
|
}
|
|
3717
3852
|
|
|
3853
|
+
// ../../src/core/database/baseRepository.ts
|
|
3854
|
+
init_connectionContext();
|
|
3855
|
+
|
|
3718
3856
|
// ../../src/core/database/query.ts
|
|
3719
3857
|
init_dialect();
|
|
3720
3858
|
function quoteIdentifier(identifier) {
|
|
@@ -4839,6 +4977,9 @@ class RepositoryQuery {
|
|
|
4839
4977
|
}
|
|
4840
4978
|
|
|
4841
4979
|
// ../../src/core/database/baseRepository.ts
|
|
4980
|
+
init_transaction();
|
|
4981
|
+
init_transactionControl();
|
|
4982
|
+
|
|
4842
4983
|
class BaseRepository {
|
|
4843
4984
|
table;
|
|
4844
4985
|
connection;
|
|
@@ -4846,6 +4987,17 @@ class BaseRepository {
|
|
|
4846
4987
|
this.table = table;
|
|
4847
4988
|
this.connection = connection;
|
|
4848
4989
|
}
|
|
4990
|
+
assertTransactionalEvent(action) {
|
|
4991
|
+
if (!eventBus.hasTransactionalListeners(modelEventName(this.table.name, action)))
|
|
4992
|
+
return;
|
|
4993
|
+
if (!hasActiveTransaction() || this.connection !== repositoryConnection && this.connection !== getActiveDatabaseConnection(repositoryConnection))
|
|
4994
|
+
throw new Error("Transactional model listeners require runInTransaction and the framework-bound repository connection before writing.");
|
|
4995
|
+
}
|
|
4996
|
+
async runModelMutation(action, operation) {
|
|
4997
|
+
if (!eventBus.hasTransactionalListeners(modelEventName(this.table.name, action)))
|
|
4998
|
+
return operation();
|
|
4999
|
+
return runInTransaction(operation);
|
|
5000
|
+
}
|
|
4849
5001
|
async count(options = {}) {
|
|
4850
5002
|
const { whereNodes, where, ...rest } = options;
|
|
4851
5003
|
return await this.countWhere(where ?? {}, rest, whereNodes ?? []);
|
|
@@ -5003,7 +5155,8 @@ class BaseRepository {
|
|
|
5003
5155
|
return await this.incrementById(id, column, -amount, extra);
|
|
5004
5156
|
}
|
|
5005
5157
|
async create(values) {
|
|
5006
|
-
|
|
5158
|
+
this.assertTransactionalEvent("created");
|
|
5159
|
+
return await this.runModelMutation("created", () => withDatabaseErrorHandling(async () => {
|
|
5007
5160
|
const { text, params } = buildInsertQuery(this.table, values);
|
|
5008
5161
|
const [record] = await this.connection.unsafe(text, params);
|
|
5009
5162
|
if (!record) {
|
|
@@ -5012,10 +5165,11 @@ class BaseRepository {
|
|
|
5012
5165
|
const entity = record;
|
|
5013
5166
|
await dispatchModelEvent(modelEventName(this.table.name, "created"), entity);
|
|
5014
5167
|
return entity;
|
|
5015
|
-
});
|
|
5168
|
+
}));
|
|
5016
5169
|
}
|
|
5017
5170
|
async updateById(id, changes) {
|
|
5018
|
-
|
|
5171
|
+
this.assertTransactionalEvent("updated");
|
|
5172
|
+
return await this.runModelMutation("updated", () => withDatabaseErrorHandling(async () => {
|
|
5019
5173
|
const { text, params } = buildUpdateQuery(this.table, id, changes);
|
|
5020
5174
|
const [record] = await this.connection.unsafe(text, params);
|
|
5021
5175
|
const entity = record ?? null;
|
|
@@ -5023,7 +5177,7 @@ class BaseRepository {
|
|
|
5023
5177
|
await dispatchModelEvent(modelEventName(this.table.name, "updated"), entity);
|
|
5024
5178
|
}
|
|
5025
5179
|
return entity;
|
|
5026
|
-
});
|
|
5180
|
+
}));
|
|
5027
5181
|
}
|
|
5028
5182
|
async updateByIdOrThrow(id, changes, errorFactory) {
|
|
5029
5183
|
const record = await this.updateById(id, changes);
|
|
@@ -5039,7 +5193,8 @@ class BaseRepository {
|
|
|
5039
5193
|
return await this.forceDeleteById(id);
|
|
5040
5194
|
}
|
|
5041
5195
|
async softDeleteById(id) {
|
|
5042
|
-
|
|
5196
|
+
this.assertTransactionalEvent("deleted");
|
|
5197
|
+
return await this.runModelMutation("deleted", () => withDatabaseErrorHandling(async () => {
|
|
5043
5198
|
const { text, params } = buildSoftDeleteByIdQuery(this.table, id, new Date);
|
|
5044
5199
|
const [record] = await this.connection.unsafe(text, params);
|
|
5045
5200
|
if (!record) {
|
|
@@ -5047,10 +5202,11 @@ class BaseRepository {
|
|
|
5047
5202
|
}
|
|
5048
5203
|
await dispatchModelEvent(modelEventName(this.table.name, "deleted"), record);
|
|
5049
5204
|
return true;
|
|
5050
|
-
});
|
|
5205
|
+
}));
|
|
5051
5206
|
}
|
|
5052
5207
|
async forceDeleteById(id) {
|
|
5053
|
-
|
|
5208
|
+
this.assertTransactionalEvent("force-deleted");
|
|
5209
|
+
return await this.runModelMutation("force-deleted", () => withDatabaseErrorHandling(async () => {
|
|
5054
5210
|
const { text, params } = buildDeleteByIdQuery(this.table, id);
|
|
5055
5211
|
const [row] = await this.connection.unsafe(text, params);
|
|
5056
5212
|
if (!row) {
|
|
@@ -5060,10 +5216,11 @@ class BaseRepository {
|
|
|
5060
5216
|
id
|
|
5061
5217
|
});
|
|
5062
5218
|
return true;
|
|
5063
|
-
});
|
|
5219
|
+
}));
|
|
5064
5220
|
}
|
|
5065
5221
|
async restoreById(id) {
|
|
5066
|
-
|
|
5222
|
+
this.assertTransactionalEvent("restored");
|
|
5223
|
+
return await this.runModelMutation("restored", () => withDatabaseErrorHandling(async () => {
|
|
5067
5224
|
const { text, params } = buildRestoreByIdQuery(this.table, id);
|
|
5068
5225
|
const [record] = await this.connection.unsafe(text, params);
|
|
5069
5226
|
if (!record) {
|
|
@@ -5072,7 +5229,7 @@ class BaseRepository {
|
|
|
5072
5229
|
const entity = record;
|
|
5073
5230
|
await dispatchModelEvent(modelEventName(this.table.name, "restored"), entity);
|
|
5074
5231
|
return entity;
|
|
5075
|
-
});
|
|
5232
|
+
}));
|
|
5076
5233
|
}
|
|
5077
5234
|
withConnection(connection) {
|
|
5078
5235
|
const clone = Object.create(Object.getPrototypeOf(this));
|
|
@@ -7269,6 +7426,7 @@ function registerModelRepository(model, repository) {
|
|
|
7269
7426
|
return model;
|
|
7270
7427
|
}
|
|
7271
7428
|
// ../../src/core/database/mysqlConnection.ts
|
|
7429
|
+
init_connectionContext();
|
|
7272
7430
|
var MYSQL_SESSION_UTC = "SET time_zone = '+00:00'";
|
|
7273
7431
|
var mysqlModule;
|
|
7274
7432
|
var mysqlPending;
|
|
@@ -7324,18 +7482,71 @@ function rowsFromResult(result) {
|
|
|
7324
7482
|
}
|
|
7325
7483
|
return [];
|
|
7326
7484
|
}
|
|
7327
|
-
function
|
|
7328
|
-
|
|
7485
|
+
async function executeMysql(target, query, params) {
|
|
7486
|
+
const [result] = params.length === 0 && target.query ? await target.query(query) : await target.execute(query, [...params]);
|
|
7487
|
+
return rowsFromResult(result);
|
|
7488
|
+
}
|
|
7489
|
+
function createMysqlAdapter(getPool, close) {
|
|
7490
|
+
const transactions = new WeakSet;
|
|
7491
|
+
const connection = {
|
|
7329
7492
|
async unsafe(query, params = []) {
|
|
7330
|
-
const
|
|
7331
|
-
|
|
7493
|
+
const active = getActiveDatabaseConnection(connection);
|
|
7494
|
+
if (transactions.has(active))
|
|
7495
|
+
return active.unsafe(query, params);
|
|
7496
|
+
return executeMysql(await getPool(), query, params);
|
|
7332
7497
|
},
|
|
7333
|
-
async
|
|
7334
|
-
if (
|
|
7335
|
-
|
|
7498
|
+
async begin(callback) {
|
|
7499
|
+
if (transactions.has(getActiveDatabaseConnection(connection))) {
|
|
7500
|
+
throw new Error("Use runInTransaction for nested MySQL transactions.");
|
|
7336
7501
|
}
|
|
7337
|
-
|
|
7502
|
+
const pool = await getPool();
|
|
7503
|
+
if (!pool.getConnection)
|
|
7504
|
+
throw new Error("MySQL transactions require a pool with getConnection().");
|
|
7505
|
+
const reserved = await pool.getConnection();
|
|
7506
|
+
let open = false;
|
|
7507
|
+
let discard = false;
|
|
7508
|
+
const transaction = {
|
|
7509
|
+
async unsafe(query, params = []) {
|
|
7510
|
+
if (!open)
|
|
7511
|
+
throw new Error("MySQL transaction is no longer active.");
|
|
7512
|
+
return executeMysql(reserved, query, params);
|
|
7513
|
+
}
|
|
7514
|
+
};
|
|
7515
|
+
transactions.add(transaction);
|
|
7516
|
+
try {
|
|
7517
|
+
await reserved.beginTransaction();
|
|
7518
|
+
open = true;
|
|
7519
|
+
const result = await runWithDatabaseConnection(transaction, () => callback(transaction));
|
|
7520
|
+
await reserved.commit();
|
|
7521
|
+
return result;
|
|
7522
|
+
} catch (error) {
|
|
7523
|
+
if (open) {
|
|
7524
|
+
try {
|
|
7525
|
+
await reserved.rollback();
|
|
7526
|
+
} catch (rollbackError) {
|
|
7527
|
+
discard = true;
|
|
7528
|
+
reserved.destroy();
|
|
7529
|
+
throw new AggregateError([error, rollbackError], "MySQL rollback failed; connection discarded.");
|
|
7530
|
+
}
|
|
7531
|
+
} else {
|
|
7532
|
+
discard = true;
|
|
7533
|
+
reserved.destroy();
|
|
7534
|
+
}
|
|
7535
|
+
throw error;
|
|
7536
|
+
} finally {
|
|
7537
|
+
open = false;
|
|
7538
|
+
if (!discard)
|
|
7539
|
+
reserved.release();
|
|
7540
|
+
}
|
|
7541
|
+
},
|
|
7542
|
+
close
|
|
7338
7543
|
};
|
|
7544
|
+
return connection;
|
|
7545
|
+
}
|
|
7546
|
+
function createMysqlConnectionFromPool(pool) {
|
|
7547
|
+
return createMysqlAdapter(async () => pool, async () => {
|
|
7548
|
+
await pool.end?.();
|
|
7549
|
+
});
|
|
7339
7550
|
}
|
|
7340
7551
|
function pinSessionToUtc(connection) {
|
|
7341
7552
|
connection.query(MYSQL_SESSION_UTC, (error) => {
|
|
@@ -7368,23 +7579,16 @@ function createMysqlConnection(url) {
|
|
|
7368
7579
|
}
|
|
7369
7580
|
return poolPending;
|
|
7370
7581
|
}
|
|
7371
|
-
return {
|
|
7372
|
-
|
|
7373
|
-
|
|
7374
|
-
|
|
7375
|
-
|
|
7376
|
-
|
|
7377
|
-
|
|
7378
|
-
|
|
7379
|
-
|
|
7380
|
-
|
|
7381
|
-
poolPending = undefined;
|
|
7382
|
-
const pool = await pending.catch(() => {
|
|
7383
|
-
return;
|
|
7384
|
-
});
|
|
7385
|
-
await pool?.end();
|
|
7386
|
-
}
|
|
7387
|
-
};
|
|
7582
|
+
return createMysqlAdapter(ensurePool, async () => {
|
|
7583
|
+
if (!poolPending)
|
|
7584
|
+
return;
|
|
7585
|
+
const pending = poolPending;
|
|
7586
|
+
poolPending = undefined;
|
|
7587
|
+
const pool = await pending.catch(() => {
|
|
7588
|
+
return;
|
|
7589
|
+
});
|
|
7590
|
+
await pool?.end();
|
|
7591
|
+
});
|
|
7388
7592
|
}
|
|
7389
7593
|
// ../../src/core/database/namedConnections.ts
|
|
7390
7594
|
init_connectionContext();
|
|
@@ -8046,6 +8250,7 @@ async function runSeedersFromDirectory(directory, db, options) {
|
|
|
8046
8250
|
return seeders.length;
|
|
8047
8251
|
}
|
|
8048
8252
|
// ../../src/core/database/sqliteConnection.ts
|
|
8253
|
+
init_connectionContext();
|
|
8049
8254
|
import { Database } from "bun:sqlite";
|
|
8050
8255
|
function isRowReturning(sql) {
|
|
8051
8256
|
const upper = sql.replace(/\s+/g, " ").trim().toUpperCase();
|
|
@@ -8065,20 +8270,70 @@ function createSqliteConnection(filename) {
|
|
|
8065
8270
|
db.exec("PRAGMA journal_mode = WAL");
|
|
8066
8271
|
db.exec("PRAGMA synchronous = NORMAL");
|
|
8067
8272
|
}
|
|
8068
|
-
|
|
8273
|
+
let tail = Promise.resolve();
|
|
8274
|
+
let pending = 0;
|
|
8275
|
+
let closed = false;
|
|
8276
|
+
const transactions = new WeakSet;
|
|
8277
|
+
function execute(query, params) {
|
|
8278
|
+
const statement = db.query(query);
|
|
8279
|
+
const args = [...params];
|
|
8280
|
+
if (isRowReturning(query))
|
|
8281
|
+
return statement.all(...args);
|
|
8282
|
+
statement.run(...args);
|
|
8283
|
+
return [];
|
|
8284
|
+
}
|
|
8285
|
+
function enqueue(operation) {
|
|
8286
|
+
if (closed)
|
|
8287
|
+
return Promise.reject(new Error("SQLite connection is closed."));
|
|
8288
|
+
pending++;
|
|
8289
|
+
const result = tail.then(operation).finally(() => {
|
|
8290
|
+
pending--;
|
|
8291
|
+
});
|
|
8292
|
+
tail = result.then(() => {}, () => {});
|
|
8293
|
+
return result;
|
|
8294
|
+
}
|
|
8295
|
+
const connection = {
|
|
8069
8296
|
async unsafe(query, params = []) {
|
|
8070
|
-
const
|
|
8071
|
-
|
|
8072
|
-
|
|
8073
|
-
|
|
8297
|
+
const active = getActiveDatabaseConnection(connection);
|
|
8298
|
+
if (transactions.has(active))
|
|
8299
|
+
return active.unsafe(query, params);
|
|
8300
|
+
return enqueue(() => execute(query, params));
|
|
8301
|
+
},
|
|
8302
|
+
async begin(callback) {
|
|
8303
|
+
if (transactions.has(getActiveDatabaseConnection(connection))) {
|
|
8304
|
+
throw new Error("Use runInTransaction for nested SQLite transactions.");
|
|
8074
8305
|
}
|
|
8075
|
-
|
|
8076
|
-
|
|
8306
|
+
return enqueue(async () => {
|
|
8307
|
+
db.exec("BEGIN IMMEDIATE");
|
|
8308
|
+
let open = true;
|
|
8309
|
+
const transaction = {
|
|
8310
|
+
async unsafe(query, params = []) {
|
|
8311
|
+
if (!open)
|
|
8312
|
+
throw new Error("SQLite transaction is no longer active.");
|
|
8313
|
+
return execute(query, params);
|
|
8314
|
+
}
|
|
8315
|
+
};
|
|
8316
|
+
transactions.add(transaction);
|
|
8317
|
+
try {
|
|
8318
|
+
const result = await runWithDatabaseConnection(transaction, () => callback(transaction));
|
|
8319
|
+
db.exec("COMMIT");
|
|
8320
|
+
return result;
|
|
8321
|
+
} catch (error) {
|
|
8322
|
+
db.exec("ROLLBACK");
|
|
8323
|
+
throw error;
|
|
8324
|
+
} finally {
|
|
8325
|
+
open = false;
|
|
8326
|
+
}
|
|
8327
|
+
});
|
|
8077
8328
|
},
|
|
8078
8329
|
close() {
|
|
8330
|
+
if (pending > 0)
|
|
8331
|
+
throw new Error("Drain SQLite operations before closing the connection.");
|
|
8332
|
+
closed = true;
|
|
8079
8333
|
db.close();
|
|
8080
8334
|
}
|
|
8081
8335
|
};
|
|
8336
|
+
return connection;
|
|
8082
8337
|
}
|
|
8083
8338
|
// ../../src/core/database/table.ts
|
|
8084
8339
|
function defineTable(definition) {
|
|
@@ -8089,6 +8344,337 @@ function defineTable(definition) {
|
|
|
8089
8344
|
init_transaction();
|
|
8090
8345
|
init_eventBus();
|
|
8091
8346
|
|
|
8347
|
+
// ../../src/core/events/outbox/index.ts
|
|
8348
|
+
init_dialect();
|
|
8349
|
+
init_repositoryConnection();
|
|
8350
|
+
init_transaction();
|
|
8351
|
+
init_databaseTenantContext();
|
|
8352
|
+
init_tenantContext();
|
|
8353
|
+
init_tenantDatabaseScope();
|
|
8354
|
+
|
|
8355
|
+
// ../../src/core/events/outbox/migration.ts
|
|
8356
|
+
function createOutboxMigration(name, driver, options = {}) {
|
|
8357
|
+
const identity = driver === "mysql" ? " CHARACTER SET ascii COLLATE ascii_bin" : "";
|
|
8358
|
+
const payloadType = driver === "mysql" ? "MEDIUMTEXT" : "TEXT";
|
|
8359
|
+
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)" : "";
|
|
8360
|
+
return {
|
|
8361
|
+
name,
|
|
8362
|
+
async up(db) {
|
|
8363
|
+
await db.unsafe(`CREATE TABLE IF NOT EXISTS strata_outbox_event (
|
|
8364
|
+
id VARCHAR(64)${identity} PRIMARY KEY, name VARCHAR(128)${identity} NOT NULL, version INTEGER NOT NULL CHECK (version > 0),
|
|
8365
|
+
tenant_id BIGINT, payload ${payloadType} NOT NULL, created_at BIGINT NOT NULL,
|
|
8366
|
+
publication_token VARCHAR(64)${identity} NOT NULL)`);
|
|
8367
|
+
await db.unsafe(`CREATE TABLE IF NOT EXISTS strata_outbox_delivery (
|
|
8368
|
+
event_id VARCHAR(64)${identity} NOT NULL,
|
|
8369
|
+
listener_name VARCHAR(128)${identity} NOT NULL, status VARCHAR(16) NOT NULL CHECK (status IN ('pending', 'processing', 'failed', 'completed')),
|
|
8370
|
+
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), max_attempts INTEGER NOT NULL CHECK (max_attempts > 0),
|
|
8371
|
+
available_at BIGINT NOT NULL, lease_token VARCHAR(64)${identity}, lease_until BIGINT,
|
|
8372
|
+
completed_at BIGINT, error_code VARCHAR(64), PRIMARY KEY (event_id, listener_name)${inlineIndex},
|
|
8373
|
+
FOREIGN KEY (event_id) REFERENCES strata_outbox_event(id) ON DELETE CASCADE)`);
|
|
8374
|
+
if (driver !== "mysql") {
|
|
8375
|
+
await db.unsafe("CREATE INDEX IF NOT EXISTS strata_outbox_due ON strata_outbox_delivery (status, available_at, event_id, listener_name)");
|
|
8376
|
+
await db.unsafe("CREATE INDEX IF NOT EXISTS strata_outbox_expired ON strata_outbox_delivery (status, lease_until, event_id, listener_name)");
|
|
8377
|
+
}
|
|
8378
|
+
if (driver === "pgsql" && options.rls) {
|
|
8379
|
+
await db.unsafe(`ALTER TABLE strata_outbox_event ENABLE ROW LEVEL SECURITY`);
|
|
8380
|
+
await db.unsafe(`ALTER TABLE strata_outbox_event FORCE ROW LEVEL SECURITY`);
|
|
8381
|
+
await db.unsafe(`DROP POLICY IF EXISTS strata_outbox_scope ON strata_outbox_event`);
|
|
8382
|
+
await db.unsafe(`CREATE POLICY strata_outbox_scope ON strata_outbox_event USING (
|
|
8383
|
+
current_setting('app.bypass_rls', true) = 'true' OR
|
|
8384
|
+
tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint OR
|
|
8385
|
+
(tenant_id IS NULL AND NULLIF(current_setting('app.tenant_id', true), '') IS NULL))`);
|
|
8386
|
+
await db.unsafe(`ALTER TABLE strata_outbox_delivery ENABLE ROW LEVEL SECURITY`);
|
|
8387
|
+
await db.unsafe(`ALTER TABLE strata_outbox_delivery FORCE ROW LEVEL SECURITY`);
|
|
8388
|
+
await db.unsafe(`DROP POLICY IF EXISTS strata_outbox_scope ON strata_outbox_delivery`);
|
|
8389
|
+
await db.unsafe(`CREATE POLICY strata_outbox_scope ON strata_outbox_delivery USING (
|
|
8390
|
+
current_setting('app.bypass_rls', true) = 'true' OR EXISTS (
|
|
8391
|
+
SELECT 1 FROM strata_outbox_event WHERE id = strata_outbox_delivery.event_id))`);
|
|
8392
|
+
}
|
|
8393
|
+
},
|
|
8394
|
+
async down(db) {
|
|
8395
|
+
const [row] = await db.unsafe("SELECT COUNT(*) AS pending FROM strata_outbox_delivery WHERE status <> 'completed'");
|
|
8396
|
+
if (Number(row?.pending) > 0)
|
|
8397
|
+
throw new Error("Cannot drop outbox tables while undelivered events remain.");
|
|
8398
|
+
await db.unsafe("DROP TABLE strata_outbox_delivery");
|
|
8399
|
+
await db.unsafe("DROP TABLE strata_outbox_event");
|
|
8400
|
+
}
|
|
8401
|
+
};
|
|
8402
|
+
}
|
|
8403
|
+
|
|
8404
|
+
// ../../src/core/events/outbox/index.ts
|
|
8405
|
+
function positiveInteger(value, name) {
|
|
8406
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
8407
|
+
throw new Error(`${name} must be a positive integer.`);
|
|
8408
|
+
return value;
|
|
8409
|
+
}
|
|
8410
|
+
function identifier(value, max) {
|
|
8411
|
+
if (!value || value.length > max || !/^[\w.:-]+$/.test(value))
|
|
8412
|
+
throw new Error("Invalid durable event/listener identifier.");
|
|
8413
|
+
return value;
|
|
8414
|
+
}
|
|
8415
|
+
function placeholders(count) {
|
|
8416
|
+
return Array.from({ length: count }, (_, index) => currentSqlDialect().placeholder(index + 1));
|
|
8417
|
+
}
|
|
8418
|
+
async function clock() {
|
|
8419
|
+
const [row] = await repositoryConnection.unsafe(`SELECT ${currentSqlDialect().epochMillisecondsExpression()} AS now_ms`);
|
|
8420
|
+
const now = Number(row?.now_ms);
|
|
8421
|
+
if (!Number.isSafeInteger(now))
|
|
8422
|
+
throw new Error("Database did not return a valid outbox clock.");
|
|
8423
|
+
return now;
|
|
8424
|
+
}
|
|
8425
|
+
async function privileged(work) {
|
|
8426
|
+
if (hasActiveTransaction())
|
|
8427
|
+
throw new Error("Outbox coordination must run outside business transactions.");
|
|
8428
|
+
for (let attempt = 0;; attempt++) {
|
|
8429
|
+
try {
|
|
8430
|
+
return await runWithMigrationBypass(() => runInTransaction(work));
|
|
8431
|
+
} catch (error) {
|
|
8432
|
+
const failure = error !== null && typeof error === "object" ? error : {};
|
|
8433
|
+
const code = String(failure.errno ?? failure.code);
|
|
8434
|
+
if (attempt >= 4 || !["40001", "40P01", "1213"].includes(code))
|
|
8435
|
+
throw error;
|
|
8436
|
+
await Bun.sleep(10 * (attempt + 1));
|
|
8437
|
+
}
|
|
8438
|
+
}
|
|
8439
|
+
}
|
|
8440
|
+
|
|
8441
|
+
class SqlOutbox {
|
|
8442
|
+
listeners = new Map;
|
|
8443
|
+
leaseMs;
|
|
8444
|
+
maxAttempts;
|
|
8445
|
+
retryDelayMs;
|
|
8446
|
+
maxPayloadBytes;
|
|
8447
|
+
tenantResolver;
|
|
8448
|
+
constructor(options) {
|
|
8449
|
+
this.leaseMs = positiveInteger(options.leaseMs ?? 60000, "leaseMs");
|
|
8450
|
+
if (this.leaseMs < 30)
|
|
8451
|
+
throw new Error("leaseMs must be at least 30 milliseconds.");
|
|
8452
|
+
this.maxAttempts = positiveInteger(options.maxAttempts ?? 5, "maxAttempts");
|
|
8453
|
+
this.retryDelayMs = positiveInteger(options.retryDelayMs ?? 1000, "retryDelayMs");
|
|
8454
|
+
this.maxPayloadBytes = positiveInteger(options.maxPayloadBytes ?? 65536, "maxPayloadBytes");
|
|
8455
|
+
this.tenantResolver = options.resolveTenant ?? resolveTenant;
|
|
8456
|
+
for (const listener of options.listeners) {
|
|
8457
|
+
identifier(listener.name, 128);
|
|
8458
|
+
identifier(listener.event, 128);
|
|
8459
|
+
if (this.listeners.has(listener.name))
|
|
8460
|
+
throw new Error("Durable listener names must be unique.");
|
|
8461
|
+
this.listeners.set(listener.name, listener);
|
|
8462
|
+
}
|
|
8463
|
+
}
|
|
8464
|
+
hasListener(name, event) {
|
|
8465
|
+
const listener = this.listeners.get(name);
|
|
8466
|
+
return listener !== undefined && (event === undefined || listener.event === event);
|
|
8467
|
+
}
|
|
8468
|
+
async publish(name, payload, options = {}) {
|
|
8469
|
+
if (!hasActiveTransaction())
|
|
8470
|
+
throw new Error("Durable publication requires an active business transaction.");
|
|
8471
|
+
return runInTransaction(() => this.persist(name, payload, options));
|
|
8472
|
+
}
|
|
8473
|
+
async persist(name, payload, options) {
|
|
8474
|
+
identifier(name, 128);
|
|
8475
|
+
const id = identifier(options.id ?? crypto.randomUUID(), 64);
|
|
8476
|
+
const version = positiveInteger(options.version ?? 1, "version");
|
|
8477
|
+
const serialized = JSON.stringify(payload);
|
|
8478
|
+
if (serialized === undefined || new TextEncoder().encode(serialized).byteLength > this.maxPayloadBytes)
|
|
8479
|
+
throw new Error("Invalid or oversized durable payload.");
|
|
8480
|
+
const listeners = [...this.listeners.values()].filter((listener) => listener.event === name);
|
|
8481
|
+
if (listeners.length === 0)
|
|
8482
|
+
throw new Error("No durable listeners registered for this event.");
|
|
8483
|
+
const tenantId = currentTenant()?.id ?? null;
|
|
8484
|
+
if (tenantId !== null)
|
|
8485
|
+
positiveInteger(tenantId, "tenantId");
|
|
8486
|
+
const token = crypto.randomUUID();
|
|
8487
|
+
const now = await clock();
|
|
8488
|
+
await repositoryConnection.unsafe(`INSERT INTO strata_outbox_event (id, name, version, tenant_id, payload, created_at, publication_token)
|
|
8489
|
+
VALUES (${placeholders(7).join(", ")})${currentSqlDialect().upsertSuffix(["id"], [])}`, [id, name, version, tenantId, serialized, now, token]);
|
|
8490
|
+
const [existing] = await repositoryConnection.unsafe(`SELECT * FROM strata_outbox_event WHERE id = ${placeholders(1)[0]}`, [id]);
|
|
8491
|
+
if (!existing || existing.name !== name || Number(existing.version) !== version || (existing.tenant_id === null ? null : Number(existing.tenant_id)) !== tenantId || existing.payload !== serialized)
|
|
8492
|
+
throw new Error("Conflicting durable event ID.");
|
|
8493
|
+
if (existing.publication_token !== token)
|
|
8494
|
+
return id;
|
|
8495
|
+
for (const listener of listeners) {
|
|
8496
|
+
await repositoryConnection.unsafe(`INSERT INTO strata_outbox_delivery (event_id, listener_name, status, max_attempts, available_at)
|
|
8497
|
+
VALUES (${placeholders(5).join(", ")})`, [id, listener.name, "pending", this.maxAttempts, now]);
|
|
8498
|
+
}
|
|
8499
|
+
return id;
|
|
8500
|
+
}
|
|
8501
|
+
async claim() {
|
|
8502
|
+
return privileged(async () => {
|
|
8503
|
+
const now = await clock();
|
|
8504
|
+
const p = placeholders(2);
|
|
8505
|
+
const columns = "event_id, listener_name, attempts, max_attempts";
|
|
8506
|
+
const [expired] = await repositoryConnection.unsafe(`SELECT ${columns} FROM strata_outbox_delivery WHERE status = 'processing' AND lease_until <= ${p[0]}
|
|
8507
|
+
ORDER BY lease_until, event_id, listener_name LIMIT 1${currentSqlDialect().rowLockClause(true)}`, [now]);
|
|
8508
|
+
const delivery = expired ?? (await repositoryConnection.unsafe(`SELECT ${columns} FROM strata_outbox_delivery WHERE status = 'pending' AND available_at <= ${p[0]}
|
|
8509
|
+
ORDER BY available_at, event_id, listener_name LIMIT 1${currentSqlDialect().rowLockClause(true)}`, [now]))[0];
|
|
8510
|
+
if (!delivery)
|
|
8511
|
+
return null;
|
|
8512
|
+
const [event] = await repositoryConnection.unsafe(`SELECT * FROM strata_outbox_event WHERE id = ${p[0]}`, [delivery.event_id]);
|
|
8513
|
+
if (!event)
|
|
8514
|
+
throw new Error("Outbox event missing for delivery.");
|
|
8515
|
+
const candidate = { ...event, ...delivery };
|
|
8516
|
+
const token = crypto.randomUUID();
|
|
8517
|
+
const exhausted = Number(candidate.attempts) >= Number(candidate.max_attempts);
|
|
8518
|
+
const q = placeholders(9);
|
|
8519
|
+
await repositoryConnection.unsafe(`UPDATE strata_outbox_delivery SET status = ${q[0]}, lease_token = ${q[1]}, lease_until = ${q[2]},
|
|
8520
|
+
attempts = ${q[3]}, error_code = ${exhausted ? "'attempts_exhausted'" : "error_code"} WHERE event_id = ${q[4]} AND listener_name = ${q[5]} AND
|
|
8521
|
+
((status = 'pending' AND available_at <= ${q[6]}) OR (status = 'processing' AND lease_until <= ${q[7]})) AND attempts = ${q[8]}`, [
|
|
8522
|
+
exhausted ? "failed" : "processing",
|
|
8523
|
+
token,
|
|
8524
|
+
exhausted ? null : now + this.leaseMs,
|
|
8525
|
+
Number(candidate.attempts) + (exhausted ? 0 : 1),
|
|
8526
|
+
candidate.id,
|
|
8527
|
+
candidate.listener_name,
|
|
8528
|
+
now,
|
|
8529
|
+
now,
|
|
8530
|
+
candidate.attempts
|
|
8531
|
+
]);
|
|
8532
|
+
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]);
|
|
8533
|
+
if (owner?.lease_token !== token)
|
|
8534
|
+
return null;
|
|
8535
|
+
if (exhausted)
|
|
8536
|
+
return "exhausted";
|
|
8537
|
+
return { ...candidate, attempts: Number(candidate.attempts) + 1, lease_token: token };
|
|
8538
|
+
});
|
|
8539
|
+
}
|
|
8540
|
+
async renew(claim) {
|
|
8541
|
+
return privileged(async () => {
|
|
8542
|
+
const now = await clock();
|
|
8543
|
+
const p = placeholders(5);
|
|
8544
|
+
await repositoryConnection.unsafe(`UPDATE strata_outbox_delivery SET lease_until = ${p[0]} WHERE event_id = ${p[1]}
|
|
8545
|
+
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]);
|
|
8546
|
+
const q = placeholders(3);
|
|
8547
|
+
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]);
|
|
8548
|
+
return Number(owner?.lease_until) > now;
|
|
8549
|
+
});
|
|
8550
|
+
}
|
|
8551
|
+
async finish(claim, success) {
|
|
8552
|
+
await privileged(async () => {
|
|
8553
|
+
const now = await clock();
|
|
8554
|
+
const status = success ? "completed" : claim.attempts >= Number(claim.max_attempts) ? "failed" : "pending";
|
|
8555
|
+
const delay = Math.min(this.retryDelayMs * 2 ** Math.min(claim.attempts - 1, 16), 300000);
|
|
8556
|
+
const p = placeholders(9);
|
|
8557
|
+
await repositoryConnection.unsafe(`UPDATE strata_outbox_delivery SET status = ${p[0]}, available_at = ${p[1]}, completed_at = ${p[2]},
|
|
8558
|
+
error_code = ${p[3]}, lease_until = NULL WHERE event_id = ${p[4]} AND listener_name = ${p[5]}
|
|
8559
|
+
AND lease_token = ${p[6]} AND status = 'processing' AND lease_until > ${p[7]} AND attempts = ${p[8]}`, [
|
|
8560
|
+
status,
|
|
8561
|
+
now + delay,
|
|
8562
|
+
success ? now : null,
|
|
8563
|
+
success ? null : "listener_failed",
|
|
8564
|
+
claim.id,
|
|
8565
|
+
claim.listener_name,
|
|
8566
|
+
claim.lease_token,
|
|
8567
|
+
now,
|
|
8568
|
+
claim.attempts
|
|
8569
|
+
]);
|
|
8570
|
+
});
|
|
8571
|
+
}
|
|
8572
|
+
async processNext(options = {}) {
|
|
8573
|
+
if (hasActiveTransaction())
|
|
8574
|
+
throw new Error("Outbox workers must run outside business transactions.");
|
|
8575
|
+
options.signal?.throwIfAborted();
|
|
8576
|
+
const claim = await this.claim();
|
|
8577
|
+
if (!claim)
|
|
8578
|
+
return false;
|
|
8579
|
+
if (claim === "exhausted")
|
|
8580
|
+
return true;
|
|
8581
|
+
const controller = new AbortController;
|
|
8582
|
+
const forward = () => controller.abort(options.signal?.reason);
|
|
8583
|
+
options.signal?.addEventListener("abort", forward, { once: true });
|
|
8584
|
+
if (options.signal?.aborted)
|
|
8585
|
+
forward();
|
|
8586
|
+
let renewal;
|
|
8587
|
+
let watchdog = setTimeout(() => controller.abort(new Error("Outbox lease expired.")), this.leaseMs);
|
|
8588
|
+
const heartbeat = setInterval(() => {
|
|
8589
|
+
if (renewal || controller.signal.aborted)
|
|
8590
|
+
return;
|
|
8591
|
+
renewal = (async () => {
|
|
8592
|
+
try {
|
|
8593
|
+
if (!await this.renew(claim))
|
|
8594
|
+
controller.abort(new Error("Outbox ownership lost."));
|
|
8595
|
+
else {
|
|
8596
|
+
clearTimeout(watchdog);
|
|
8597
|
+
watchdog = setTimeout(() => controller.abort(new Error("Outbox lease expired.")), this.leaseMs);
|
|
8598
|
+
}
|
|
8599
|
+
} catch {
|
|
8600
|
+
controller.abort(new Error("Outbox lease renewal failed."));
|
|
8601
|
+
} finally {
|
|
8602
|
+
renewal = undefined;
|
|
8603
|
+
}
|
|
8604
|
+
})();
|
|
8605
|
+
}, Math.max(10, Math.floor(this.leaseMs / 3)));
|
|
8606
|
+
let success = false;
|
|
8607
|
+
try {
|
|
8608
|
+
const listener = this.listeners.get(claim.listener_name);
|
|
8609
|
+
if (!listener || listener.event !== claim.name)
|
|
8610
|
+
throw new Error("Durable listener is not registered.");
|
|
8611
|
+
const event = {
|
|
8612
|
+
id: claim.id,
|
|
8613
|
+
name: claim.name,
|
|
8614
|
+
version: Number(claim.version),
|
|
8615
|
+
tenantId: claim.tenant_id === null ? null : Number(claim.tenant_id),
|
|
8616
|
+
payload: JSON.parse(claim.payload)
|
|
8617
|
+
};
|
|
8618
|
+
const tenant = event.tenantId === null ? null : await privileged(() => this.tenantResolver(Number(event.tenantId)));
|
|
8619
|
+
if (event.tenantId !== null && (!tenant || tenant.id !== event.tenantId))
|
|
8620
|
+
throw new Error("Outbox tenant could not be resolved.");
|
|
8621
|
+
const handle = async () => {
|
|
8622
|
+
controller.signal.throwIfAborted();
|
|
8623
|
+
await listener.handle(event, { signal: controller.signal });
|
|
8624
|
+
controller.signal.throwIfAborted();
|
|
8625
|
+
};
|
|
8626
|
+
if (tenant)
|
|
8627
|
+
await runWithTenantDatabase(tenant, handle);
|
|
8628
|
+
else
|
|
8629
|
+
await handle();
|
|
8630
|
+
success = true;
|
|
8631
|
+
} catch {
|
|
8632
|
+
success = false;
|
|
8633
|
+
} finally {
|
|
8634
|
+
clearInterval(heartbeat);
|
|
8635
|
+
await renewal;
|
|
8636
|
+
clearTimeout(watchdog);
|
|
8637
|
+
options.signal?.removeEventListener("abort", forward);
|
|
8638
|
+
}
|
|
8639
|
+
await this.finish(claim, success && !controller.signal.aborted);
|
|
8640
|
+
return true;
|
|
8641
|
+
}
|
|
8642
|
+
async work(options) {
|
|
8643
|
+
const pollMs = positiveInteger(options.pollMs ?? 1000, "pollMs");
|
|
8644
|
+
while (!options.signal.aborted) {
|
|
8645
|
+
try {
|
|
8646
|
+
if (await this.processNext({ signal: options.signal }))
|
|
8647
|
+
continue;
|
|
8648
|
+
} catch (error) {
|
|
8649
|
+
if (options.signal.aborted)
|
|
8650
|
+
return;
|
|
8651
|
+
throw error;
|
|
8652
|
+
}
|
|
8653
|
+
await new Promise((resolve) => {
|
|
8654
|
+
const done = () => {
|
|
8655
|
+
clearTimeout(timer);
|
|
8656
|
+
options.signal.removeEventListener("abort", done);
|
|
8657
|
+
resolve();
|
|
8658
|
+
};
|
|
8659
|
+
const timer = setTimeout(done, pollMs);
|
|
8660
|
+
options.signal.addEventListener("abort", done, { once: true });
|
|
8661
|
+
if (options.signal.aborted)
|
|
8662
|
+
done();
|
|
8663
|
+
});
|
|
8664
|
+
}
|
|
8665
|
+
}
|
|
8666
|
+
async replay(eventId, listenerName) {
|
|
8667
|
+
identifier(eventId, 64);
|
|
8668
|
+
identifier(listenerName, 128);
|
|
8669
|
+
await privileged(async () => {
|
|
8670
|
+
const now = await clock();
|
|
8671
|
+
const p = placeholders(3);
|
|
8672
|
+
await repositoryConnection.unsafe(`UPDATE strata_outbox_delivery SET status = 'pending', attempts = 0, available_at = ${p[0]},
|
|
8673
|
+
lease_token = NULL, lease_until = NULL, completed_at = NULL, error_code = NULL
|
|
8674
|
+
WHERE event_id = ${p[1]} AND listener_name = ${p[2]} AND status = 'failed'`, [now, eventId, listenerName]);
|
|
8675
|
+
});
|
|
8676
|
+
}
|
|
8677
|
+
}
|
|
8092
8678
|
// ../../src/core/mail/mailer.ts
|
|
8093
8679
|
function resolveSmtpConfig() {
|
|
8094
8680
|
const host = process.env.MAIL_HOST?.trim();
|
|
@@ -9895,7 +10481,7 @@ function logServerError(error, mappedError) {
|
|
|
9895
10481
|
});
|
|
9896
10482
|
}
|
|
9897
10483
|
async function webErrorResponse(error, request) {
|
|
9898
|
-
if (!request || !isViewsEnabled() || requestPrefersJson(request)) {
|
|
10484
|
+
if (currentRequestMeta().errorFormat === "json" || !request || !isViewsEnabled() || requestPrefersJson(request)) {
|
|
9899
10485
|
return null;
|
|
9900
10486
|
}
|
|
9901
10487
|
const mappedError = toHttpError(error) ?? mapDatabaseError(error);
|
|
@@ -12408,6 +12994,7 @@ export {
|
|
|
12408
12994
|
Schedule,
|
|
12409
12995
|
Schema,
|
|
12410
12996
|
ServiceContainer,
|
|
12997
|
+
SqlOutbox,
|
|
12411
12998
|
SqliteGrammar,
|
|
12412
12999
|
StorageManager,
|
|
12413
13000
|
SyncQueue,
|
|
@@ -12502,6 +13089,7 @@ export {
|
|
|
12502
13089
|
createMysqlPool,
|
|
12503
13090
|
createNotificationDispatcher,
|
|
12504
13091
|
createOidcHandshake,
|
|
13092
|
+
createOutboxMigration,
|
|
12505
13093
|
createProductionQueue,
|
|
12506
13094
|
createQueue,
|
|
12507
13095
|
createQueueWorker,
|
|
@@ -12567,6 +13155,7 @@ export {
|
|
|
12567
13155
|
grammarForDriver,
|
|
12568
13156
|
guestCanViewResource,
|
|
12569
13157
|
hasActiveDatabaseConnection,
|
|
13158
|
+
hasActiveTransaction,
|
|
12570
13159
|
hasMany,
|
|
12571
13160
|
hasManyThrough,
|
|
12572
13161
|
hasMinimumOrgRole2 as hasMinimumOrgRole,
|