@getstrata/core 2.2.8 → 2.2.10
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 +12 -0
- package/dist/core/database/acquisitionMetrics.d.ts +23 -0
- package/dist/core/database/baseRepository.d.ts +6 -0
- package/dist/core/database/postgresReadOnlyCollector.d.ts +7 -0
- package/dist/core/database/transaction.d.ts +11 -1
- package/dist/core/queue/failedJobMetrics.d.ts +17 -0
- package/dist/core/queue/queueMetrics.d.ts +4 -0
- package/dist/core/tracing/health.d.ts +27 -0
- package/dist/core/tracing/otel.d.ts +2 -0
- package/dist/core/tracing/tracingMiddleware.d.ts +2 -0
- package/dist/entries/audit/exportAuditLogs.js +80 -5
- package/dist/entries/auth/scimAuthMiddleware.js +80 -5
- package/dist/entries/http/response.js +3 -0
- package/dist/entries/jobs/exportAuditLogsJob.js +80 -5
- package/dist/entries/queue/queueMetrics.js +1018 -7
- package/dist/entries/tenant/databaseTenantContext.js +80 -5
- package/dist/entries/tenant/tenantDatabaseScope.js +80 -5
- package/dist/entries/tracing/tracingMiddleware.js +188 -5
- package/dist/framework/public-api.d.ts +7 -0
- package/dist/index.js +474 -98
- package/package.json +2 -1
|
@@ -1,4 +1,877 @@
|
|
|
1
1
|
// @bun
|
|
2
|
+
var __esm = (fn, res, err) => () => {
|
|
3
|
+
if (fn)
|
|
4
|
+
try {
|
|
5
|
+
res = fn(fn = 0);
|
|
6
|
+
} catch (e) {
|
|
7
|
+
err = [e];
|
|
8
|
+
}
|
|
9
|
+
if (err)
|
|
10
|
+
throw err[0];
|
|
11
|
+
return res;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// ../../src/core/runtime/asyncContextStore.ts
|
|
15
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
16
|
+
function createAsyncContextStore(key) {
|
|
17
|
+
const symbol = Symbol.for(key);
|
|
18
|
+
const globalRecord = globalThis;
|
|
19
|
+
const existing = globalRecord[symbol];
|
|
20
|
+
if (existing) {
|
|
21
|
+
return existing;
|
|
22
|
+
}
|
|
23
|
+
const store = new AsyncLocalStorage;
|
|
24
|
+
globalRecord[symbol] = store;
|
|
25
|
+
return store;
|
|
26
|
+
}
|
|
27
|
+
var init_asyncContextStore = () => {};
|
|
28
|
+
|
|
29
|
+
// ../../src/core/database/connectionContext.ts
|
|
30
|
+
function runWithDatabaseConnection(connection, callback) {
|
|
31
|
+
return activeConnection.run(connection, callback);
|
|
32
|
+
}
|
|
33
|
+
function getActiveDatabaseConnection(fallback) {
|
|
34
|
+
return activeConnection.getStore() ?? fallback;
|
|
35
|
+
}
|
|
36
|
+
function hasActiveDatabaseConnection() {
|
|
37
|
+
return activeConnection.getStore() !== undefined;
|
|
38
|
+
}
|
|
39
|
+
var activeConnection;
|
|
40
|
+
var init_connectionContext = __esm(() => {
|
|
41
|
+
init_asyncContextStore();
|
|
42
|
+
activeConnection = createAsyncContextStore("@getstrata/databaseConnectionContext");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ../../src/core/database/schema/driver.ts
|
|
46
|
+
function normalizeConnectionName(connection) {
|
|
47
|
+
const normalized = connection.trim().toLowerCase();
|
|
48
|
+
if (normalized === "pgsql" || normalized === "postgres" || normalized === "postgresql") {
|
|
49
|
+
return "pgsql";
|
|
50
|
+
}
|
|
51
|
+
if (normalized === "mysql" || normalized === "mariadb") {
|
|
52
|
+
return "mysql";
|
|
53
|
+
}
|
|
54
|
+
if (normalized === "sqlite") {
|
|
55
|
+
return "sqlite";
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`Unsupported DB_CONNECTION: ${connection}`);
|
|
58
|
+
}
|
|
59
|
+
function resolveDriverFromUrl(url) {
|
|
60
|
+
const normalized = url.trim().toLowerCase();
|
|
61
|
+
if (normalized.startsWith("postgres://") || normalized.startsWith("postgresql://") || normalized.startsWith("postgres:")) {
|
|
62
|
+
return "pgsql";
|
|
63
|
+
}
|
|
64
|
+
if (normalized.startsWith("mysql://") || normalized.startsWith("mysql:")) {
|
|
65
|
+
return "mysql";
|
|
66
|
+
}
|
|
67
|
+
if (normalized.startsWith("sqlite:")) {
|
|
68
|
+
return "sqlite";
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
function resolveDatabaseDriver(options = {}) {
|
|
73
|
+
const connection = options.connection ?? process.env.DB_CONNECTION;
|
|
74
|
+
if (connection) {
|
|
75
|
+
return normalizeConnectionName(connection);
|
|
76
|
+
}
|
|
77
|
+
const url = options.url ?? process.env.DATABASE_URL ?? "";
|
|
78
|
+
const fromUrl = resolveDriverFromUrl(url);
|
|
79
|
+
if (fromUrl) {
|
|
80
|
+
return fromUrl;
|
|
81
|
+
}
|
|
82
|
+
return "pgsql";
|
|
83
|
+
}
|
|
84
|
+
var init_driver = () => {};
|
|
85
|
+
|
|
86
|
+
// ../../src/core/database/dialect.ts
|
|
87
|
+
function assertSafeIdentifier(identifier) {
|
|
88
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
|
|
89
|
+
throw new Error(`Invalid SQL identifier: ${identifier}`);
|
|
90
|
+
}
|
|
91
|
+
return identifier;
|
|
92
|
+
}
|
|
93
|
+
function quoteIdentifierFor(dialect, column) {
|
|
94
|
+
return dialect.quoteIdentifier(column);
|
|
95
|
+
}
|
|
96
|
+
function dialectFor(driver) {
|
|
97
|
+
return dialects[driver];
|
|
98
|
+
}
|
|
99
|
+
function currentSqlDialect() {
|
|
100
|
+
return dialectContext.getStore() ?? dialectOverride ?? dialectFor(resolveDatabaseDriver());
|
|
101
|
+
}
|
|
102
|
+
var postgresDialect, mysqlDialect, sqliteDialect, dialects, dialectContext, dialectOverride = null;
|
|
103
|
+
var init_dialect = __esm(() => {
|
|
104
|
+
init_asyncContextStore();
|
|
105
|
+
init_driver();
|
|
106
|
+
postgresDialect = {
|
|
107
|
+
driver: "pgsql",
|
|
108
|
+
placeholder(index) {
|
|
109
|
+
return `$${index}`;
|
|
110
|
+
},
|
|
111
|
+
quoteIdentifier(identifier) {
|
|
112
|
+
return `"${assertSafeIdentifier(identifier)}"`;
|
|
113
|
+
},
|
|
114
|
+
nowExpression() {
|
|
115
|
+
return "NOW()";
|
|
116
|
+
},
|
|
117
|
+
epochMillisecondsExpression() {
|
|
118
|
+
return "CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)";
|
|
119
|
+
},
|
|
120
|
+
rowLockClause(skipLocked = false) {
|
|
121
|
+
return ` FOR UPDATE${skipLocked ? " SKIP LOCKED" : ""}`;
|
|
122
|
+
},
|
|
123
|
+
timestampValue(value) {
|
|
124
|
+
return value.toISOString();
|
|
125
|
+
},
|
|
126
|
+
returningClause(columns) {
|
|
127
|
+
return ` RETURNING ${columns}`;
|
|
128
|
+
},
|
|
129
|
+
ilikeOperator() {
|
|
130
|
+
return "ILIKE";
|
|
131
|
+
},
|
|
132
|
+
nullsLastSuffix() {
|
|
133
|
+
return " NULLS LAST";
|
|
134
|
+
},
|
|
135
|
+
castToText(expression) {
|
|
136
|
+
return `${expression}::text`;
|
|
137
|
+
},
|
|
138
|
+
upsertSuffix(conflictColumns, updateColumns) {
|
|
139
|
+
const target = conflictColumns.map((column) => quoteIdentifierFor(this, column)).join(", ");
|
|
140
|
+
if (updateColumns.length === 0) {
|
|
141
|
+
return ` ON CONFLICT (${target}) DO NOTHING`;
|
|
142
|
+
}
|
|
143
|
+
const assignments = updateColumns.map((column) => {
|
|
144
|
+
const quoted = quoteIdentifierFor(this, column);
|
|
145
|
+
return `${quoted} = excluded.${quoted}`;
|
|
146
|
+
}).join(", ");
|
|
147
|
+
return ` ON CONFLICT (${target}) DO UPDATE SET ${assignments}`;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
mysqlDialect = {
|
|
151
|
+
driver: "mysql",
|
|
152
|
+
placeholder() {
|
|
153
|
+
return "?";
|
|
154
|
+
},
|
|
155
|
+
quoteIdentifier(identifier) {
|
|
156
|
+
return `\`${assertSafeIdentifier(identifier)}\``;
|
|
157
|
+
},
|
|
158
|
+
nowExpression() {
|
|
159
|
+
return "CURRENT_TIMESTAMP";
|
|
160
|
+
},
|
|
161
|
+
epochMillisecondsExpression() {
|
|
162
|
+
return "CAST(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 AS UNSIGNED)";
|
|
163
|
+
},
|
|
164
|
+
rowLockClause(skipLocked = false) {
|
|
165
|
+
return ` FOR UPDATE${skipLocked ? " SKIP LOCKED" : ""}`;
|
|
166
|
+
},
|
|
167
|
+
timestampValue(value) {
|
|
168
|
+
return value.toISOString().slice(0, 19).replace("T", " ");
|
|
169
|
+
},
|
|
170
|
+
returningClause() {
|
|
171
|
+
return "";
|
|
172
|
+
},
|
|
173
|
+
ilikeOperator() {
|
|
174
|
+
return "LIKE";
|
|
175
|
+
},
|
|
176
|
+
nullsLastSuffix() {
|
|
177
|
+
return "";
|
|
178
|
+
},
|
|
179
|
+
castToText(expression) {
|
|
180
|
+
return `CAST(${expression} AS CHAR)`;
|
|
181
|
+
},
|
|
182
|
+
upsertSuffix(conflictColumns, updateColumns) {
|
|
183
|
+
if (updateColumns.length === 0) {
|
|
184
|
+
const anchor = quoteIdentifierFor(this, conflictColumns[0] ?? "");
|
|
185
|
+
return ` ON DUPLICATE KEY UPDATE ${anchor} = ${anchor}`;
|
|
186
|
+
}
|
|
187
|
+
const assignments = updateColumns.map((column) => {
|
|
188
|
+
const quoted = quoteIdentifierFor(this, column);
|
|
189
|
+
return `${quoted} = VALUES(${quoted})`;
|
|
190
|
+
}).join(", ");
|
|
191
|
+
return ` ON DUPLICATE KEY UPDATE ${assignments}`;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
sqliteDialect = {
|
|
195
|
+
driver: "sqlite",
|
|
196
|
+
placeholder() {
|
|
197
|
+
return "?";
|
|
198
|
+
},
|
|
199
|
+
quoteIdentifier(identifier) {
|
|
200
|
+
return `"${assertSafeIdentifier(identifier)}"`;
|
|
201
|
+
},
|
|
202
|
+
nowExpression() {
|
|
203
|
+
return "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
|
|
204
|
+
},
|
|
205
|
+
epochMillisecondsExpression() {
|
|
206
|
+
return "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
|
|
207
|
+
},
|
|
208
|
+
rowLockClause() {
|
|
209
|
+
return "";
|
|
210
|
+
},
|
|
211
|
+
timestampValue(value) {
|
|
212
|
+
return value.toISOString();
|
|
213
|
+
},
|
|
214
|
+
returningClause(columns) {
|
|
215
|
+
return ` RETURNING ${columns}`;
|
|
216
|
+
},
|
|
217
|
+
ilikeOperator() {
|
|
218
|
+
return "LIKE";
|
|
219
|
+
},
|
|
220
|
+
nullsLastSuffix() {
|
|
221
|
+
return "";
|
|
222
|
+
},
|
|
223
|
+
castToText(expression) {
|
|
224
|
+
return `CAST(${expression} AS TEXT)`;
|
|
225
|
+
},
|
|
226
|
+
upsertSuffix(conflictColumns, updateColumns) {
|
|
227
|
+
const target = conflictColumns.map((column) => quoteIdentifierFor(this, column)).join(", ");
|
|
228
|
+
if (updateColumns.length === 0) {
|
|
229
|
+
return ` ON CONFLICT (${target}) DO NOTHING`;
|
|
230
|
+
}
|
|
231
|
+
const assignments = updateColumns.map((column) => {
|
|
232
|
+
const quoted = quoteIdentifierFor(this, column);
|
|
233
|
+
return `${quoted} = excluded.${quoted}`;
|
|
234
|
+
}).join(", ");
|
|
235
|
+
return ` ON CONFLICT (${target}) DO UPDATE SET ${assignments}`;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
dialects = {
|
|
239
|
+
pgsql: postgresDialect,
|
|
240
|
+
mysql: mysqlDialect,
|
|
241
|
+
sqlite: sqliteDialect
|
|
242
|
+
};
|
|
243
|
+
dialectContext = createAsyncContextStore("@getstrata/sqlDialect");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ../../src/core/database/boundConnection.ts
|
|
247
|
+
function boundConnectionState() {
|
|
248
|
+
const globalRecord = globalThis;
|
|
249
|
+
const existing = globalRecord[BOUND_CONNECTION_KEY];
|
|
250
|
+
if (existing) {
|
|
251
|
+
return existing;
|
|
252
|
+
}
|
|
253
|
+
const created = { connection: null };
|
|
254
|
+
globalRecord[BOUND_CONNECTION_KEY] = created;
|
|
255
|
+
return created;
|
|
256
|
+
}
|
|
257
|
+
function getBoundDatabaseConnection() {
|
|
258
|
+
return boundConnectionState().connection;
|
|
259
|
+
}
|
|
260
|
+
var BOUND_CONNECTION_KEY;
|
|
261
|
+
var init_boundConnection = __esm(() => {
|
|
262
|
+
BOUND_CONNECTION_KEY = Symbol.for("@getstrata/boundDatabaseConnection");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ../../src/core/database/queryProxy.ts
|
|
266
|
+
var POOL_CONNECTION_METHODS;
|
|
267
|
+
var init_queryProxy = __esm(() => {
|
|
268
|
+
init_connectionContext();
|
|
269
|
+
POOL_CONNECTION_METHODS = new Set(["begin", "close", "connect"]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ../../src/core/database/defaultConnection.ts
|
|
273
|
+
function defaultConnectionState() {
|
|
274
|
+
const globalRecord = globalThis;
|
|
275
|
+
const existing = globalRecord[DEFAULT_CONNECTION_KEY];
|
|
276
|
+
if (existing) {
|
|
277
|
+
return existing;
|
|
278
|
+
}
|
|
279
|
+
const created = { pool: null, query: null };
|
|
280
|
+
globalRecord[DEFAULT_CONNECTION_KEY] = created;
|
|
281
|
+
return created;
|
|
282
|
+
}
|
|
283
|
+
function getDefaultDatabasePool() {
|
|
284
|
+
const pool = defaultConnectionState().pool;
|
|
285
|
+
if (!pool) {
|
|
286
|
+
throw new Error("Default database pool is not registered. Call registerDefaultDatabasePool() during app bootstrap.");
|
|
287
|
+
}
|
|
288
|
+
return pool;
|
|
289
|
+
}
|
|
290
|
+
function getDefaultDatabaseQuery() {
|
|
291
|
+
const query = defaultConnectionState().query;
|
|
292
|
+
if (!query) {
|
|
293
|
+
throw new Error("Default database query handle is not registered. Call registerDefaultDatabasePool() during app bootstrap.");
|
|
294
|
+
}
|
|
295
|
+
return query;
|
|
296
|
+
}
|
|
297
|
+
var DEFAULT_CONNECTION_KEY;
|
|
298
|
+
var init_defaultConnection = __esm(() => {
|
|
299
|
+
init_queryProxy();
|
|
300
|
+
DEFAULT_CONNECTION_KEY = Symbol.for("@getstrata/defaultDatabaseConnection");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ../../src/core/database/repositoryConnection.ts
|
|
304
|
+
function resolveRepositoryConnection() {
|
|
305
|
+
return getActiveDatabaseConnection(getBoundDatabaseConnection() ?? getDefaultDatabaseQuery());
|
|
306
|
+
}
|
|
307
|
+
function compileTaggedSql(strings, values) {
|
|
308
|
+
const dialect = currentSqlDialect();
|
|
309
|
+
let query = strings[0] ?? "";
|
|
310
|
+
const params = [];
|
|
311
|
+
for (let index = 0;index < values.length; index += 1) {
|
|
312
|
+
params.push(values[index]);
|
|
313
|
+
query += `${dialect.placeholder(params.length)}${strings[index + 1] ?? ""}`;
|
|
314
|
+
}
|
|
315
|
+
return { query, params };
|
|
316
|
+
}
|
|
317
|
+
function isTaggedStrings(value) {
|
|
318
|
+
if (!Array.isArray(value) || typeof value[0] !== "string") {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
return typeof value.raw !== "undefined";
|
|
322
|
+
}
|
|
323
|
+
function runOnConnection(connection, args) {
|
|
324
|
+
if (typeof connection === "function") {
|
|
325
|
+
return connection(...args);
|
|
326
|
+
}
|
|
327
|
+
if (typeof connection.unsafe !== "function") {
|
|
328
|
+
throw new Error("Database connection cannot run SQL.");
|
|
329
|
+
}
|
|
330
|
+
const strings = args[0];
|
|
331
|
+
if (!isTaggedStrings(strings)) {
|
|
332
|
+
throw new Error("Database connection cannot run tagged SQL.");
|
|
333
|
+
}
|
|
334
|
+
const compiled = compileTaggedSql(strings, args.slice(1));
|
|
335
|
+
return connection.unsafe(compiled.query, compiled.params);
|
|
336
|
+
}
|
|
337
|
+
var repositoryConnection;
|
|
338
|
+
var init_repositoryConnection = __esm(() => {
|
|
339
|
+
init_boundConnection();
|
|
340
|
+
init_connectionContext();
|
|
341
|
+
init_defaultConnection();
|
|
342
|
+
init_dialect();
|
|
343
|
+
repositoryConnection = new Proxy(function repositoryConnection() {}, {
|
|
344
|
+
apply(_target, _thisArg, args) {
|
|
345
|
+
return runOnConnection(resolveRepositoryConnection(), args);
|
|
346
|
+
},
|
|
347
|
+
get(_target, property) {
|
|
348
|
+
const connection = resolveRepositoryConnection();
|
|
349
|
+
const value = connection[property];
|
|
350
|
+
return typeof value === "function" ? value.bind(connection) : value;
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// ../../src/core/tenant/tenantContext.ts
|
|
356
|
+
function runWithTenant(tenant, callback) {
|
|
357
|
+
return tenantContext.run(tenant, callback);
|
|
358
|
+
}
|
|
359
|
+
function currentTenant() {
|
|
360
|
+
return tenantContext.getStore() ?? null;
|
|
361
|
+
}
|
|
362
|
+
var tenantContext;
|
|
363
|
+
var init_tenantContext = __esm(() => {
|
|
364
|
+
init_asyncContextStore();
|
|
365
|
+
tenantContext = createAsyncContextStore("@getstrata/tenantContext");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// ../../src/core/events/eventBus.ts
|
|
369
|
+
class EventBus {
|
|
370
|
+
constructor() {}
|
|
371
|
+
transactionalListeners = new Map;
|
|
372
|
+
listenTransactional(event, listener) {
|
|
373
|
+
const handlers = this.transactionalListeners.get(event) ?? new Set;
|
|
374
|
+
handlers.add(listener);
|
|
375
|
+
this.transactionalListeners.set(event, handlers);
|
|
376
|
+
return () => {
|
|
377
|
+
handlers.delete(listener);
|
|
378
|
+
if (!handlers.size)
|
|
379
|
+
this.transactionalListeners.delete(event);
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
hasTransactionalListeners(event) {
|
|
383
|
+
return (this.transactionalListeners.get(event)?.size ?? 0) > 0;
|
|
384
|
+
}
|
|
385
|
+
async dispatchTransactional(event, payload) {
|
|
386
|
+
for (const listener of this.transactionalListeners.get(event) ?? [])
|
|
387
|
+
await listener(payload);
|
|
388
|
+
}
|
|
389
|
+
listeners = new Map;
|
|
390
|
+
on(event, listener) {
|
|
391
|
+
return this.listen(event, listener);
|
|
392
|
+
}
|
|
393
|
+
listen(event, listener) {
|
|
394
|
+
const handlers = this.listeners.get(event) ?? new Set;
|
|
395
|
+
handlers.add(listener);
|
|
396
|
+
this.listeners.set(event, handlers);
|
|
397
|
+
return () => {
|
|
398
|
+
handlers.delete(listener);
|
|
399
|
+
if (handlers.size === 0) {
|
|
400
|
+
this.listeners.delete(event);
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
async emit(event, payload) {
|
|
405
|
+
await this.dispatch(event, payload);
|
|
406
|
+
}
|
|
407
|
+
async dispatch(event, payload) {
|
|
408
|
+
const handlers = this.listeners.get(event);
|
|
409
|
+
if (!handlers || handlers.size === 0) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
for (const handler of handlers) {
|
|
413
|
+
await handler(payload);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function readSharedEventBus() {
|
|
418
|
+
const globalBus = globalThis[EVENT_BUS_KEY];
|
|
419
|
+
if (globalBus) {
|
|
420
|
+
return globalBus;
|
|
421
|
+
}
|
|
422
|
+
const bus = new EventBus;
|
|
423
|
+
globalThis[EVENT_BUS_KEY] = bus;
|
|
424
|
+
return bus;
|
|
425
|
+
}
|
|
426
|
+
var EVENT_BUS_KEY, eventBus;
|
|
427
|
+
var init_eventBus = __esm(() => {
|
|
428
|
+
EVENT_BUS_KEY = Symbol.for("@getstrata/eventBus");
|
|
429
|
+
eventBus = readSharedEventBus();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// ../../src/core/database/transactionControl.ts
|
|
433
|
+
function unwrapTransactionRollback(error, depth = 0) {
|
|
434
|
+
if (error instanceof TransactionRollback) {
|
|
435
|
+
return error;
|
|
436
|
+
}
|
|
437
|
+
if (depth >= 5 || !(error instanceof Error) || error.cause === undefined) {
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
return unwrapTransactionRollback(error.cause, depth + 1);
|
|
441
|
+
}
|
|
442
|
+
function hasActiveTransaction() {
|
|
443
|
+
return hasActiveDatabaseConnection() && (transactionScopes.getStore()?.rollback.length ?? 0) > 0;
|
|
444
|
+
}
|
|
445
|
+
function requestTransactionRollback() {
|
|
446
|
+
const state = transactionScopes.getStore();
|
|
447
|
+
const index = state ? state.rollback.length - 1 : -1;
|
|
448
|
+
if (!state || index < 0) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const flags = state.rollback;
|
|
452
|
+
const current = flags[index];
|
|
453
|
+
if (current === undefined) {
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
flags[index] = true;
|
|
457
|
+
}
|
|
458
|
+
function scopeRequestsRollback() {
|
|
459
|
+
const state = transactionScopes.getStore();
|
|
460
|
+
if (!state || state.rollback.length === 0) {
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
return state.rollback[state.rollback.length - 1] === true;
|
|
464
|
+
}
|
|
465
|
+
async function commitOrRollbackScope(result) {
|
|
466
|
+
if (scopeRequestsRollback()) {
|
|
467
|
+
throw new TransactionRollback(result);
|
|
468
|
+
}
|
|
469
|
+
return result;
|
|
470
|
+
}
|
|
471
|
+
async function settleTransaction(work) {
|
|
472
|
+
try {
|
|
473
|
+
return await work();
|
|
474
|
+
} catch (error) {
|
|
475
|
+
const rollback = unwrapTransactionRollback(error);
|
|
476
|
+
if (rollback) {
|
|
477
|
+
return rollback.result;
|
|
478
|
+
}
|
|
479
|
+
throw error;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
async function runWithTransactionScope(callback) {
|
|
483
|
+
const existing = transactionScopes.getStore();
|
|
484
|
+
if (existing) {
|
|
485
|
+
existing.rollback.push(false);
|
|
486
|
+
try {
|
|
487
|
+
return await callback();
|
|
488
|
+
} finally {
|
|
489
|
+
existing.rollback.pop();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const state = { rollback: [false] };
|
|
493
|
+
return await transactionScopes.run(state, callback);
|
|
494
|
+
}
|
|
495
|
+
var TransactionRollback, transactionScopes;
|
|
496
|
+
var init_transactionControl = __esm(() => {
|
|
497
|
+
init_asyncContextStore();
|
|
498
|
+
init_connectionContext();
|
|
499
|
+
TransactionRollback = class TransactionRollback extends Error {
|
|
500
|
+
result;
|
|
501
|
+
constructor(result) {
|
|
502
|
+
super("Transaction rolled back");
|
|
503
|
+
this.name = "TransactionRollback";
|
|
504
|
+
this.result = result;
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
transactionScopes = createAsyncContextStore("@getstrata/transactionScopes");
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// ../../src/core/tenant/tenancyConfig.ts
|
|
511
|
+
function readTenancyDriver(env = process.env) {
|
|
512
|
+
const raw = env.TENANCY_DRIVER?.trim();
|
|
513
|
+
if (raw === undefined || raw === "") {
|
|
514
|
+
return "rls";
|
|
515
|
+
}
|
|
516
|
+
if (TENANCY_DRIVERS.includes(raw)) {
|
|
517
|
+
return raw;
|
|
518
|
+
}
|
|
519
|
+
throw new Error(`TENANCY_DRIVER must be one of ${TENANCY_DRIVERS.join(", ")}; received "${raw}".`);
|
|
520
|
+
}
|
|
521
|
+
function isTenancyEnabled(env = process.env) {
|
|
522
|
+
return readTenancyDriver(env) !== "none";
|
|
523
|
+
}
|
|
524
|
+
function isRlsTenancy(env = process.env) {
|
|
525
|
+
return readTenancyDriver(env) === "rls";
|
|
526
|
+
}
|
|
527
|
+
var TENANCY_DRIVERS;
|
|
528
|
+
var init_tenancyConfig = __esm(() => {
|
|
529
|
+
TENANCY_DRIVERS = ["rls", "column", "none"];
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// ../../src/core/tenant/databaseTenantContext.ts
|
|
533
|
+
import { getDefaultDatabasePool as getDefaultDatabasePool2 } from "@getstrata/core/database/defaultConnection";
|
|
534
|
+
async function applyBypassToTransaction(transaction, bypass) {
|
|
535
|
+
await transaction.unsafe(`SELECT set_config('app.bypass_rls', $1, true)`, [
|
|
536
|
+
bypass ? "true" : "false"
|
|
537
|
+
]);
|
|
538
|
+
}
|
|
539
|
+
function stringifyBypassIdentifier(identifier) {
|
|
540
|
+
if (typeof identifier === "number") {
|
|
541
|
+
return String(identifier);
|
|
542
|
+
}
|
|
543
|
+
return identifier.trim();
|
|
544
|
+
}
|
|
545
|
+
async function applyIdentifierToTransaction(transaction, identifier) {
|
|
546
|
+
await transaction.unsafe(`SELECT set_config('app.bypass_identifier', $1, true)`, [
|
|
547
|
+
stringifyBypassIdentifier(identifier)
|
|
548
|
+
]);
|
|
549
|
+
}
|
|
550
|
+
function assertBypassIdentifier(identifier) {
|
|
551
|
+
if (typeof identifier === "number") {
|
|
552
|
+
if (!Number.isInteger(identifier) || identifier <= 0) {
|
|
553
|
+
throw new Error("RLS bypass requires a caller-supplied identifier that pins the row.");
|
|
554
|
+
}
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (typeof identifier === "string" && identifier.trim() !== "") {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
throw new Error("RLS bypass requires a caller-supplied identifier that pins the row.");
|
|
561
|
+
}
|
|
562
|
+
async function runWithScopedTenantTransaction(apply, callback) {
|
|
563
|
+
if (!isRlsTenancy()) {
|
|
564
|
+
return await callback();
|
|
565
|
+
}
|
|
566
|
+
if (hasActiveDatabaseConnection()) {
|
|
567
|
+
const connection = getActiveDatabaseConnection(getDefaultDatabasePool2());
|
|
568
|
+
return await runInTransaction(async () => {
|
|
569
|
+
const [previous] = await connection.unsafe(`SELECT current_setting('app.tenant_id', true) AS tenant_id,
|
|
570
|
+
current_setting('app.bypass_rls', true) AS bypass_rls,
|
|
571
|
+
current_setting('app.bypass_identifier', true) AS bypass_identifier`);
|
|
572
|
+
await apply(connection);
|
|
573
|
+
const result = await callback();
|
|
574
|
+
await connection.unsafe(`SELECT set_config('app.tenant_id', $1, true),
|
|
575
|
+
set_config('app.bypass_rls', $2, true), set_config('app.bypass_identifier', $3, true)`, [previous?.tenant_id ?? "", previous?.bypass_rls ?? "", previous?.bypass_identifier ?? ""]);
|
|
576
|
+
return result;
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
const pool = getDefaultDatabasePool2();
|
|
580
|
+
const begin = pool.begin;
|
|
581
|
+
if (typeof begin !== "function") {
|
|
582
|
+
throw new Error("RLS migration bypass requires a pool that supports begin(). Session-scoped set_config is not used on pooled connections.");
|
|
583
|
+
}
|
|
584
|
+
return await settleTransaction(() => runWithDeferredModelEvents(async () => {
|
|
585
|
+
return await begin.bind(pool)(async (transaction) => {
|
|
586
|
+
await apply(transaction);
|
|
587
|
+
return await runWithDatabaseConnection(transaction, () => runWithTransactionScope(async () => commitOrRollbackScope(await callback())));
|
|
588
|
+
});
|
|
589
|
+
}));
|
|
590
|
+
}
|
|
591
|
+
async function runWithMigrationBypass(callback) {
|
|
592
|
+
return await runWithScopedTenantTransaction((transaction) => applyBypassToTransaction(transaction, true), callback);
|
|
593
|
+
}
|
|
594
|
+
async function runWithMigrationBypassForIdentifier(identifier, callback) {
|
|
595
|
+
assertBypassIdentifier(identifier);
|
|
596
|
+
return await runWithScopedTenantTransaction((transaction) => applyIdentifierToTransaction(transaction, identifier), callback);
|
|
597
|
+
}
|
|
598
|
+
var init_databaseTenantContext = __esm(() => {
|
|
599
|
+
init_connectionContext();
|
|
600
|
+
init_transaction();
|
|
601
|
+
init_transactionControl();
|
|
602
|
+
init_deferredModelEvents();
|
|
603
|
+
init_tenancyConfig();
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// ../../src/core/tenant/tenantDatabaseScope.ts
|
|
607
|
+
import { getDefaultDatabasePool as getDefaultDatabasePool3 } from "@getstrata/core/database/defaultConnection";
|
|
608
|
+
async function applyTenantContextToTransaction(transaction, tenantId) {
|
|
609
|
+
await transaction.unsafe(`SELECT set_config('app.tenant_id', $1, true)`, [String(tenantId)]);
|
|
610
|
+
await transaction.unsafe(`SELECT set_config('app.bypass_rls', $1, true)`, ["false"]);
|
|
611
|
+
}
|
|
612
|
+
async function runWithTenantDatabase(tenant, callback) {
|
|
613
|
+
const outerTenant = currentTenant();
|
|
614
|
+
if (hasActiveDatabaseConnection() && outerTenant && outerTenant.id !== tenant.id) {
|
|
615
|
+
throw new Error("Cannot switch tenants inside an active database transaction.");
|
|
616
|
+
}
|
|
617
|
+
if (!isTenancyEnabled() || !isRlsTenancy()) {
|
|
618
|
+
return await runWithTenant(tenant, callback);
|
|
619
|
+
}
|
|
620
|
+
if (hasActiveDatabaseConnection()) {
|
|
621
|
+
const activeConnection = getActiveDatabaseConnection(getDefaultDatabasePool3());
|
|
622
|
+
return await runWithScopedTenantTransaction(() => applyTenantContextToTransaction(activeConnection, tenant.id), () => runWithTenant(tenant, callback));
|
|
623
|
+
}
|
|
624
|
+
const pool = getDefaultDatabasePool3();
|
|
625
|
+
const begin = pool.begin;
|
|
626
|
+
if (typeof begin !== "function") {
|
|
627
|
+
throw new Error("RLS tenant scope requires a pool that supports begin(). Session-scoped set_config is not used on pooled connections.");
|
|
628
|
+
}
|
|
629
|
+
return await settleTransaction(() => runWithDeferredModelEvents(async () => {
|
|
630
|
+
return await begin.bind(pool)(async (transaction) => {
|
|
631
|
+
await applyTenantContextToTransaction(transaction, tenant.id);
|
|
632
|
+
return await runWithDatabaseConnection(transaction, async () => {
|
|
633
|
+
return await runWithTransactionScope(async () => commitOrRollbackScope(await runWithTenant(tenant, callback)));
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
}));
|
|
637
|
+
}
|
|
638
|
+
function isInsideTenantDatabaseScope(tenantId = currentTenant()?.id) {
|
|
639
|
+
return hasActiveDatabaseConnection() && currentTenant()?.id === tenantId;
|
|
640
|
+
}
|
|
641
|
+
var init_tenantDatabaseScope = __esm(() => {
|
|
642
|
+
init_connectionContext();
|
|
643
|
+
init_transactionControl();
|
|
644
|
+
init_deferredModelEvents();
|
|
645
|
+
init_databaseTenantContext();
|
|
646
|
+
init_tenancyConfig();
|
|
647
|
+
init_tenantContext();
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// ../../src/core/events/deferredModelEvents.ts
|
|
651
|
+
function sameTenant(left, right) {
|
|
652
|
+
if (left === null && right === null) {
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
655
|
+
if (left === null || right === null) {
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
return left.id === right.id;
|
|
659
|
+
}
|
|
660
|
+
function groupQueuedModelEvents(queued) {
|
|
661
|
+
const groups = [];
|
|
662
|
+
for (const item of queued) {
|
|
663
|
+
const lastGroup = groups[groups.length - 1];
|
|
664
|
+
const last = lastGroup?.[lastGroup.length - 1];
|
|
665
|
+
if (!lastGroup || !last || !sameTenant(last.tenant, item.tenant)) {
|
|
666
|
+
groups.push([item]);
|
|
667
|
+
} else {
|
|
668
|
+
lastGroup.push(item);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return groups;
|
|
672
|
+
}
|
|
673
|
+
async function dispatchEventBatch(batch) {
|
|
674
|
+
for (const item of batch) {
|
|
675
|
+
await eventBus.dispatch(item.event, item.payload);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
async function dispatchQueuedModelEvents(queued) {
|
|
679
|
+
if (queued.length === 0) {
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
await Promise.resolve().then(() => init_tenantDatabaseScope());
|
|
683
|
+
for (const batch of groupQueuedModelEvents(queued)) {
|
|
684
|
+
const tenant = batch[0]?.tenant ?? null;
|
|
685
|
+
if (tenant) {
|
|
686
|
+
await runWithTenantDatabase(tenant, () => dispatchEventBatch(batch));
|
|
687
|
+
} else {
|
|
688
|
+
await dispatchEventBatch(batch);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
async function runWithDeferredModelEvents(callback) {
|
|
693
|
+
const existing = deferredModelEvents.getStore();
|
|
694
|
+
if (existing) {
|
|
695
|
+
existing.frames.push([]);
|
|
696
|
+
existing.rollbackFrames.push([]);
|
|
697
|
+
try {
|
|
698
|
+
const result = await callback();
|
|
699
|
+
const nested = existing.frames.pop() ?? [];
|
|
700
|
+
const rollback = existing.rollbackFrames.pop() ?? [];
|
|
701
|
+
existing.rollbackFrames.at(-1)?.push(...rollback);
|
|
702
|
+
const parent = existing.frames[existing.frames.length - 1];
|
|
703
|
+
if (parent) {
|
|
704
|
+
parent.push(...nested);
|
|
705
|
+
}
|
|
706
|
+
return result;
|
|
707
|
+
} catch (error) {
|
|
708
|
+
existing.frames.pop();
|
|
709
|
+
for (const rollback of (existing.rollbackFrames.pop() ?? []).reverse())
|
|
710
|
+
rollback();
|
|
711
|
+
throw error;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
const state = { frames: [[]], rollbackFrames: [[]] };
|
|
715
|
+
let result;
|
|
716
|
+
try {
|
|
717
|
+
result = await deferredModelEvents.run(state, callback);
|
|
718
|
+
} catch (error) {
|
|
719
|
+
for (const rollback of (state.rollbackFrames[0] ?? []).reverse())
|
|
720
|
+
rollback();
|
|
721
|
+
throw error;
|
|
722
|
+
}
|
|
723
|
+
await dispatchQueuedModelEvents(state.frames[0] ?? []);
|
|
724
|
+
return result;
|
|
725
|
+
}
|
|
726
|
+
var deferredModelEvents;
|
|
727
|
+
var init_deferredModelEvents = __esm(() => {
|
|
728
|
+
init_asyncContextStore();
|
|
729
|
+
init_tenantContext();
|
|
730
|
+
init_eventBus();
|
|
731
|
+
deferredModelEvents = createAsyncContextStore("@getstrata/deferredModelEvents");
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// ../../src/core/database/acquisitionMetrics.ts
|
|
735
|
+
async function observeTransactionAcquisition(metrics, acquire, signal) {
|
|
736
|
+
const state = states.get(metrics);
|
|
737
|
+
if (!state)
|
|
738
|
+
throw new TypeError("Transaction acquisition metrics must be created by the framework factory.");
|
|
739
|
+
const started = performance.now();
|
|
740
|
+
state.inflight++;
|
|
741
|
+
let outcome = "acquired";
|
|
742
|
+
try {
|
|
743
|
+
return await acquire();
|
|
744
|
+
} catch (error) {
|
|
745
|
+
outcome = signal?.aborted ? "aborted" : "failed";
|
|
746
|
+
throw error;
|
|
747
|
+
} finally {
|
|
748
|
+
state.inflight--;
|
|
749
|
+
const elapsed = Math.max(0, performance.now() - started) / 1000;
|
|
750
|
+
const histogram = state.outcomes[outcomes.indexOf(outcome)];
|
|
751
|
+
if (histogram) {
|
|
752
|
+
histogram.count++;
|
|
753
|
+
histogram.sumSeconds += elapsed;
|
|
754
|
+
for (let index = 0;index < ACQUISITION_BUCKETS_SECONDS.length; index++) {
|
|
755
|
+
const bound = ACQUISITION_BUCKETS_SECONDS[index];
|
|
756
|
+
if (bound !== undefined && elapsed <= bound)
|
|
757
|
+
histogram.buckets[index] = (histogram.buckets[index] ?? 0) + 1;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
var ACQUISITION_BUCKETS_SECONDS, outcomes, metricsBrand, states;
|
|
763
|
+
var init_acquisitionMetrics = __esm(() => {
|
|
764
|
+
ACQUISITION_BUCKETS_SECONDS = [
|
|
765
|
+
0.001,
|
|
766
|
+
0.005,
|
|
767
|
+
0.01,
|
|
768
|
+
0.025,
|
|
769
|
+
0.05,
|
|
770
|
+
0.1,
|
|
771
|
+
0.25,
|
|
772
|
+
0.5,
|
|
773
|
+
1,
|
|
774
|
+
2.5,
|
|
775
|
+
5,
|
|
776
|
+
10
|
|
777
|
+
];
|
|
778
|
+
outcomes = ["acquired", "failed", "aborted"];
|
|
779
|
+
metricsBrand = Symbol("transaction acquisition metrics");
|
|
780
|
+
states = new WeakMap;
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
// ../../src/core/database/connection.ts
|
|
784
|
+
function createDatabaseConnection(source) {
|
|
785
|
+
return {
|
|
786
|
+
async unsafe(query, params = []) {
|
|
787
|
+
return await source.unsafe(query, params);
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
var init_connection = () => {};
|
|
792
|
+
|
|
793
|
+
// ../../src/core/database/transaction.ts
|
|
794
|
+
async function runInSavepoint(connection, operation) {
|
|
795
|
+
const inherited = transactionFrame.getStore();
|
|
796
|
+
let parent = inherited?.connection === connection ? inherited : outerFrames.get(connection);
|
|
797
|
+
if (!parent) {
|
|
798
|
+
parent = { connection, childActive: false };
|
|
799
|
+
outerFrames.set(connection, parent);
|
|
800
|
+
}
|
|
801
|
+
if (parent.childActive) {
|
|
802
|
+
throw new Error("Concurrent nested transactions on one connection are not supported. Await each transaction before starting the next.");
|
|
803
|
+
}
|
|
804
|
+
parent.childActive = true;
|
|
805
|
+
const name = `strata_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
806
|
+
try {
|
|
807
|
+
return await settleTransaction(() => runWithDeferredModelEvents(async () => {
|
|
808
|
+
await connection.unsafe(`SAVEPOINT ${name}`);
|
|
809
|
+
try {
|
|
810
|
+
const result = await transactionFrame.run({ connection, childActive: false }, () => runWithTransactionScope(async () => commitOrRollbackScope(await operation(createDatabaseConnection(connection)))));
|
|
811
|
+
await connection.unsafe(`RELEASE SAVEPOINT ${name}`);
|
|
812
|
+
return result;
|
|
813
|
+
} catch (error) {
|
|
814
|
+
await connection.unsafe(`ROLLBACK TO SAVEPOINT ${name}`);
|
|
815
|
+
await connection.unsafe(`RELEASE SAVEPOINT ${name}`);
|
|
816
|
+
throw error;
|
|
817
|
+
}
|
|
818
|
+
}));
|
|
819
|
+
} finally {
|
|
820
|
+
parent.childActive = false;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
function supportsTransactions(connection) {
|
|
824
|
+
return typeof connection.begin === "function";
|
|
825
|
+
}
|
|
826
|
+
async function runInTransaction(operation, options = {}) {
|
|
827
|
+
const signal = options.acquisitionSignal;
|
|
828
|
+
signal?.throwIfAborted();
|
|
829
|
+
const admittedOperation = (connection) => {
|
|
830
|
+
signal?.throwIfAborted();
|
|
831
|
+
return operation(connection);
|
|
832
|
+
};
|
|
833
|
+
const pool = resolveRepositoryConnection();
|
|
834
|
+
if (hasActiveDatabaseConnection()) {
|
|
835
|
+
return await runInSavepoint(getActiveDatabaseConnection(pool), admittedOperation);
|
|
836
|
+
}
|
|
837
|
+
if (!supportsTransactions(pool)) {
|
|
838
|
+
throw new Error("Active database connection does not support transactions. Bind a client with begin() via bindDatabaseConnection.");
|
|
839
|
+
}
|
|
840
|
+
return await settleTransaction(() => runWithDeferredModelEvents(async () => {
|
|
841
|
+
let reserved;
|
|
842
|
+
if (signal || options.acquisitionMetrics) {
|
|
843
|
+
if (!pool.reserve)
|
|
844
|
+
throw new Error(signal ? "Active database connection does not support cancellable transaction acquisition." : "Active database connection does not support observable transaction acquisition.");
|
|
845
|
+
const reserve = pool.reserve.bind(pool);
|
|
846
|
+
reserved = options.acquisitionMetrics ? await observeTransactionAcquisition(options.acquisitionMetrics, () => reserve({ signal }), signal) : await reserve({ signal });
|
|
847
|
+
}
|
|
848
|
+
const connection = reserved ?? pool;
|
|
849
|
+
try {
|
|
850
|
+
signal?.throwIfAborted();
|
|
851
|
+
if (!supportsTransactions(connection))
|
|
852
|
+
throw new Error("Reserved database connection does not support transactions.");
|
|
853
|
+
return await connection.begin(async (transaction) => {
|
|
854
|
+
return await runWithDatabaseConnection(transaction, () => transactionFrame.run({ connection: transaction, childActive: false }, () => runWithTransactionScope(async () => commitOrRollbackScope(await admittedOperation(createDatabaseConnection(transaction))))));
|
|
855
|
+
});
|
|
856
|
+
} finally {
|
|
857
|
+
await reserved?.release();
|
|
858
|
+
}
|
|
859
|
+
}));
|
|
860
|
+
}
|
|
861
|
+
var transactionFrame, outerFrames;
|
|
862
|
+
var init_transaction = __esm(() => {
|
|
863
|
+
init_deferredModelEvents();
|
|
864
|
+
init_asyncContextStore();
|
|
865
|
+
init_acquisitionMetrics();
|
|
866
|
+
init_connection();
|
|
867
|
+
init_connectionContext();
|
|
868
|
+
init_repositoryConnection();
|
|
869
|
+
init_transactionControl();
|
|
870
|
+
init_acquisitionMetrics();
|
|
871
|
+
transactionFrame = createAsyncContextStore("@getstrata/transactionFrame");
|
|
872
|
+
outerFrames = new WeakMap;
|
|
873
|
+
});
|
|
874
|
+
|
|
2
875
|
// ../../src/core/queue/queueMetrics.ts
|
|
3
876
|
var {RedisClient: RedisClient3 } = globalThis.Bun;
|
|
4
877
|
|
|
@@ -1319,6 +2192,117 @@ function createAppQueue(driver, redisUrl, failedJobs = createFailedJobService(),
|
|
|
1319
2192
|
});
|
|
1320
2193
|
}
|
|
1321
2194
|
|
|
2195
|
+
// ../../src/core/database/postgresReadOnlyCollector.ts
|
|
2196
|
+
init_connectionContext();
|
|
2197
|
+
init_dialect();
|
|
2198
|
+
init_repositoryConnection();
|
|
2199
|
+
init_transaction();
|
|
2200
|
+
function createPostgresReadOnlyCollector(name, timeoutMs, collect) {
|
|
2201
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 5000)
|
|
2202
|
+
throw new TypeError(`${name} timeout must be between 1 and 5000 milliseconds.`);
|
|
2203
|
+
let active;
|
|
2204
|
+
let closed = false;
|
|
2205
|
+
return {
|
|
2206
|
+
async collect() {
|
|
2207
|
+
if (closed)
|
|
2208
|
+
throw new Error(`${name} collector is closed.`);
|
|
2209
|
+
if (hasActiveTransaction() || hasActiveDatabaseConnection())
|
|
2210
|
+
throw new Error(`${name} require an independent transaction.`);
|
|
2211
|
+
if (currentSqlDialect().driver !== "pgsql")
|
|
2212
|
+
throw new Error(`${name} currently require Postgres.`);
|
|
2213
|
+
if (active)
|
|
2214
|
+
throw new Error(`${name} collection is still settling.`);
|
|
2215
|
+
const deadline = performance.now() + timeoutMs;
|
|
2216
|
+
let expired = false;
|
|
2217
|
+
const acquisition = new AbortController;
|
|
2218
|
+
let timer;
|
|
2219
|
+
const remaining = () => {
|
|
2220
|
+
const value = Math.floor(deadline - performance.now());
|
|
2221
|
+
if (expired || value < 1)
|
|
2222
|
+
throw new Error(`${name} collection timed out.`);
|
|
2223
|
+
return value;
|
|
2224
|
+
};
|
|
2225
|
+
const work = runInTransaction(async () => {
|
|
2226
|
+
await repositoryConnection.unsafe("SELECT set_config('statement_timeout', $1, true)", [`${remaining()}ms`]);
|
|
2227
|
+
await repositoryConnection.unsafe("SET TRANSACTION READ ONLY");
|
|
2228
|
+
remaining();
|
|
2229
|
+
const result = await collect(remaining);
|
|
2230
|
+
remaining();
|
|
2231
|
+
return result;
|
|
2232
|
+
}, { acquisitionSignal: acquisition.signal });
|
|
2233
|
+
active = work;
|
|
2234
|
+
work.finally(() => {
|
|
2235
|
+
if (active === work)
|
|
2236
|
+
active = undefined;
|
|
2237
|
+
}).catch(() => {});
|
|
2238
|
+
try {
|
|
2239
|
+
return await Promise.race([
|
|
2240
|
+
work,
|
|
2241
|
+
new Promise((_, reject) => {
|
|
2242
|
+
timer = setTimeout(() => {
|
|
2243
|
+
expired = true;
|
|
2244
|
+
const error = new Error(`${name} collection timed out.`);
|
|
2245
|
+
acquisition.abort(error);
|
|
2246
|
+
reject(error);
|
|
2247
|
+
}, timeoutMs);
|
|
2248
|
+
})
|
|
2249
|
+
]);
|
|
2250
|
+
} finally {
|
|
2251
|
+
if (timer !== undefined)
|
|
2252
|
+
clearTimeout(timer);
|
|
2253
|
+
}
|
|
2254
|
+
},
|
|
2255
|
+
async close() {
|
|
2256
|
+
closed = true;
|
|
2257
|
+
await active?.catch(() => {});
|
|
2258
|
+
}
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
// ../../src/core/queue/failedJobMetrics.ts
|
|
2263
|
+
init_repositoryConnection();
|
|
2264
|
+
var schemaQuery = `SELECT c.relkind = 'r' AND c.relpersistence = 'p' AND NOT c.relrowsecurity AND EXISTS (
|
|
2265
|
+
SELECT 1 FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0]
|
|
2266
|
+
WHERE i.indrelid = c.oid AND i.indisprimary AND i.indisvalid AND i.indisready
|
|
2267
|
+
AND i.indnkeyatts = 1 AND a.attname = 'id' AND NOT a.attisdropped
|
|
2268
|
+
AND a.atttypid IN ('int2'::regtype, 'int4'::regtype, 'int8'::regtype)
|
|
2269
|
+
) AS supported FROM pg_class c WHERE c.oid = 'failed_job'::regclass`;
|
|
2270
|
+
function createFailedJobMetricsCollector(options = {}) {
|
|
2271
|
+
const sampleLimit = options.sampleLimit ?? 500;
|
|
2272
|
+
if (!Number.isSafeInteger(sampleLimit) || sampleLimit < 1 || sampleLimit > 1000)
|
|
2273
|
+
throw new TypeError("Failed-job metrics sample limit must be between 1 and 1000.");
|
|
2274
|
+
const repository = new failedJobRepository_default;
|
|
2275
|
+
return createPostgresReadOnlyCollector("Failed-job metrics", options.timeoutMs ?? 1000, async (remaining) => {
|
|
2276
|
+
await repositoryConnection.unsafe('LOCK TABLE "failed_job" IN ACCESS SHARE MODE');
|
|
2277
|
+
const schema = await repositoryConnection.unsafe(schemaQuery);
|
|
2278
|
+
if (schema.length !== 1 || schema[0]?.supported !== true)
|
|
2279
|
+
throw new Error("Failed-job metrics require the standard global table with an ID primary key and no RLS.");
|
|
2280
|
+
remaining();
|
|
2281
|
+
const rows = await repository.query().orderBy({ column: "id", direction: "ASC" }).project(["id"], sampleLimit + 1);
|
|
2282
|
+
if (rows.length > sampleLimit + 1 || rows.some((row) => row.id == null))
|
|
2283
|
+
throw new Error("Invalid failed-job metrics response.");
|
|
2284
|
+
return {
|
|
2285
|
+
sampleLimit,
|
|
2286
|
+
count: Math.min(rows.length, sampleLimit),
|
|
2287
|
+
capped: rows.length > sampleLimit
|
|
2288
|
+
};
|
|
2289
|
+
});
|
|
2290
|
+
}
|
|
2291
|
+
function renderFailedJobMetrics(snapshot) {
|
|
2292
|
+
if (!Number.isSafeInteger(snapshot.sampleLimit) || snapshot.sampleLimit < 1 || snapshot.sampleLimit > 1000 || !Number.isSafeInteger(snapshot.count) || snapshot.count < 0 || snapshot.count > snapshot.sampleLimit || typeof snapshot.capped !== "boolean" || snapshot.capped && snapshot.count !== snapshot.sampleLimit)
|
|
2293
|
+
throw new TypeError("Invalid failed-job metrics snapshot.");
|
|
2294
|
+
return `# HELP strata_queue_failed_job_collector_success Whether failed-job collection succeeded.
|
|
2295
|
+
# TYPE strata_queue_failed_job_collector_success gauge
|
|
2296
|
+
strata_queue_failed_job_collector_success 1
|
|
2297
|
+
# HELP strata_queue_failed_jobs_sample Capped retained failed-job count; a lower bound when capped.
|
|
2298
|
+
# TYPE strata_queue_failed_jobs_sample gauge
|
|
2299
|
+
strata_queue_failed_jobs_sample ${snapshot.count}
|
|
2300
|
+
# HELP strata_queue_failed_jobs_sample_capped Whether the failed-job sample is truncated.
|
|
2301
|
+
# TYPE strata_queue_failed_jobs_sample_capped gauge
|
|
2302
|
+
strata_queue_failed_jobs_sample_capped ${Number(snapshot.capped)}
|
|
2303
|
+
`;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
1322
2306
|
// ../../src/core/queue/queueMetrics.ts
|
|
1323
2307
|
async function readRedisQueueDepth(redisUrl) {
|
|
1324
2308
|
const client = new RedisClient3(redisUrl);
|
|
@@ -1393,18 +2377,28 @@ local ready = capped == 1 and -1 or size-inflight
|
|
|
1393
2377
|
if ready < 0 and capped == 0 then return redis.error_reply('Inconsistent stream pending state') end
|
|
1394
2378
|
local retries = redis.call('ZCARD', KEYS[2])
|
|
1395
2379
|
local due = redis.call('ZCOUNT', KEYS[2], '-inf', ms)
|
|
2380
|
+
local lateness = 0
|
|
2381
|
+
if due > 0 then
|
|
2382
|
+
local oldest = redis.call('ZRANGE', KEYS[2], 0, 0, 'WITHSCORES')
|
|
2383
|
+
local score = tonumber(oldest[2])
|
|
2384
|
+
if not score or score < 0 or score ~= math.floor(score) or score > ms then
|
|
2385
|
+
return redis.error_reply('Invalid retry due score')
|
|
2386
|
+
end
|
|
2387
|
+
-- Cancellation sets score zero to request immediate promotion/cleanup; it is not a timestamp.
|
|
2388
|
+
lateness = score == 0 and -1 or (ms-score)/1000
|
|
2389
|
+
end
|
|
1396
2390
|
local first = redis.call('XRANGE', KEYS[1], '-', '+', 'COUNT', 1)
|
|
1397
2391
|
local age = 0
|
|
1398
2392
|
if #first > 0 then
|
|
1399
2393
|
local timestamp = tonumber(string.match(first[1][1], '^(%d+)-'))
|
|
1400
2394
|
age = math.max(0, ms-timestamp)/1000
|
|
1401
2395
|
end
|
|
1402
|
-
return {size+retries, ready, inflight, capped, due, retries-due, redis.call('LLEN', KEYS[3]), tostring(age)}
|
|
2396
|
+
return {size+retries, ready, inflight, capped, due, retries-due, redis.call('LLEN', KEYS[3]), tostring(age), tostring(lateness)}
|
|
1403
2397
|
`;
|
|
1404
2398
|
var LIST_SNAPSHOT = `
|
|
1405
2399
|
local ready = redis.call('LLEN', KEYS[1])
|
|
1406
2400
|
local inflight = redis.call('LLEN', KEYS[2])
|
|
1407
|
-
return {ready+inflight, ready, inflight, 0, 0, 0, redis.call('LLEN', KEYS[3]), '-1'}
|
|
2401
|
+
return {ready+inflight, ready, inflight, 0, 0, 0, redis.call('LLEN', KEYS[3]), '-1', '-1'}
|
|
1408
2402
|
`;
|
|
1409
2403
|
async function readRedisQueueSnapshot(redisUrl, options = {}) {
|
|
1410
2404
|
if (!redisUrl.trim())
|
|
@@ -1427,13 +2421,23 @@ async function readRedisQueueSnapshot(redisUrl, options = {}) {
|
|
|
1427
2421
|
STREAM_GROUP,
|
|
1428
2422
|
String(INFLIGHT_SAMPLE_LIMIT)
|
|
1429
2423
|
]);
|
|
1430
|
-
if (!Array.isArray(raw) || raw.length !==
|
|
2424
|
+
if (!Array.isArray(raw) || raw.length !== 9)
|
|
1431
2425
|
throw new Error("Invalid queue metrics response.");
|
|
1432
2426
|
const values = raw.map(Number);
|
|
1433
2427
|
if (values.some((value) => !Number.isFinite(value)) || values.slice(0, 7).some((value, i) => !Number.isSafeInteger(value) || value < (i === 1 ? -1 : 0)))
|
|
1434
2428
|
throw new Error("Invalid queue metrics values.");
|
|
1435
|
-
const [
|
|
1436
|
-
|
|
2429
|
+
const [
|
|
2430
|
+
unfinished,
|
|
2431
|
+
ready,
|
|
2432
|
+
inflight,
|
|
2433
|
+
capped,
|
|
2434
|
+
retryDue,
|
|
2435
|
+
retryWaiting,
|
|
2436
|
+
quarantined,
|
|
2437
|
+
age,
|
|
2438
|
+
lateness
|
|
2439
|
+
] = values;
|
|
2440
|
+
if (unfinished === undefined || ready === undefined || inflight === undefined || capped === undefined || retryDue === undefined || retryWaiting === undefined || quarantined === undefined || age === undefined || lateness === undefined || lateness < 0 && lateness !== -1 || capped !== 0 && capped !== 1 || age < (transport === "lists" ? -1 : 0))
|
|
1437
2441
|
throw new Error("Invalid queue metrics values.");
|
|
1438
2442
|
return {
|
|
1439
2443
|
priority: index === 0 ? "high" : index === 1 ? "default" : "low",
|
|
@@ -1443,6 +2447,7 @@ async function readRedisQueueSnapshot(redisUrl, options = {}) {
|
|
|
1443
2447
|
inflightCapped: capped === 1,
|
|
1444
2448
|
retryDue,
|
|
1445
2449
|
retryWaiting,
|
|
2450
|
+
retryActionableLatenessSeconds: lateness === -1 ? null : lateness,
|
|
1446
2451
|
quarantined,
|
|
1447
2452
|
oldestStreamEntryAgeSeconds: transport === "streams" ? age : null
|
|
1448
2453
|
};
|
|
@@ -1464,7 +2469,7 @@ function renderRedisQueueMetrics(snapshot) {
|
|
|
1464
2469
|
if (snapshot.transport !== "streams" && snapshot.transport !== "lists" || snapshot.priorities.length !== 3 || new Set(snapshot.priorities.map((row) => row.priority)).size !== 3)
|
|
1465
2470
|
throw new TypeError("Invalid queue metrics snapshot.");
|
|
1466
2471
|
for (const row of snapshot.priorities) {
|
|
1467
|
-
if (!["high", "default", "low"].includes(row.priority) || typeof row.inflightCapped !== "boolean" || [row.unfinished, row.inflight, row.retryDue, row.retryWaiting, row.quarantined].some((value) => !Number.isSafeInteger(value) || value < 0) || row.ready !== null && (!Number.isSafeInteger(row.ready) || row.ready < 0) || row.oldestStreamEntryAgeSeconds !== null && (!Number.isFinite(row.oldestStreamEntryAgeSeconds) || row.oldestStreamEntryAgeSeconds < 0))
|
|
2472
|
+
if (!["high", "default", "low"].includes(row.priority) || typeof row.inflightCapped !== "boolean" || [row.unfinished, row.inflight, row.retryDue, row.retryWaiting, row.quarantined].some((value) => !Number.isSafeInteger(value) || value < 0) || row.ready !== null && (!Number.isSafeInteger(row.ready) || row.ready < 0) || row.retryActionableLatenessSeconds != null && (!Number.isFinite(row.retryActionableLatenessSeconds) || row.retryActionableLatenessSeconds < 0) || row.oldestStreamEntryAgeSeconds !== null && (!Number.isFinite(row.oldestStreamEntryAgeSeconds) || row.oldestStreamEntryAgeSeconds < 0))
|
|
1468
2473
|
throw new TypeError("Invalid queue metrics snapshot.");
|
|
1469
2474
|
}
|
|
1470
2475
|
const lines = [
|
|
@@ -1478,7 +2483,9 @@ function renderRedisQueueMetrics(snapshot) {
|
|
|
1478
2483
|
"# HELP strata_queue_inflight_sample_capped Whether the inflight sample was truncated; ready is omitted when truncated.",
|
|
1479
2484
|
"# TYPE strata_queue_inflight_sample_capped gauge",
|
|
1480
2485
|
"# HELP strata_queue_oldest_stream_entry_age_seconds Oldest current stream entry residence age; resets when a retry is promoted.",
|
|
1481
|
-
"# TYPE strata_queue_oldest_stream_entry_age_seconds gauge"
|
|
2486
|
+
"# TYPE strata_queue_oldest_stream_entry_age_seconds gauge",
|
|
2487
|
+
"# HELP strata_queue_retry_actionable_lateness_seconds Oldest retry due-score lateness; unavailable for cancellation sentinels or lists.",
|
|
2488
|
+
"# TYPE strata_queue_retry_actionable_lateness_seconds gauge"
|
|
1482
2489
|
];
|
|
1483
2490
|
for (const priority of snapshot.priorities) {
|
|
1484
2491
|
const label = `priority="${priority.priority}"`;
|
|
@@ -1494,6 +2501,8 @@ function renderRedisQueueMetrics(snapshot) {
|
|
|
1494
2501
|
if (value !== null)
|
|
1495
2502
|
lines.push(`strata_queue_jobs{${label},state="${state}"} ${value}`);
|
|
1496
2503
|
lines.push(`strata_queue_inflight_sample_capped{${label}} ${Number(priority.inflightCapped)}`);
|
|
2504
|
+
if (snapshot.transport === "streams" && priority.retryActionableLatenessSeconds != null)
|
|
2505
|
+
lines.push(`strata_queue_retry_actionable_lateness_seconds{${label}} ${priority.retryActionableLatenessSeconds}`);
|
|
1497
2506
|
if (priority.oldestStreamEntryAgeSeconds !== null)
|
|
1498
2507
|
lines.push(`strata_queue_oldest_stream_entry_age_seconds{${label}} ${priority.oldestStreamEntryAgeSeconds}`);
|
|
1499
2508
|
}
|
|
@@ -1504,7 +2513,9 @@ function renderRedisQueueMetrics(snapshot) {
|
|
|
1504
2513
|
export {
|
|
1505
2514
|
INFLIGHT_SAMPLE_LIMIT,
|
|
1506
2515
|
collectQueueMetrics,
|
|
2516
|
+
createFailedJobMetricsCollector,
|
|
1507
2517
|
readRedisQueueDepth,
|
|
1508
2518
|
readRedisQueueSnapshot,
|
|
2519
|
+
renderFailedJobMetrics,
|
|
1509
2520
|
renderRedisQueueMetrics
|
|
1510
2521
|
};
|