@getstrata/core 2.2.0 → 2.2.2
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 +11 -0
- package/dist/core/contracts/serviceTokens.d.ts +3 -2
- package/dist/core/database/baseRepository.d.ts +5 -0
- package/dist/core/database/model.d.ts +3 -1
- package/dist/core/database/query.d.ts +6 -1
- package/dist/core/database/repositoryQuery.d.ts +7 -1
- package/dist/core/database/schema/blueprint.d.ts +2 -0
- package/dist/core/database/schema/columnDefinition.d.ts +1 -1
- package/dist/core/database/types.d.ts +10 -1
- package/dist/core/http/requestMetaContext.d.ts +2 -0
- package/dist/core/queue/createAppQueue.d.ts +2 -1
- package/dist/core/queue/failedJobService.d.ts +3 -1
- package/dist/core/queue/index.d.ts +10 -2
- package/dist/core/queue/jobRunner.d.ts +7 -1
- package/dist/core/queue/publicQueue.d.ts +2 -1
- package/dist/core/queue/queueConfig.d.ts +3 -0
- package/dist/core/queue/redisQueue.d.ts +5 -7
- package/dist/core/queue/redisQueueKeys.d.ts +8 -0
- package/dist/core/queue/redisStreams.d.ts +73 -0
- package/dist/core/queue/types.d.ts +1 -0
- package/dist/core/tenant/publicTenantHost.d.ts +11 -0
- package/dist/core/tenant/tenantMiddleware.d.ts +4 -0
- package/dist/entries/auth/basicAuthGuard.js +1 -0
- package/dist/entries/auth/jwtGuard.js +1 -0
- package/dist/entries/auth/sessionGuard.js +1 -0
- package/dist/entries/contracts/serviceTokens.js +2 -0
- package/dist/entries/database/query.js +70 -1
- package/dist/entries/database/repositoryQuery.js +99 -1
- package/dist/entries/database/schema.js +9 -0
- package/dist/entries/database/sqliteConnection.js +1 -1
- package/dist/entries/queue/createAppQueue.js +681 -66
- package/dist/entries/queue/failedJobRepository.js +1 -1
- package/dist/entries/queue/failedJobService.js +5 -1
- package/dist/entries/queue/jobRunner.js +67 -2
- package/dist/entries/queue/publicQueue.js +679 -65
- package/dist/entries/queue/queueMetrics.js +694 -72
- package/dist/entries/queue/redisQueue.js +765 -148
- package/dist/framework/public-api.d.ts +2 -2
- package/dist/index.js +855 -69
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1136,6 +1136,7 @@ class ConfigStore {
|
|
|
1136
1136
|
|
|
1137
1137
|
// ../../src/core/contracts/serviceTokens.ts
|
|
1138
1138
|
var CORE_THROTTLE_QUOTA_POLICY_TOKEN = createServiceToken("core.throttleQuotaPolicy");
|
|
1139
|
+
var CORE_PUBLIC_TENANCY_TOKEN = createServiceToken("core.publicTenancy");
|
|
1139
1140
|
var CORE_TENANT_RESOLVER_TOKEN = createServiceToken("core.tenantResolver");
|
|
1140
1141
|
var CORE_HTTP_CLEANUP_TOKEN = createServiceToken("core.httpCleanup");
|
|
1141
1142
|
var CORE_QUEUE_TOKEN = "core.queue";
|
|
@@ -4462,6 +4463,31 @@ function getDefinedColumnEntries(table, values, options = {}) {
|
|
|
4462
4463
|
return [[column, value]];
|
|
4463
4464
|
});
|
|
4464
4465
|
}
|
|
4466
|
+
function buildSelectLock(table, options) {
|
|
4467
|
+
const lock = options.lock;
|
|
4468
|
+
if (!lock)
|
|
4469
|
+
return "";
|
|
4470
|
+
const driver = currentSqlDialect().driver;
|
|
4471
|
+
if (driver === "sqlite")
|
|
4472
|
+
throw new Error("Row locks are not supported on SQLite; use an explicit write transaction.");
|
|
4473
|
+
if (lock.mode !== "update" && lock.mode !== "share")
|
|
4474
|
+
throw new TypeError("Invalid row lock mode.");
|
|
4475
|
+
if (lock.wait !== undefined && !["wait", "nowait", "skipLocked"].includes(lock.wait))
|
|
4476
|
+
throw new TypeError("Invalid row lock wait policy.");
|
|
4477
|
+
if (options.groupBy || options.having)
|
|
4478
|
+
throw new Error("Row locks cannot be combined with grouped/aggregate queries.");
|
|
4479
|
+
let target = "";
|
|
4480
|
+
if (lock.of !== undefined) {
|
|
4481
|
+
if (driver !== "pgsql")
|
|
4482
|
+
throw new Error("Row lock targets are supported only on PostgreSQL.");
|
|
4483
|
+
const tables = [table.name, ...(options.joins ?? []).map((join) => join.table)];
|
|
4484
|
+
if (!lock.of.length || lock.of.some((name) => !tables.includes(name)))
|
|
4485
|
+
throw new TypeError("Row lock targets must name tables in this query.");
|
|
4486
|
+
target = ` OF ${lock.of.map(quoteIdentifier).join(", ")}`;
|
|
4487
|
+
}
|
|
4488
|
+
const wait = lock.wait === "nowait" ? " NOWAIT" : lock.wait === "skipLocked" ? " SKIP LOCKED" : "";
|
|
4489
|
+
return ` FOR ${lock.mode === "update" ? "UPDATE" : "SHARE"}${target}${wait}`;
|
|
4490
|
+
}
|
|
4465
4491
|
function buildSelectQuery(table, options = {}, whereNodes = []) {
|
|
4466
4492
|
const params = [];
|
|
4467
4493
|
const columns = buildSelectList(table, options.select, params);
|
|
@@ -4472,8 +4498,9 @@ function buildSelectQuery(table, options = {}, whereNodes = []) {
|
|
|
4472
4498
|
const orderBy = buildOrderByClause(table.name, options.orderBy ?? table.defaultOrderBy);
|
|
4473
4499
|
const limit = buildLimitClause(options.limit);
|
|
4474
4500
|
const offset = buildOffsetClause(options.offset);
|
|
4501
|
+
const lock = buildSelectLock(table, options);
|
|
4475
4502
|
return {
|
|
4476
|
-
text: `SELECT ${columns} FROM ${quoteIdentifier(table.name)}${joins}${clause}${groupBy}${havingClause}${orderBy}${limit}${offset}`,
|
|
4503
|
+
text: `SELECT ${columns} FROM ${quoteIdentifier(table.name)}${joins}${clause}${groupBy}${havingClause}${orderBy}${limit}${offset}${lock}`,
|
|
4477
4504
|
params
|
|
4478
4505
|
};
|
|
4479
4506
|
}
|
|
@@ -4642,6 +4669,48 @@ function buildDeleteByIdQuery(table, id) {
|
|
|
4642
4669
|
params
|
|
4643
4670
|
};
|
|
4644
4671
|
}
|
|
4672
|
+
function buildConditionalWriteQuery(table, operation, changes, options, returning) {
|
|
4673
|
+
for (const key of [
|
|
4674
|
+
"joins",
|
|
4675
|
+
"groupBy",
|
|
4676
|
+
"having",
|
|
4677
|
+
"select",
|
|
4678
|
+
"orderBy",
|
|
4679
|
+
"limit",
|
|
4680
|
+
"offset",
|
|
4681
|
+
"lock"
|
|
4682
|
+
]) {
|
|
4683
|
+
if (options[key] !== undefined)
|
|
4684
|
+
throw new Error(`Conditional writes do not support ${key}.`);
|
|
4685
|
+
}
|
|
4686
|
+
const params = [];
|
|
4687
|
+
const predicate = buildAdvancedWhereClause(table.name, options.where ?? {}, options.whereNodes ?? []);
|
|
4688
|
+
if (!predicate.clause)
|
|
4689
|
+
throw new Error("Conditional writes require an explicit predicate.");
|
|
4690
|
+
if (returning !== undefined && currentSqlDialect().driver === "mysql")
|
|
4691
|
+
throw new Error("Mutation RETURNING is not supported on MySQL.");
|
|
4692
|
+
const columns = returning ?? [table.primaryKey];
|
|
4693
|
+
if (!columns.length || columns.some((column) => !table.columns.includes(column)))
|
|
4694
|
+
throw new Error("RETURNING columns must be nonempty declared table columns.");
|
|
4695
|
+
const deletedAt = resolveSoftDeleteColumn(table);
|
|
4696
|
+
const values = operation === "delete" && deletedAt ? { [deletedAt]: new Date } : changes;
|
|
4697
|
+
let prefix;
|
|
4698
|
+
if (operation === "update" || deletedAt) {
|
|
4699
|
+
const entries = Object.entries(values).filter(([, value]) => value !== undefined);
|
|
4700
|
+
if (!entries.length)
|
|
4701
|
+
throw new Error("Conditional updates require at least one value.");
|
|
4702
|
+
if (entries.some(([column]) => column === table.primaryKey || !table.columns.includes(column)))
|
|
4703
|
+
throw new Error("Conditional updates require declared non-primary-key columns.");
|
|
4704
|
+
const assignments = entries.map(([column, value]) => `${quoteIdentifier(column)} = ${pushParam(params, value)}`);
|
|
4705
|
+
prefix = `UPDATE ${quoteIdentifier(table.name)} SET ${assignments.join(", ")}`;
|
|
4706
|
+
} else
|
|
4707
|
+
prefix = `DELETE FROM ${quoteIdentifier(table.name)}`;
|
|
4708
|
+
const { clause } = buildQueryWhereClause(table, options, options.whereNodes ?? [], params);
|
|
4709
|
+
return {
|
|
4710
|
+
text: prefix + clause + returningSuffix(columns.map(quoteIdentifier).join(", ")),
|
|
4711
|
+
params
|
|
4712
|
+
};
|
|
4713
|
+
}
|
|
4645
4714
|
|
|
4646
4715
|
// ../../src/core/database/relationships.ts
|
|
4647
4716
|
function hasMany(definition) {
|
|
@@ -4990,6 +5059,20 @@ class RepositoryQuery {
|
|
|
4990
5059
|
this.whereNodes.push({ kind: "or", where: input });
|
|
4991
5060
|
return this;
|
|
4992
5061
|
}
|
|
5062
|
+
lockForUpdate(options = {}) {
|
|
5063
|
+
this.queryOptions = {
|
|
5064
|
+
...this.queryOptions,
|
|
5065
|
+
lock: { ...options, of: options.of ? [...options.of] : undefined, mode: "update" }
|
|
5066
|
+
};
|
|
5067
|
+
return this;
|
|
5068
|
+
}
|
|
5069
|
+
sharedLock(options = {}) {
|
|
5070
|
+
this.queryOptions = {
|
|
5071
|
+
...this.queryOptions,
|
|
5072
|
+
lock: { ...options, of: options.of ? [...options.of] : undefined, mode: "share" }
|
|
5073
|
+
};
|
|
5074
|
+
return this;
|
|
5075
|
+
}
|
|
4993
5076
|
orderBy(orderBy) {
|
|
4994
5077
|
this.queryOptions = { ...this.queryOptions, orderBy };
|
|
4995
5078
|
return this;
|
|
@@ -5129,6 +5212,22 @@ class RepositoryQuery {
|
|
|
5129
5212
|
this.queryOptions = { ...this.queryOptions, onlyTrashed: true };
|
|
5130
5213
|
return this;
|
|
5131
5214
|
}
|
|
5215
|
+
async update(changes) {
|
|
5216
|
+
this.assertNoMutationEagerLoads();
|
|
5217
|
+
return this.repository.updateWhere(changes, this.buildOptions());
|
|
5218
|
+
}
|
|
5219
|
+
async updateReturning(changes, ...columns) {
|
|
5220
|
+
this.assertNoMutationEagerLoads();
|
|
5221
|
+
return this.repository.updateWhereReturning(changes, columns, this.buildOptions());
|
|
5222
|
+
}
|
|
5223
|
+
async delete() {
|
|
5224
|
+
this.assertNoMutationEagerLoads();
|
|
5225
|
+
return this.repository.deleteWhere(this.buildOptions());
|
|
5226
|
+
}
|
|
5227
|
+
assertNoMutationEagerLoads() {
|
|
5228
|
+
if (this.eagerLoads.length)
|
|
5229
|
+
throw new Error("Conditional writes do not support eager loading.");
|
|
5230
|
+
}
|
|
5132
5231
|
async get() {
|
|
5133
5232
|
const rows = await this.repository.findAll(this.buildOptions());
|
|
5134
5233
|
return await this.attach(rows);
|
|
@@ -5334,11 +5433,16 @@ class BaseRepository {
|
|
|
5334
5433
|
return runInTransaction(operation);
|
|
5335
5434
|
}
|
|
5336
5435
|
async count(options = {}) {
|
|
5436
|
+
if (options.lock)
|
|
5437
|
+
throw new Error("Row locks require a row selection, not count().");
|
|
5337
5438
|
const { whereNodes, where, ...rest } = options;
|
|
5338
5439
|
return await this.countWhere(where ?? {}, rest, whereNodes ?? []);
|
|
5339
5440
|
}
|
|
5340
5441
|
async findAll(options = {}) {
|
|
5341
5442
|
return await withDatabaseErrorHandling(async () => {
|
|
5443
|
+
if (options.lock && (!hasActiveTransaction() || this.connection !== repositoryConnection && this.connection !== getActiveDatabaseConnection(repositoryConnection))) {
|
|
5444
|
+
throw new Error("Row locks require the framework active transaction connection.");
|
|
5445
|
+
}
|
|
5342
5446
|
const { whereNodes, ...queryOptions } = options;
|
|
5343
5447
|
const { text, params } = buildSelectQuery(this.table, queryOptions, whereNodes ?? []);
|
|
5344
5448
|
return await this.connection.unsafe(text, params);
|
|
@@ -5508,6 +5612,28 @@ class BaseRepository {
|
|
|
5508
5612
|
return entity;
|
|
5509
5613
|
}));
|
|
5510
5614
|
}
|
|
5615
|
+
async updateWhere(changes, options) {
|
|
5616
|
+
return this.executeConditionalWrite("update", changes, options);
|
|
5617
|
+
}
|
|
5618
|
+
async deleteWhere(options) {
|
|
5619
|
+
return this.executeConditionalWrite("delete", {}, options);
|
|
5620
|
+
}
|
|
5621
|
+
async updateWhereReturning(changes, columns, options) {
|
|
5622
|
+
const { text, params } = buildConditionalWriteQuery(this.table, "update", changes, options, columns);
|
|
5623
|
+
return withDatabaseErrorHandling(() => this.connection.unsafe(text, params));
|
|
5624
|
+
}
|
|
5625
|
+
async executeConditionalWrite(operation, changes, options) {
|
|
5626
|
+
const { text, params } = buildConditionalWriteQuery(this.table, operation, changes, options);
|
|
5627
|
+
return withDatabaseErrorHandling(async () => {
|
|
5628
|
+
const rows = await this.connection.unsafe(text, params);
|
|
5629
|
+
if (currentSqlDialect().driver !== "mysql")
|
|
5630
|
+
return rows.length;
|
|
5631
|
+
const affected = rows[0]?.affectedRows;
|
|
5632
|
+
if (!Number.isSafeInteger(affected) || affected === undefined || affected < 0)
|
|
5633
|
+
throw new Error("MySQL conditional writes require affectedRows metadata from the framework adapter.");
|
|
5634
|
+
return affected;
|
|
5635
|
+
});
|
|
5636
|
+
}
|
|
5511
5637
|
async updateById(id, changes) {
|
|
5512
5638
|
this.assertTransactionalEvent("updated");
|
|
5513
5639
|
return await this.runModelMutation("updated", () => withDatabaseErrorHandling(async () => {
|
|
@@ -7171,6 +7297,14 @@ class ModelQuery {
|
|
|
7171
7297
|
return rows.map((row) => applyCasts(row, modelStatics(this.modelClass).$casts, "hydrate"));
|
|
7172
7298
|
});
|
|
7173
7299
|
}
|
|
7300
|
+
lockForUpdate(options = {}) {
|
|
7301
|
+
this.query.lockForUpdate(options);
|
|
7302
|
+
return this;
|
|
7303
|
+
}
|
|
7304
|
+
sharedLock(options = {}) {
|
|
7305
|
+
this.query.sharedLock(options);
|
|
7306
|
+
return this;
|
|
7307
|
+
}
|
|
7174
7308
|
orderBy(orderBy) {
|
|
7175
7309
|
this.query.orderBy(orderBy);
|
|
7176
7310
|
return this;
|
|
@@ -8199,6 +8333,13 @@ class Blueprint {
|
|
|
8199
8333
|
this.columns.push(column);
|
|
8200
8334
|
return column;
|
|
8201
8335
|
}
|
|
8336
|
+
bigId(name = "id") {
|
|
8337
|
+
const column = new ColumnDefinition(name, "bigId");
|
|
8338
|
+
column.primary();
|
|
8339
|
+
column.autoIncrement = true;
|
|
8340
|
+
this.columns.push(column);
|
|
8341
|
+
return column;
|
|
8342
|
+
}
|
|
8202
8343
|
string(name, length) {
|
|
8203
8344
|
if (length !== undefined && (!Number.isSafeInteger(length) || length <= 0)) {
|
|
8204
8345
|
throw new Error("String length must be a positive safe integer.");
|
|
@@ -8345,6 +8486,8 @@ init_dialect();
|
|
|
8345
8486
|
// ../../src/core/database/schema/grammars/grammar.ts
|
|
8346
8487
|
function compileColumnType(driver, column) {
|
|
8347
8488
|
switch (column.kind) {
|
|
8489
|
+
case "bigId":
|
|
8490
|
+
return driver === "pgsql" ? "BIGSERIAL" : compileIdType(driver);
|
|
8348
8491
|
case "id":
|
|
8349
8492
|
return compileIdType(driver);
|
|
8350
8493
|
case "string":
|
|
@@ -8780,7 +8923,7 @@ function createSqliteConnection(filename) {
|
|
|
8780
8923
|
const transactions = new WeakSet;
|
|
8781
8924
|
function execute(query, params) {
|
|
8782
8925
|
const statement = db.query(query);
|
|
8783
|
-
const args =
|
|
8926
|
+
const args = params.map((value) => value instanceof Date ? value.toISOString() : value);
|
|
8784
8927
|
if (isRowReturning(query))
|
|
8785
8928
|
return statement.all(...args);
|
|
8786
8929
|
statement.run(...args);
|
|
@@ -12490,7 +12633,7 @@ function createQueue(driver) {
|
|
|
12490
12633
|
var failedJobTable = defineTable({
|
|
12491
12634
|
name: "failed_job",
|
|
12492
12635
|
primaryKey: "id",
|
|
12493
|
-
columns: ["id", "job_name", "payload", "exception", "failed_at"],
|
|
12636
|
+
columns: ["id", "job_name", "job_id", "payload", "exception", "failed_at"],
|
|
12494
12637
|
defaultOrderBy: { column: "failed_at", direction: "DESC" }
|
|
12495
12638
|
});
|
|
12496
12639
|
|
|
@@ -12511,6 +12654,7 @@ class FailedJobService {
|
|
|
12511
12654
|
async recordFailure(input) {
|
|
12512
12655
|
return await this.repository.create({
|
|
12513
12656
|
job_name: input.jobName,
|
|
12657
|
+
job_id: input.jobId ?? null,
|
|
12514
12658
|
payload: input.payload,
|
|
12515
12659
|
exception: input.exception,
|
|
12516
12660
|
failed_at: new Date
|
|
@@ -12522,8 +12666,11 @@ class FailedJobService {
|
|
|
12522
12666
|
orderBy: { column: "failed_at", direction: "DESC" }
|
|
12523
12667
|
});
|
|
12524
12668
|
}
|
|
12525
|
-
async retry(id) {
|
|
12669
|
+
async retry(id, enqueue) {
|
|
12670
|
+
if (typeof enqueue !== "function")
|
|
12671
|
+
throw new Error("Failed-job retry requires an admission callback.");
|
|
12526
12672
|
const failedJob = await this.repository.findByIdOrThrow(id, (jobId) => new Error(`Failed job ${jobId} not found.`));
|
|
12673
|
+
await enqueue(failedJob);
|
|
12527
12674
|
await this.repository.deleteById(id);
|
|
12528
12675
|
return failedJob;
|
|
12529
12676
|
}
|
|
@@ -12546,6 +12693,9 @@ class FailedJobService {
|
|
|
12546
12693
|
}
|
|
12547
12694
|
var failedJobService_default = FailedJobService;
|
|
12548
12695
|
|
|
12696
|
+
// ../../src/core/queue/jobRunner.ts
|
|
12697
|
+
import { setTimeout as delay } from "timers/promises";
|
|
12698
|
+
|
|
12549
12699
|
// ../../src/core/queue/queueConfig.ts
|
|
12550
12700
|
function resolveQueueConfig() {
|
|
12551
12701
|
const driver = process.env.QUEUE_DRIVER;
|
|
@@ -12568,22 +12718,82 @@ var queueConfig = {
|
|
|
12568
12718
|
return resolveQueueConfig().backoffMs;
|
|
12569
12719
|
}
|
|
12570
12720
|
};
|
|
12721
|
+
function resolveRedisQueueTransport() {
|
|
12722
|
+
const transport = process.env.QUEUE_REDIS_TRANSPORT ?? "lists";
|
|
12723
|
+
if (transport !== "lists" && transport !== "streams")
|
|
12724
|
+
throw new Error('QUEUE_REDIS_TRANSPORT must be "lists" or "streams".');
|
|
12725
|
+
return transport;
|
|
12726
|
+
}
|
|
12727
|
+
function readQueueVisibilityMs() {
|
|
12728
|
+
const value = Number(process.env.QUEUE_VISIBILITY_MS ?? "60000");
|
|
12729
|
+
if (!Number.isSafeInteger(value) || value < 30)
|
|
12730
|
+
throw new Error("QUEUE_VISIBILITY_MS must be an integer of at least 30 milliseconds.");
|
|
12731
|
+
return value;
|
|
12732
|
+
}
|
|
12571
12733
|
|
|
12572
12734
|
// ../../src/core/queue/jobRunner.ts
|
|
12735
|
+
function parseQueueJobEnvelope(rawPayload) {
|
|
12736
|
+
let parsed;
|
|
12737
|
+
try {
|
|
12738
|
+
parsed = JSON.parse(rawPayload);
|
|
12739
|
+
} catch {
|
|
12740
|
+
console.error("[QueueWorker] Ignoring malformed queue payload");
|
|
12741
|
+
return null;
|
|
12742
|
+
}
|
|
12743
|
+
if (!parsed || typeof parsed !== "object") {
|
|
12744
|
+
console.error("[QueueWorker] Ignoring non-object queue payload");
|
|
12745
|
+
return null;
|
|
12746
|
+
}
|
|
12747
|
+
const envelope = parsed;
|
|
12748
|
+
if (typeof envelope.name !== "string" || envelope.name.length === 0) {
|
|
12749
|
+
console.error("[QueueWorker] Ignoring queue payload without job name");
|
|
12750
|
+
return null;
|
|
12751
|
+
}
|
|
12752
|
+
if (!jobRegistry.create(envelope.name)) {
|
|
12753
|
+
console.error(`[QueueWorker] Ignoring unknown job name: ${envelope.name}`);
|
|
12754
|
+
return null;
|
|
12755
|
+
}
|
|
12756
|
+
if (envelope.payload !== undefined && (typeof envelope.payload !== "object" || envelope.payload === null)) {
|
|
12757
|
+
console.error("[QueueWorker] Ignoring queue payload with invalid payload object");
|
|
12758
|
+
return null;
|
|
12759
|
+
}
|
|
12760
|
+
if (envelope.jobId !== undefined && (typeof envelope.jobId !== "string" || !envelope.jobId.length))
|
|
12761
|
+
return null;
|
|
12762
|
+
if (envelope.attempts !== undefined && (!Number.isSafeInteger(envelope.attempts) || envelope.attempts < 0))
|
|
12763
|
+
return null;
|
|
12764
|
+
if (envelope.deadlineAtMs !== undefined && (!Number.isSafeInteger(envelope.deadlineAtMs) || envelope.deadlineAtMs <= 0))
|
|
12765
|
+
return null;
|
|
12766
|
+
if (envelope.cancellable !== undefined && (typeof envelope.cancellable !== "boolean" || envelope.cancellable && !envelope.jobId))
|
|
12767
|
+
return null;
|
|
12768
|
+
return {
|
|
12769
|
+
...envelope.cancellable ? { cancellable: true } : {},
|
|
12770
|
+
...envelope.deadlineAtMs === undefined ? {} : { deadlineAtMs: envelope.deadlineAtMs },
|
|
12771
|
+
jobId: envelope.jobId,
|
|
12772
|
+
name: envelope.name,
|
|
12773
|
+
payload: envelope.payload ?? {},
|
|
12774
|
+
attempts: typeof envelope.attempts === "number" ? envelope.attempts : 0
|
|
12775
|
+
};
|
|
12776
|
+
}
|
|
12573
12777
|
async function runQueueJob(envelope, failedJobs, options = {}) {
|
|
12778
|
+
options.context?.signal.throwIfAborted();
|
|
12574
12779
|
const job = jobRegistry.create(envelope.name);
|
|
12575
12780
|
if (!job) {
|
|
12576
12781
|
throw new Error(`Unknown job "${envelope.name}".`);
|
|
12577
12782
|
}
|
|
12578
12783
|
const attempts = envelope.attempts ?? 0;
|
|
12579
12784
|
try {
|
|
12580
|
-
await job.handle(envelope.payload);
|
|
12785
|
+
await job.handle(envelope.payload, options.context);
|
|
12581
12786
|
} catch (error) {
|
|
12787
|
+
options.context?.signal.throwIfAborted();
|
|
12582
12788
|
const nextAttempt = attempts + 1;
|
|
12583
12789
|
const maxAttempts = job.maxAttempts ?? queueConfig.maxAttempts;
|
|
12584
12790
|
if (nextAttempt < maxAttempts) {
|
|
12585
12791
|
const backoffMs = job.backoffMs ?? queueConfig.backoffMs;
|
|
12586
|
-
|
|
12792
|
+
if (options.deferRetry) {
|
|
12793
|
+
await options.deferRetry({ ...envelope, attempts: nextAttempt }, backoffMs * nextAttempt);
|
|
12794
|
+
return;
|
|
12795
|
+
}
|
|
12796
|
+
await delay(backoffMs * nextAttempt, undefined, { signal: options.context?.signal });
|
|
12587
12797
|
await runQueueJob({
|
|
12588
12798
|
...envelope,
|
|
12589
12799
|
attempts: nextAttempt
|
|
@@ -12592,6 +12802,7 @@ async function runQueueJob(envelope, failedJobs, options = {}) {
|
|
|
12592
12802
|
}
|
|
12593
12803
|
await failedJobs.recordFailure({
|
|
12594
12804
|
jobName: envelope.name,
|
|
12805
|
+
jobId: options.context?.jobId ?? envelope.jobId,
|
|
12595
12806
|
payload: envelope.payload,
|
|
12596
12807
|
exception: error instanceof Error ? error.stack ?? error.message : String(error)
|
|
12597
12808
|
});
|
|
@@ -12601,7 +12812,9 @@ async function runQueueJob(envelope, failedJobs, options = {}) {
|
|
|
12601
12812
|
}
|
|
12602
12813
|
|
|
12603
12814
|
// ../../src/core/queue/redisQueue.ts
|
|
12604
|
-
var {RedisClient:
|
|
12815
|
+
var {RedisClient: RedisClient4 } = globalThis.Bun;
|
|
12816
|
+
|
|
12817
|
+
// ../../src/core/queue/redisQueueKeys.ts
|
|
12605
12818
|
function queueListKey() {
|
|
12606
12819
|
return namespacedRedisKey("queue:default");
|
|
12607
12820
|
}
|
|
@@ -12614,21 +12827,10 @@ function queueLowKey() {
|
|
|
12614
12827
|
var QUEUE_LIST_KEY = queueListKey();
|
|
12615
12828
|
var QUEUE_HIGH_KEY = queueHighKey();
|
|
12616
12829
|
var QUEUE_LOW_KEY = queueLowKey();
|
|
12617
|
-
|
|
12618
|
-
|
|
12619
|
-
function queueProcessingKey(queueKey) {
|
|
12620
|
-
return `${queueKey}:processing`;
|
|
12621
|
-
}
|
|
12622
|
-
function queueProcessingLeaseKey(queueKey) {
|
|
12623
|
-
return `${queueKey}:processing:leases`;
|
|
12624
|
-
}
|
|
12625
|
-
function readVisibilityMs() {
|
|
12626
|
-
const parsed = Number(process.env.QUEUE_VISIBILITY_MS ?? String(DEFAULT_VISIBILITY_MS));
|
|
12627
|
-
if (!Number.isSafeInteger(parsed) || parsed < 30) {
|
|
12628
|
-
throw new Error("QUEUE_VISIBILITY_MS must be an integer of at least 30 milliseconds.");
|
|
12629
|
-
}
|
|
12630
|
-
return parsed;
|
|
12830
|
+
function defaultQueueKeys() {
|
|
12831
|
+
return [queueHighKey(), queueListKey(), queueLowKey()];
|
|
12631
12832
|
}
|
|
12833
|
+
var QUEUE_KEYS = defaultQueueKeys();
|
|
12632
12834
|
function queueKeyForPriority(priority = "default") {
|
|
12633
12835
|
switch (priority) {
|
|
12634
12836
|
case "high":
|
|
@@ -12639,36 +12841,557 @@ function queueKeyForPriority(priority = "default") {
|
|
|
12639
12841
|
return queueListKey();
|
|
12640
12842
|
}
|
|
12641
12843
|
}
|
|
12642
|
-
|
|
12643
|
-
|
|
12844
|
+
|
|
12845
|
+
// ../../src/core/queue/redisStreams.ts
|
|
12846
|
+
var {RedisClient: RedisClient3 } = globalThis.Bun;
|
|
12847
|
+
var STREAM_GROUP = "strata-workers-v1";
|
|
12848
|
+
function streamQueueKey(queueKey) {
|
|
12849
|
+
return `${queueKey}:stream`;
|
|
12850
|
+
}
|
|
12851
|
+
function streamDeadLetterKey(queueKey) {
|
|
12852
|
+
return `${streamQueueKey(queueKey)}:invalid`;
|
|
12853
|
+
}
|
|
12854
|
+
function streamRetryKey(queueKey) {
|
|
12855
|
+
return `${streamQueueKey(queueKey)}:retries`;
|
|
12856
|
+
}
|
|
12857
|
+
function streamControlKey(key, jobId) {
|
|
12858
|
+
const prefix = `${streamQueueKey(key)}/`;
|
|
12859
|
+
if (!jobId.startsWith(prefix) || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(jobId.slice(prefix.length)))
|
|
12860
|
+
throw new Error("Invalid cancellable Streams job ID");
|
|
12861
|
+
return `${streamQueueKey(key)}:control:${jobId.slice(prefix.length)}`;
|
|
12862
|
+
}
|
|
12863
|
+
var ENQUEUE_CONTROLLED = `
|
|
12864
|
+
local kind = redis.call('TYPE', KEYS[1]).ok
|
|
12865
|
+
if kind ~= 'none' and kind ~= 'stream' then return redis.error_reply('Invalid stream admission key type') end
|
|
12866
|
+
if redis.call('EXISTS', KEYS[2]) ~= 0 then return redis.error_reply('Streams control identity already exists') end
|
|
12867
|
+
redis.call('XADD', KEYS[1], '*', 'payload', ARGV[1])
|
|
12868
|
+
redis.call('HSET', KEYS[2], 'state', 'active')
|
|
12869
|
+
return 1
|
|
12870
|
+
`;
|
|
12871
|
+
var CANCEL_STREAM = `
|
|
12872
|
+
local state = redis.call('HGET', KEYS[1], 'state')
|
|
12873
|
+
if not state then return 0 end
|
|
12874
|
+
local kind = redis.call('TYPE', KEYS[2]).ok
|
|
12875
|
+
if kind ~= 'none' and kind ~= 'zset' then return redis.error_reply('Invalid stream retry key type') end
|
|
12876
|
+
local payload = redis.call('HGET', KEYS[1], 'retryPayload')
|
|
12877
|
+
if payload and redis.call('ZSCORE', KEYS[2], payload) then redis.call('ZADD', KEYS[2], 0, payload) end
|
|
12878
|
+
redis.call('HSET', KEYS[1], 'state', 'cancelled')
|
|
12879
|
+
return 1
|
|
12880
|
+
`;
|
|
12881
|
+
async function cancelStreamJob(client, key, jobId) {
|
|
12882
|
+
return Number(await client.send("EVAL", [
|
|
12883
|
+
CANCEL_STREAM,
|
|
12884
|
+
"2",
|
|
12885
|
+
streamControlKey(key, jobId),
|
|
12886
|
+
streamRetryKey(key)
|
|
12887
|
+
])) === 1;
|
|
12888
|
+
}
|
|
12889
|
+
async function readStreamCancellation(client, controlKey) {
|
|
12890
|
+
const state = await client.send("HGET", [controlKey, "state"]);
|
|
12891
|
+
if (state !== "active" && state !== "cancelled")
|
|
12892
|
+
throw new Error("Missing or invalid Streams control state");
|
|
12893
|
+
return state === "cancelled";
|
|
12894
|
+
}
|
|
12895
|
+
var MAX_TIMER_MS = 2147483647;
|
|
12896
|
+
async function readStreamTime(client) {
|
|
12897
|
+
const value = await client.send("TIME", []);
|
|
12898
|
+
if (!Array.isArray(value) || value.length !== 2 || typeof value[0] !== "string" || typeof value[1] !== "string" || !/^\d+$/.test(value[0]) || !/^\d+$/.test(value[1]))
|
|
12899
|
+
throw new Error("Invalid Redis TIME response");
|
|
12900
|
+
const seconds = Number(value[0]);
|
|
12901
|
+
const micros = Number(value[1]);
|
|
12902
|
+
const now = seconds * 1000 + Math.floor(micros / 1000);
|
|
12903
|
+
if (!Number.isSafeInteger(seconds) || seconds < 0 || !Number.isSafeInteger(micros) || micros < 0 || micros >= 1e6 || !Number.isSafeInteger(now))
|
|
12904
|
+
throw new Error("Invalid Redis TIME response");
|
|
12905
|
+
return now;
|
|
12906
|
+
}
|
|
12907
|
+
function watchStreamDeadline(client, deadlineAtMs, remainingMs, expire, fail) {
|
|
12908
|
+
let stopped = false;
|
|
12909
|
+
let timer;
|
|
12910
|
+
const schedule = (remaining) => {
|
|
12911
|
+
timer = setTimeout(() => {
|
|
12912
|
+
(async () => {
|
|
12913
|
+
try {
|
|
12914
|
+
const remaining = deadlineAtMs - await readStreamTime(client);
|
|
12915
|
+
if (stopped)
|
|
12916
|
+
return;
|
|
12917
|
+
if (remaining <= 0)
|
|
12918
|
+
expire();
|
|
12919
|
+
else
|
|
12920
|
+
schedule(remaining);
|
|
12921
|
+
} catch (error) {
|
|
12922
|
+
if (!stopped)
|
|
12923
|
+
fail(error);
|
|
12924
|
+
}
|
|
12925
|
+
})();
|
|
12926
|
+
}, Math.max(1, Math.min(MAX_TIMER_MS, remaining)));
|
|
12927
|
+
};
|
|
12928
|
+
schedule(remainingMs);
|
|
12929
|
+
return () => {
|
|
12930
|
+
stopped = true;
|
|
12931
|
+
clearTimeout(timer);
|
|
12932
|
+
};
|
|
12933
|
+
}
|
|
12934
|
+
var SCHEDULE_RETRY = `
|
|
12935
|
+
local pending = redis.call('XPENDING', KEYS[1], ARGV[1], ARGV[2], ARGV[2], 1)
|
|
12936
|
+
if #pending == 0 or pending[1][2] ~= ARGV[3] or tonumber(pending[1][3]) >= tonumber(ARGV[4]) then return 0 end
|
|
12937
|
+
local kind = redis.call('TYPE', KEYS[2]).ok
|
|
12938
|
+
if kind ~= 'none' and kind ~= 'zset' then return redis.error_reply('Invalid stream retry key type') end
|
|
12939
|
+
if ARGV[8] == '1' then
|
|
12940
|
+
local state = redis.call('HGET', KEYS[3], 'state')
|
|
12941
|
+
if state == 'cancelled' then return redis.error_reply('Streams job cancelled') end
|
|
12942
|
+
if state ~= 'active' then return redis.error_reply('Missing or invalid Streams control state') end
|
|
12943
|
+
end
|
|
12944
|
+
local now = redis.call('TIME')
|
|
12945
|
+
local due = tonumber(now[1])*1000 + math.floor(tonumber(now[2])/1000) + tonumber(ARGV[5])
|
|
12946
|
+
if ARGV[7] ~= '' then due = math.min(due, tonumber(ARGV[7])) end
|
|
12947
|
+
redis.call('ZADD', KEYS[2], due, ARGV[6])
|
|
12948
|
+
if ARGV[8] == '1' then redis.call('HSET', KEYS[3], 'retryPayload', ARGV[6]) end
|
|
12949
|
+
redis.call('XACK', KEYS[1], ARGV[1], ARGV[2])
|
|
12950
|
+
redis.call('XDEL', KEYS[1], ARGV[2])
|
|
12951
|
+
if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[3]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[3]) end
|
|
12952
|
+
return 1
|
|
12953
|
+
`;
|
|
12954
|
+
var PROMOTE_RETRIES = `
|
|
12955
|
+
local kinds = {'stream','zset'}
|
|
12956
|
+
for i=1,2 do
|
|
12957
|
+
local kind = redis.call('TYPE', KEYS[i]).ok
|
|
12958
|
+
if kind ~= 'none' and kind ~= kinds[i] then return redis.error_reply('Invalid stream retry promotion key type') end
|
|
12959
|
+
end
|
|
12960
|
+
local now = redis.call('TIME')
|
|
12961
|
+
local due = tonumber(now[1])*1000 + math.floor(tonumber(now[2])/1000)
|
|
12962
|
+
local entries = redis.call('ZRANGE', KEYS[2], '-inf', due, 'BYSCORE', 'LIMIT', 0, 100)
|
|
12963
|
+
for _, payload in ipairs(entries) do
|
|
12964
|
+
redis.call('XADD', KEYS[1], '*', 'payload', payload)
|
|
12965
|
+
redis.call('ZREM', KEYS[2], payload)
|
|
12966
|
+
end
|
|
12967
|
+
return #entries
|
|
12968
|
+
`;
|
|
12969
|
+
async function scheduleStreamRetry(client, key, reservation, envelope, delayMs) {
|
|
12970
|
+
if (!Number.isSafeInteger(delayMs) || delayMs < 0 || delayMs > Number.MAX_SAFE_INTEGER / 2)
|
|
12971
|
+
throw new Error("Invalid Streams retry delay");
|
|
12972
|
+
const scheduled = await client.send("EVAL", [
|
|
12973
|
+
SCHEDULE_RETRY,
|
|
12974
|
+
"3",
|
|
12975
|
+
streamQueueKey(key),
|
|
12976
|
+
streamRetryKey(key),
|
|
12977
|
+
envelope.cancellable ? streamControlKey(key, envelope.jobId ?? "") : streamQueueKey(key),
|
|
12978
|
+
STREAM_GROUP,
|
|
12979
|
+
reservation.id,
|
|
12980
|
+
reservation.owner,
|
|
12981
|
+
String(readQueueVisibilityMs()),
|
|
12982
|
+
String(delayMs),
|
|
12983
|
+
JSON.stringify(envelope),
|
|
12984
|
+
envelope.deadlineAtMs === undefined ? "" : String(envelope.deadlineAtMs),
|
|
12985
|
+
envelope.cancellable ? "1" : "0"
|
|
12986
|
+
]);
|
|
12987
|
+
if (Number(scheduled) !== 1)
|
|
12988
|
+
throw new Error("Queue lease lost while scheduling retry");
|
|
12989
|
+
}
|
|
12990
|
+
async function promoteStreamRetries(client, key) {
|
|
12991
|
+
return Number(await client.send("EVAL", [PROMOTE_RETRIES, "2", streamQueueKey(key), streamRetryKey(key)]));
|
|
12992
|
+
}
|
|
12993
|
+
async function readStreamQueueDepth(client, key) {
|
|
12994
|
+
return Number(await client.send("EVAL", [
|
|
12995
|
+
"return redis.call('XLEN', KEYS[1]) + redis.call('ZCARD', KEYS[2])",
|
|
12996
|
+
"2",
|
|
12997
|
+
streamQueueKey(key),
|
|
12998
|
+
streamRetryKey(key)
|
|
12999
|
+
]));
|
|
13000
|
+
}
|
|
13001
|
+
var ASSERT_DRAINED = `
|
|
13002
|
+
if redis.call('LLEN', KEYS[1]) > 0 or redis.call('LLEN', KEYS[2]) > 0 or redis.call('HLEN', KEYS[3]) > 0 then
|
|
13003
|
+
return redis.error_reply('Legacy queue state requires maintenance conversion before Streams activation')
|
|
13004
|
+
end
|
|
13005
|
+
return 1
|
|
13006
|
+
`;
|
|
13007
|
+
async function assertLegacyDrained(client, key) {
|
|
13008
|
+
await client.send("EVAL", [
|
|
13009
|
+
ASSERT_DRAINED,
|
|
13010
|
+
"3",
|
|
13011
|
+
key,
|
|
13012
|
+
`${key}:processing`,
|
|
13013
|
+
`${key}:processing:leases`
|
|
13014
|
+
]);
|
|
13015
|
+
}
|
|
13016
|
+
var UPDATE_STREAM = `
|
|
13017
|
+
local pending = redis.call('XPENDING', KEYS[1], ARGV[1], ARGV[2], ARGV[2], 1)
|
|
13018
|
+
if #pending == 0 or pending[1][2] ~= ARGV[3] or tonumber(pending[1][3]) >= tonumber(ARGV[4]) then return 0 end
|
|
13019
|
+
if ARGV[7] == '1' then
|
|
13020
|
+
local kind = redis.call('TYPE', KEYS[3]).ok
|
|
13021
|
+
if kind ~= 'none' and kind ~= 'hash' then return redis.error_reply('Invalid stream control key type') end
|
|
13022
|
+
end
|
|
13023
|
+
if ARGV[5] == 'renew' then
|
|
13024
|
+
local claimed = redis.call('XCLAIM', KEYS[1], ARGV[1], ARGV[3], 0, ARGV[2], 'JUSTID')
|
|
13025
|
+
return #claimed
|
|
13026
|
+
end
|
|
13027
|
+
if ARGV[5] == 'quarantine' then
|
|
13028
|
+
local kind = redis.call('TYPE', KEYS[2]).ok
|
|
13029
|
+
if kind ~= 'none' and kind ~= 'stream' then return redis.error_reply('Invalid stream quarantine key type') end
|
|
13030
|
+
redis.call('XADD', KEYS[2], '*', 'sourceId', ARGV[2], 'payload', ARGV[6])
|
|
13031
|
+
end
|
|
13032
|
+
local acknowledged = redis.call('XACK', KEYS[1], ARGV[1], ARGV[2])
|
|
13033
|
+
if acknowledged == 1 then
|
|
13034
|
+
redis.call('XDEL', KEYS[1], ARGV[2])
|
|
13035
|
+
if ARGV[7] == '1' then redis.call('DEL', KEYS[3]) end
|
|
13036
|
+
end
|
|
13037
|
+
if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[3]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[3]) end
|
|
13038
|
+
return acknowledged
|
|
13039
|
+
`;
|
|
13040
|
+
var CLAIM_STREAM = `
|
|
13041
|
+
local window = redis.call('XPENDING', KEYS[1], ARGV[1], ARGV[4], '+', 10)
|
|
13042
|
+
local claimed = redis.call('XAUTOCLAIM', KEYS[1], ARGV[1], ARGV[2], ARGV[3], ARGV[4], 'COUNT', 1)
|
|
13043
|
+
for _, entry in ipairs(window) do
|
|
13044
|
+
if entry[2] ~= ARGV[2] and #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, entry[2]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], entry[2]) end
|
|
13045
|
+
end
|
|
13046
|
+
if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[2]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[2]) end
|
|
13047
|
+
return claimed
|
|
13048
|
+
`;
|
|
13049
|
+
var READ_STREAM = `
|
|
13050
|
+
local entries = redis.call('XREADGROUP', 'GROUP', ARGV[1], ARGV[2], 'COUNT', 1, 'STREAMS', KEYS[1], '>')
|
|
13051
|
+
if not entries and #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[2]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[2]) end
|
|
13052
|
+
return entries
|
|
13053
|
+
`;
|
|
13054
|
+
async function ensureStreamGroup(client, key) {
|
|
13055
|
+
await assertLegacyDrained(client, key);
|
|
12644
13056
|
try {
|
|
12645
|
-
|
|
12646
|
-
} catch {
|
|
12647
|
-
|
|
12648
|
-
|
|
13057
|
+
await client.send("XGROUP", ["CREATE", streamQueueKey(key), STREAM_GROUP, "0", "MKSTREAM"]);
|
|
13058
|
+
} catch (error) {
|
|
13059
|
+
if (!(error instanceof Error) || !error.message.includes("BUSYGROUP"))
|
|
13060
|
+
throw error;
|
|
12649
13061
|
}
|
|
12650
|
-
|
|
12651
|
-
|
|
12652
|
-
|
|
13062
|
+
}
|
|
13063
|
+
function readStreamEntry(value, owner) {
|
|
13064
|
+
if (!Array.isArray(value) || typeof value[0] !== "string" || !Array.isArray(value[1]))
|
|
13065
|
+
throw new Error("Invalid Redis stream entry response");
|
|
13066
|
+
const fields = value[1];
|
|
13067
|
+
let payload;
|
|
13068
|
+
for (let index = 0;index < fields.length; index += 2) {
|
|
13069
|
+
if (fields[index] === "payload" && typeof fields[index + 1] === "string")
|
|
13070
|
+
payload = fields[index + 1];
|
|
12653
13071
|
}
|
|
12654
|
-
|
|
12655
|
-
|
|
12656
|
-
|
|
12657
|
-
|
|
13072
|
+
return { id: value[0], owner, payload: payload ?? "" };
|
|
13073
|
+
}
|
|
13074
|
+
async function updateStreamReservation(client, key, reservation, action, controlKey) {
|
|
13075
|
+
return Number(await client.send("EVAL", [
|
|
13076
|
+
UPDATE_STREAM,
|
|
13077
|
+
"3",
|
|
13078
|
+
streamQueueKey(key),
|
|
13079
|
+
streamDeadLetterKey(key),
|
|
13080
|
+
controlKey ?? streamQueueKey(key),
|
|
13081
|
+
STREAM_GROUP,
|
|
13082
|
+
reservation.id,
|
|
13083
|
+
reservation.owner,
|
|
13084
|
+
String(readQueueVisibilityMs()),
|
|
13085
|
+
action,
|
|
13086
|
+
reservation.payload,
|
|
13087
|
+
controlKey ? "1" : "0"
|
|
13088
|
+
])) === 1;
|
|
13089
|
+
}
|
|
13090
|
+
async function reserveStreamJob(client, key, owner, cursor = "0-0") {
|
|
13091
|
+
const claimed = await client.send("EVAL", [
|
|
13092
|
+
CLAIM_STREAM,
|
|
13093
|
+
"1",
|
|
13094
|
+
streamQueueKey(key),
|
|
13095
|
+
STREAM_GROUP,
|
|
13096
|
+
owner,
|
|
13097
|
+
String(readQueueVisibilityMs()),
|
|
13098
|
+
cursor
|
|
13099
|
+
]);
|
|
13100
|
+
if (!Array.isArray(claimed) || typeof claimed[0] !== "string" || !Array.isArray(claimed[1]))
|
|
13101
|
+
throw new Error("Invalid Redis XAUTOCLAIM response");
|
|
13102
|
+
const nextCursor = claimed[0];
|
|
13103
|
+
if (claimed[1].length > 0)
|
|
13104
|
+
return { reservation: readStreamEntry(claimed[1][0], owner), cursor: nextCursor };
|
|
13105
|
+
const fresh = await client.send("EVAL", [
|
|
13106
|
+
READ_STREAM,
|
|
13107
|
+
"1",
|
|
13108
|
+
streamQueueKey(key),
|
|
13109
|
+
STREAM_GROUP,
|
|
13110
|
+
owner
|
|
13111
|
+
]);
|
|
13112
|
+
if (fresh === null)
|
|
13113
|
+
return { reservation: null, cursor: nextCursor };
|
|
13114
|
+
if (!Array.isArray(fresh) || !Array.isArray(fresh[0]) || !Array.isArray(fresh[0][1]))
|
|
13115
|
+
throw new Error("Invalid Redis XREADGROUP response");
|
|
13116
|
+
return { reservation: readStreamEntry(fresh[0][1][0], owner), cursor: nextCursor };
|
|
13117
|
+
}
|
|
13118
|
+
|
|
13119
|
+
class RedisStreamsQueue {
|
|
13120
|
+
client;
|
|
13121
|
+
constructor(redisUrl) {
|
|
13122
|
+
this.client = new RedisClient3(redisUrl);
|
|
12658
13123
|
}
|
|
12659
|
-
|
|
12660
|
-
|
|
12661
|
-
return null;
|
|
13124
|
+
close() {
|
|
13125
|
+
this.client.close();
|
|
12662
13126
|
}
|
|
12663
|
-
|
|
12664
|
-
|
|
12665
|
-
|
|
13127
|
+
async dispatch(job, payload) {
|
|
13128
|
+
await this.enqueue(job, payload);
|
|
13129
|
+
}
|
|
13130
|
+
async enqueue(job, payload, options = {}) {
|
|
13131
|
+
return this.admit(job, payload, options);
|
|
13132
|
+
}
|
|
13133
|
+
async replay(job, payload, jobId) {
|
|
13134
|
+
const prefix = defaultQueueKeys().find((key) => jobId.startsWith(`${streamQueueKey(key)}/`));
|
|
13135
|
+
if (!prefix || !/^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9]+-[0-9]+)$/.test(jobId.slice(streamQueueKey(prefix).length + 1)))
|
|
13136
|
+
throw new Error("Replay identity does not belong to this Streams namespace");
|
|
13137
|
+
await this.admit(job, payload, {}, jobId);
|
|
13138
|
+
}
|
|
13139
|
+
async admit(job, payload, options, replayId) {
|
|
13140
|
+
if (options.cancellable !== undefined && typeof options.cancellable !== "boolean")
|
|
13141
|
+
throw new Error("cancellable must be a boolean");
|
|
13142
|
+
if (options.timeoutMs !== undefined && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > MAX_TIMER_MS))
|
|
13143
|
+
throw new Error("Streams timeoutMs must be an integer between 1 and 2147483647");
|
|
13144
|
+
const name = jobRegistry.resolveName(job);
|
|
13145
|
+
if (!name)
|
|
13146
|
+
throw new Error("Job is not registered with the queue worker registry.");
|
|
13147
|
+
const key = queueKeyForPriority(job.priority);
|
|
13148
|
+
await assertLegacyDrained(this.client, key);
|
|
13149
|
+
const jobId = replayId ?? `${streamQueueKey(key)}/${crypto.randomUUID()}`;
|
|
13150
|
+
const deadlineAtMs = options.timeoutMs === undefined ? undefined : await readStreamTime(this.client) + options.timeoutMs;
|
|
13151
|
+
const envelope = JSON.stringify({
|
|
13152
|
+
name,
|
|
13153
|
+
payload,
|
|
13154
|
+
attempts: 0,
|
|
13155
|
+
jobId,
|
|
13156
|
+
deadlineAtMs,
|
|
13157
|
+
...options.cancellable ? { cancellable: true } : {}
|
|
13158
|
+
});
|
|
13159
|
+
if (options.cancellable)
|
|
13160
|
+
await this.client.send("EVAL", [
|
|
13161
|
+
ENQUEUE_CONTROLLED,
|
|
13162
|
+
"2",
|
|
13163
|
+
streamQueueKey(key),
|
|
13164
|
+
streamControlKey(key, jobId),
|
|
13165
|
+
envelope
|
|
13166
|
+
]);
|
|
13167
|
+
else
|
|
13168
|
+
await this.client.send("XADD", [streamQueueKey(key), "*", "payload", envelope]);
|
|
13169
|
+
return jobId;
|
|
13170
|
+
}
|
|
13171
|
+
async cancel(jobId) {
|
|
13172
|
+
const key = defaultQueueKeys().find((key) => jobId.startsWith(`${streamQueueKey(key)}/`));
|
|
13173
|
+
if (!key)
|
|
13174
|
+
throw new Error("Invalid cancellable Streams job ID");
|
|
13175
|
+
return cancelStreamJob(this.client, key, jobId);
|
|
13176
|
+
}
|
|
13177
|
+
}
|
|
13178
|
+
|
|
13179
|
+
class RedisStreamsWorker {
|
|
13180
|
+
failedJobs;
|
|
13181
|
+
timeoutSeconds;
|
|
13182
|
+
queueKeys;
|
|
13183
|
+
client;
|
|
13184
|
+
owner = crypto.randomUUID();
|
|
13185
|
+
cursors = new Map;
|
|
13186
|
+
processing = false;
|
|
13187
|
+
initialized = false;
|
|
13188
|
+
running = false;
|
|
13189
|
+
stopping = false;
|
|
13190
|
+
active;
|
|
13191
|
+
constructor(redisUrl, failedJobs, timeoutSeconds = 5, queueKeys = defaultQueueKeys()) {
|
|
13192
|
+
this.failedJobs = failedJobs;
|
|
13193
|
+
this.timeoutSeconds = timeoutSeconds;
|
|
13194
|
+
this.queueKeys = queueKeys;
|
|
13195
|
+
readQueueVisibilityMs();
|
|
13196
|
+
this.client = new RedisClient3(redisUrl);
|
|
13197
|
+
}
|
|
13198
|
+
requestStop() {
|
|
13199
|
+
this.stopping = true;
|
|
13200
|
+
}
|
|
13201
|
+
isRunning() {
|
|
13202
|
+
return this.running;
|
|
13203
|
+
}
|
|
13204
|
+
close() {
|
|
13205
|
+
this.stopping = true;
|
|
13206
|
+
this.active?.abort(new Error("Queue worker closed"));
|
|
13207
|
+
this.client.close();
|
|
13208
|
+
}
|
|
13209
|
+
async initialize() {
|
|
13210
|
+
if (this.initialized)
|
|
13211
|
+
return;
|
|
13212
|
+
for (const key of this.queueKeys)
|
|
13213
|
+
await ensureStreamGroup(this.client, key);
|
|
13214
|
+
this.initialized = true;
|
|
13215
|
+
}
|
|
13216
|
+
async reserve(key) {
|
|
13217
|
+
await promoteStreamRetries(this.client, key);
|
|
13218
|
+
const result = await reserveStreamJob(this.client, key, this.owner, this.cursors.get(key));
|
|
13219
|
+
this.cursors.set(key, result.cursor);
|
|
13220
|
+
return result.reservation;
|
|
13221
|
+
}
|
|
13222
|
+
async processNext() {
|
|
13223
|
+
if (this.processing)
|
|
13224
|
+
throw new Error("Queue worker already processing a job.");
|
|
13225
|
+
this.processing = true;
|
|
13226
|
+
try {
|
|
13227
|
+
return await this.processOne();
|
|
13228
|
+
} finally {
|
|
13229
|
+
this.processing = false;
|
|
13230
|
+
}
|
|
13231
|
+
}
|
|
13232
|
+
async processOne() {
|
|
13233
|
+
if (this.stopping)
|
|
13234
|
+
return false;
|
|
13235
|
+
await this.initialize();
|
|
13236
|
+
const deadline = performance.now() + this.timeoutSeconds * 1000;
|
|
13237
|
+
do {
|
|
13238
|
+
for (const key of this.queueKeys) {
|
|
13239
|
+
if (this.stopping)
|
|
13240
|
+
return false;
|
|
13241
|
+
const reservation = await this.reserve(key);
|
|
13242
|
+
if (!reservation)
|
|
13243
|
+
continue;
|
|
13244
|
+
await this.handle(key, reservation);
|
|
13245
|
+
return true;
|
|
13246
|
+
}
|
|
13247
|
+
if (performance.now() >= deadline || this.stopping)
|
|
13248
|
+
return false;
|
|
13249
|
+
await Bun.sleep(Math.min(100, Math.max(1, deadline - performance.now())));
|
|
13250
|
+
} while (!this.stopping);
|
|
13251
|
+
return false;
|
|
13252
|
+
}
|
|
13253
|
+
async handle(key, reservation) {
|
|
13254
|
+
const envelope = parseQueueJobEnvelope(reservation.payload);
|
|
13255
|
+
if (!envelope) {
|
|
13256
|
+
await updateStreamReservation(this.client, key, reservation, "quarantine");
|
|
13257
|
+
return;
|
|
13258
|
+
}
|
|
13259
|
+
envelope.jobId ??= `${streamQueueKey(key)}/${reservation.id}`;
|
|
13260
|
+
let controlKey;
|
|
13261
|
+
if (envelope.cancellable) {
|
|
13262
|
+
try {
|
|
13263
|
+
controlKey = streamControlKey(key, envelope.jobId);
|
|
13264
|
+
} catch {
|
|
13265
|
+
await updateStreamReservation(this.client, key, reservation, "quarantine");
|
|
13266
|
+
return;
|
|
13267
|
+
}
|
|
13268
|
+
}
|
|
13269
|
+
const cancellationError = new Error("Streams job cancelled");
|
|
13270
|
+
if (controlKey && await readStreamCancellation(this.client, controlKey)) {
|
|
13271
|
+
await this.failedJobs.recordFailure({
|
|
13272
|
+
jobName: envelope.name,
|
|
13273
|
+
jobId: envelope.jobId,
|
|
13274
|
+
payload: envelope.payload,
|
|
13275
|
+
exception: cancellationError.message
|
|
13276
|
+
});
|
|
13277
|
+
await updateStreamReservation(this.client, key, reservation, "ack", controlKey);
|
|
13278
|
+
return;
|
|
13279
|
+
}
|
|
13280
|
+
const remainingMs = envelope.deadlineAtMs === undefined ? undefined : envelope.deadlineAtMs - await readStreamTime(this.client);
|
|
13281
|
+
const deadlineError = new Error("Streams job deadline exceeded");
|
|
13282
|
+
if (remainingMs !== undefined && remainingMs <= 0) {
|
|
13283
|
+
await this.failedJobs.recordFailure({
|
|
13284
|
+
jobName: envelope.name,
|
|
13285
|
+
jobId: envelope.jobId,
|
|
13286
|
+
payload: envelope.payload,
|
|
13287
|
+
exception: deadlineError.message
|
|
13288
|
+
});
|
|
13289
|
+
await updateStreamReservation(this.client, key, reservation, "ack", controlKey);
|
|
13290
|
+
return;
|
|
13291
|
+
}
|
|
13292
|
+
const controller = new AbortController;
|
|
13293
|
+
this.active = controller;
|
|
13294
|
+
let acknowledge = false;
|
|
13295
|
+
let released = false;
|
|
13296
|
+
let ownershipLost = false;
|
|
13297
|
+
let renewal;
|
|
13298
|
+
let deadlineExpired = false;
|
|
13299
|
+
let cancellationRequested = false;
|
|
13300
|
+
let failureRecorded = false;
|
|
13301
|
+
const loseOwnership = (error) => {
|
|
13302
|
+
ownershipLost = true;
|
|
13303
|
+
controller.abort(error);
|
|
13304
|
+
};
|
|
13305
|
+
const stopDeadlineWatch = remainingMs === undefined || envelope.deadlineAtMs === undefined ? undefined : watchStreamDeadline(this.client, envelope.deadlineAtMs, remainingMs, () => {
|
|
13306
|
+
deadlineExpired = true;
|
|
13307
|
+
controller.abort(deadlineError);
|
|
13308
|
+
}, loseOwnership);
|
|
13309
|
+
const heartbeat = setInterval(() => {
|
|
13310
|
+
if (renewal || ownershipLost || released)
|
|
13311
|
+
return;
|
|
13312
|
+
renewal = (async () => {
|
|
13313
|
+
try {
|
|
13314
|
+
if (!await updateStreamReservation(this.client, key, reservation, "renew", controlKey))
|
|
13315
|
+
loseOwnership(new Error("Queue lease lost"));
|
|
13316
|
+
else if (controlKey && await readStreamCancellation(this.client, controlKey)) {
|
|
13317
|
+
cancellationRequested = true;
|
|
13318
|
+
controller.abort(cancellationError);
|
|
13319
|
+
}
|
|
13320
|
+
} catch (error) {
|
|
13321
|
+
loseOwnership(error);
|
|
13322
|
+
console.error("[StreamsWorker] Lease renewal failed:", error);
|
|
13323
|
+
}
|
|
13324
|
+
})().finally(() => {
|
|
13325
|
+
renewal = undefined;
|
|
13326
|
+
});
|
|
13327
|
+
}, Math.max(1, Math.floor(readQueueVisibilityMs() / 3)));
|
|
13328
|
+
try {
|
|
13329
|
+
await runQueueJob(envelope, this.failedJobs, {
|
|
13330
|
+
context: {
|
|
13331
|
+
jobId: envelope.jobId,
|
|
13332
|
+
signal: controller.signal,
|
|
13333
|
+
deadlineAtMs: envelope.deadlineAtMs
|
|
13334
|
+
},
|
|
13335
|
+
deferRetry: async (next, delayMs) => {
|
|
13336
|
+
controller.signal.throwIfAborted();
|
|
13337
|
+
await scheduleStreamRetry(this.client, key, reservation, next, delayMs);
|
|
13338
|
+
released = true;
|
|
13339
|
+
},
|
|
13340
|
+
onFailureRecorded: () => {
|
|
13341
|
+
acknowledge = true;
|
|
13342
|
+
failureRecorded = true;
|
|
13343
|
+
}
|
|
13344
|
+
});
|
|
13345
|
+
acknowledge = true;
|
|
13346
|
+
} catch (error) {
|
|
13347
|
+
console.error("[StreamsWorker] Job failed:", error);
|
|
13348
|
+
} finally {
|
|
13349
|
+
stopDeadlineWatch?.();
|
|
13350
|
+
clearInterval(heartbeat);
|
|
13351
|
+
await renewal;
|
|
13352
|
+
this.active = undefined;
|
|
13353
|
+
}
|
|
13354
|
+
if (!ownershipLost && !released && controlKey && (cancellationRequested || await readStreamCancellation(this.client, controlKey))) {
|
|
13355
|
+
if (!failureRecorded)
|
|
13356
|
+
await this.failedJobs.recordFailure({
|
|
13357
|
+
jobName: envelope.name,
|
|
13358
|
+
jobId: envelope.jobId,
|
|
13359
|
+
payload: envelope.payload,
|
|
13360
|
+
exception: cancellationError.message
|
|
13361
|
+
});
|
|
13362
|
+
failureRecorded = true;
|
|
13363
|
+
acknowledge = true;
|
|
13364
|
+
}
|
|
13365
|
+
if (!ownershipLost && !released && envelope.deadlineAtMs !== undefined && (deadlineExpired || envelope.deadlineAtMs <= await readStreamTime(this.client))) {
|
|
13366
|
+
if (!failureRecorded)
|
|
13367
|
+
await this.failedJobs.recordFailure({
|
|
13368
|
+
jobName: envelope.name,
|
|
13369
|
+
jobId: envelope.jobId,
|
|
13370
|
+
payload: envelope.payload,
|
|
13371
|
+
exception: deadlineError.message
|
|
13372
|
+
});
|
|
13373
|
+
acknowledge = true;
|
|
13374
|
+
}
|
|
13375
|
+
if (acknowledge && !ownershipLost && !released)
|
|
13376
|
+
await updateStreamReservation(this.client, key, reservation, "ack", controlKey);
|
|
13377
|
+
}
|
|
13378
|
+
async run() {
|
|
13379
|
+
this.running = true;
|
|
13380
|
+
try {
|
|
13381
|
+
while (!this.stopping)
|
|
13382
|
+
await this.processNext();
|
|
13383
|
+
} finally {
|
|
13384
|
+
this.running = false;
|
|
13385
|
+
}
|
|
12666
13386
|
}
|
|
12667
|
-
|
|
12668
|
-
|
|
12669
|
-
|
|
12670
|
-
|
|
12671
|
-
}
|
|
13387
|
+
}
|
|
13388
|
+
|
|
13389
|
+
// ../../src/core/queue/redisQueue.ts
|
|
13390
|
+
function queueProcessingKey(queueKey) {
|
|
13391
|
+
return `${queueKey}:processing`;
|
|
13392
|
+
}
|
|
13393
|
+
function queueProcessingLeaseKey(queueKey) {
|
|
13394
|
+
return `${queueKey}:processing:leases`;
|
|
12672
13395
|
}
|
|
12673
13396
|
var SCRIPT_HEADER = `
|
|
12674
13397
|
local function checkType(key, expected)
|
|
@@ -12757,7 +13480,7 @@ async function reserveQueueJob(client, queueKey, owner) {
|
|
|
12757
13480
|
queueProcessingLeaseKey(queueKey),
|
|
12758
13481
|
crypto.randomUUID(),
|
|
12759
13482
|
owner,
|
|
12760
|
-
String(
|
|
13483
|
+
String(readQueueVisibilityMs())
|
|
12761
13484
|
]);
|
|
12762
13485
|
if (typeof raw !== "string")
|
|
12763
13486
|
return null;
|
|
@@ -12775,7 +13498,7 @@ async function updateQueueReservation(client, queueKey, reservation, action) {
|
|
|
12775
13498
|
reservation.owner,
|
|
12776
13499
|
reservation.record,
|
|
12777
13500
|
action,
|
|
12778
|
-
String(
|
|
13501
|
+
String(readQueueVisibilityMs()),
|
|
12779
13502
|
reservation.payload
|
|
12780
13503
|
])) === 1;
|
|
12781
13504
|
}
|
|
@@ -12788,7 +13511,7 @@ async function reclaimExpiredQueueReservations(client, queueKeys = QUEUE_KEYS) {
|
|
|
12788
13511
|
key,
|
|
12789
13512
|
queueProcessingKey(key),
|
|
12790
13513
|
queueProcessingLeaseKey(key),
|
|
12791
|
-
String(
|
|
13514
|
+
String(readQueueVisibilityMs())
|
|
12792
13515
|
]));
|
|
12793
13516
|
}
|
|
12794
13517
|
return reclaimed;
|
|
@@ -12797,7 +13520,7 @@ async function reclaimExpiredQueueReservations(client, queueKeys = QUEUE_KEYS) {
|
|
|
12797
13520
|
class RedisQueue {
|
|
12798
13521
|
client;
|
|
12799
13522
|
constructor(redisUrl) {
|
|
12800
|
-
this.client = new
|
|
13523
|
+
this.client = new RedisClient4(redisUrl);
|
|
12801
13524
|
}
|
|
12802
13525
|
close() {
|
|
12803
13526
|
this.client.close();
|
|
@@ -12829,7 +13552,7 @@ class QueueWorker {
|
|
|
12829
13552
|
this.failedJobs = failedJobs;
|
|
12830
13553
|
this.timeoutSeconds = timeoutSeconds;
|
|
12831
13554
|
this.queueKeys = queueKeys;
|
|
12832
|
-
this.client = new
|
|
13555
|
+
this.client = new RedisClient4(redisUrl);
|
|
12833
13556
|
}
|
|
12834
13557
|
requestStop() {
|
|
12835
13558
|
this.stopping = true;
|
|
@@ -12884,7 +13607,7 @@ class QueueWorker {
|
|
|
12884
13607
|
})().finally(() => {
|
|
12885
13608
|
renewal = undefined;
|
|
12886
13609
|
});
|
|
12887
|
-
}, Math.max(1, Math.floor(
|
|
13610
|
+
}, Math.max(1, Math.floor(readQueueVisibilityMs() / 3)));
|
|
12888
13611
|
try {
|
|
12889
13612
|
await runQueueJob(envelope, this.failedJobs, {
|
|
12890
13613
|
onFailureRecorded: () => {
|
|
@@ -12962,22 +13685,30 @@ function createProductionQueue(driver, options = {}) {
|
|
|
12962
13685
|
if (!options.redisUrl) {
|
|
12963
13686
|
throw new Error('QUEUE_DRIVER="redis" requires REDIS_URL to be set.');
|
|
12964
13687
|
}
|
|
12965
|
-
return new RedisQueue(options.redisUrl);
|
|
13688
|
+
return resolveRedisQueueTransport() === "streams" ? new RedisStreamsQueue(options.redisUrl) : new RedisQueue(options.redisUrl);
|
|
12966
13689
|
}
|
|
12967
13690
|
return new ResilientQueue(failedJobs, driver === "async");
|
|
12968
13691
|
}
|
|
12969
13692
|
function createQueueWorker(redisUrl, failedJobs = createFailedJobService()) {
|
|
12970
|
-
return new QueueWorker(redisUrl, failedJobs);
|
|
13693
|
+
return resolveRedisQueueTransport() === "streams" ? new RedisStreamsWorker(redisUrl, failedJobs) : new QueueWorker(redisUrl, failedJobs);
|
|
12971
13694
|
}
|
|
12972
13695
|
// ../../src/core/queue/queueMetrics.ts
|
|
12973
|
-
var {RedisClient:
|
|
13696
|
+
var {RedisClient: RedisClient5 } = globalThis.Bun;
|
|
12974
13697
|
async function readRedisQueueDepth(redisUrl) {
|
|
12975
|
-
const client = new
|
|
12976
|
-
|
|
12977
|
-
|
|
12978
|
-
|
|
12979
|
-
|
|
12980
|
-
|
|
13698
|
+
const client = new RedisClient5(redisUrl);
|
|
13699
|
+
let high;
|
|
13700
|
+
let defaultQueue;
|
|
13701
|
+
let low;
|
|
13702
|
+
try {
|
|
13703
|
+
const readDepth = async (key) => resolveRedisQueueTransport() === "streams" ? readStreamQueueDepth(client, key) : client.llen(key);
|
|
13704
|
+
[high, defaultQueue, low] = await Promise.all([
|
|
13705
|
+
readDepth(defaultQueueKeys()[0]),
|
|
13706
|
+
readDepth(defaultQueueKeys()[1]),
|
|
13707
|
+
readDepth(defaultQueueKeys()[2])
|
|
13708
|
+
]);
|
|
13709
|
+
} finally {
|
|
13710
|
+
client.close();
|
|
13711
|
+
}
|
|
12981
13712
|
return {
|
|
12982
13713
|
high: Number(high ?? 0),
|
|
12983
13714
|
default: Number(defaultQueue ?? 0),
|
|
@@ -13025,7 +13756,7 @@ import { createHash as createHash6 } from "crypto";
|
|
|
13025
13756
|
|
|
13026
13757
|
// ../../src/core/scheduler/leases.ts
|
|
13027
13758
|
import { createHash as createHash5 } from "crypto";
|
|
13028
|
-
var {RedisClient:
|
|
13759
|
+
var {RedisClient: RedisClient6 } = globalThis.Bun;
|
|
13029
13760
|
var LEASE_SCRIPT = `
|
|
13030
13761
|
for _, key in ipairs(KEYS) do
|
|
13031
13762
|
local kind = redis.call('TYPE', key).ok
|
|
@@ -13068,7 +13799,7 @@ class RedisSchedulerLeaseStore {
|
|
|
13068
13799
|
if (options.client)
|
|
13069
13800
|
this.client = options.client;
|
|
13070
13801
|
else {
|
|
13071
|
-
const client = new
|
|
13802
|
+
const client = new RedisClient6(options.redisUrl, { autoReconnect: false, maxRetries: 0 });
|
|
13072
13803
|
this.owned = client;
|
|
13073
13804
|
this.client = client;
|
|
13074
13805
|
}
|
|
@@ -13465,6 +14196,53 @@ init_tenantDatabaseScope();
|
|
|
13465
14196
|
import { createHash as createHash7 } from "crypto";
|
|
13466
14197
|
init_repositoryConnection();
|
|
13467
14198
|
init_databaseTenantContext();
|
|
14199
|
+
|
|
14200
|
+
// ../../src/core/tenant/publicTenantHost.ts
|
|
14201
|
+
import { BlockList, isIP } from "net";
|
|
14202
|
+
function normalizeTenantHostname(authority) {
|
|
14203
|
+
if (!authority || authority !== authority.trim() || /[\s\\/@?#%,]/.test(authority)) {
|
|
14204
|
+
throw new ForbiddenError("Invalid tenant hostname.");
|
|
14205
|
+
}
|
|
14206
|
+
let url;
|
|
14207
|
+
try {
|
|
14208
|
+
url = new URL(`http://${authority}`);
|
|
14209
|
+
} catch {
|
|
14210
|
+
throw new ForbiddenError("Invalid tenant hostname.");
|
|
14211
|
+
}
|
|
14212
|
+
const hostname = url.hostname.toLowerCase().replace(/\.$/, "");
|
|
14213
|
+
const ip = hostname.startsWith("[") ? hostname.slice(1, -1) : hostname;
|
|
14214
|
+
if (!isIP(ip) && (hostname.length > 253 || !hostname.split(".").every((label) => label.length > 0 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)))) {
|
|
14215
|
+
throw new ForbiddenError("Invalid tenant hostname.");
|
|
14216
|
+
}
|
|
14217
|
+
return hostname;
|
|
14218
|
+
}
|
|
14219
|
+
function createPublicTenantSelector(options) {
|
|
14220
|
+
const addresses = options.trustedProxyAddresses ?? [];
|
|
14221
|
+
if (addresses.length > 128)
|
|
14222
|
+
throw new Error("Public tenancy supports at most 128 trusted proxy addresses.");
|
|
14223
|
+
const proxies = new BlockList;
|
|
14224
|
+
for (const address of addresses) {
|
|
14225
|
+
const version = isIP(address);
|
|
14226
|
+
if (!version)
|
|
14227
|
+
throw new Error("Trusted proxy addresses must be literal IP addresses.");
|
|
14228
|
+
proxies.addAddress(address, version === 6 ? "ipv6" : "ipv4");
|
|
14229
|
+
}
|
|
14230
|
+
return async (request) => {
|
|
14231
|
+
const peer = currentRequestMeta().peerAddress;
|
|
14232
|
+
const version = peer ? isIP(peer) : 0;
|
|
14233
|
+
const trusted = peer && version && proxies.check(peer, version === 6 ? "ipv6" : "ipv4");
|
|
14234
|
+
const forwarded = trusted ? request.headers.get("x-forwarded-host") : null;
|
|
14235
|
+
const authority = forwarded ?? request.headers.get("host") ?? new URL(request.url).host;
|
|
14236
|
+
const id = await options.resolveTenantId(normalizeTenantHostname(authority));
|
|
14237
|
+
if (id === null)
|
|
14238
|
+
throw new ForbiddenError("Tenant host not found.");
|
|
14239
|
+
if (!Number.isSafeInteger(id) || id <= 0)
|
|
14240
|
+
throw new Error("Public tenant resolver returned an invalid identity.");
|
|
14241
|
+
return id;
|
|
14242
|
+
};
|
|
14243
|
+
}
|
|
14244
|
+
|
|
14245
|
+
// ../../src/core/tenant/tenantMiddleware.ts
|
|
13468
14246
|
init_tenancyConfig();
|
|
13469
14247
|
init_tenantContext();
|
|
13470
14248
|
init_tenantDatabaseScope();
|
|
@@ -13489,16 +14267,17 @@ async function resolveUserTenantId(userId) {
|
|
|
13489
14267
|
function auditChecksum(payload) {
|
|
13490
14268
|
return createHash7("sha256").update(JSON.stringify(payload)).digest("hex");
|
|
13491
14269
|
}
|
|
13492
|
-
async function resolveTenantForRequest(request, resolver) {
|
|
14270
|
+
async function resolveTenantForRequest(request, resolver, selectPublicTenant) {
|
|
14271
|
+
const hostTenantId = selectPublicTenant ? await selectPublicTenant(request) : null;
|
|
13493
14272
|
const user = currentAuthUser();
|
|
13494
14273
|
const headerValue = request.headers.get("x-tenant-id")?.trim();
|
|
13495
|
-
const parsedHeader = headerValue !== undefined && headerValue.length > 0 ? Number
|
|
14274
|
+
const parsedHeader = headerValue !== undefined && headerValue.length > 0 ? Number(headerValue) : Number.NaN;
|
|
13496
14275
|
if (user) {
|
|
13497
14276
|
const userId = typeof user.id === "number" ? user.id : Number.parseInt(String(user.id), 10);
|
|
13498
14277
|
if (Number.isInteger(userId) && userId > 0) {
|
|
13499
14278
|
const userTenantId = await resolveUserTenantId(userId);
|
|
13500
14279
|
if (!isGlobalAdmin(user)) {
|
|
13501
|
-
if (Number.
|
|
14280
|
+
if (Number.isSafeInteger(parsedHeader) && parsedHeader > 0 && parsedHeader !== userTenantId) {
|
|
13502
14281
|
throw new ForbiddenError("Tenant header does not match your account.");
|
|
13503
14282
|
}
|
|
13504
14283
|
const memberTenant = await resolver(userTenantId);
|
|
@@ -13507,7 +14286,7 @@ async function resolveTenantForRequest(request, resolver) {
|
|
|
13507
14286
|
}
|
|
13508
14287
|
return DEFAULT_TENANT;
|
|
13509
14288
|
}
|
|
13510
|
-
if (Number.
|
|
14289
|
+
if (Number.isSafeInteger(parsedHeader) && parsedHeader > 0) {
|
|
13511
14290
|
const headerTenant = await resolver(parsedHeader);
|
|
13512
14291
|
if (headerTenant) {
|
|
13513
14292
|
return headerTenant;
|
|
@@ -13521,9 +14300,14 @@ async function resolveTenantForRequest(request, resolver) {
|
|
|
13521
14300
|
return DEFAULT_TENANT;
|
|
13522
14301
|
}
|
|
13523
14302
|
}
|
|
13524
|
-
const headerTenantId = Number.
|
|
13525
|
-
const tenantId =
|
|
14303
|
+
const headerTenantId = Number.isSafeInteger(parsedHeader) && parsedHeader > 0 ? parsedHeader : null;
|
|
14304
|
+
const tenantId = hostTenantId !== null ? hostTenantId : !isProductionEnv() && process.env.TENANT_DEV_HEADERS === "true" && headerTenantId !== null ? headerTenantId : DEFAULT_TENANT.id;
|
|
14305
|
+
if (isProductionEnv() && !selectPublicTenant) {
|
|
14306
|
+
throw new ForbiddenError("Public tenant host resolver is required.");
|
|
14307
|
+
}
|
|
13526
14308
|
const guestTenant = await resolver(tenantId);
|
|
14309
|
+
if (selectPublicTenant && !guestTenant)
|
|
14310
|
+
throw new ForbiddenError("Tenant not found.");
|
|
13527
14311
|
if (guestTenant) {
|
|
13528
14312
|
return guestTenant;
|
|
13529
14313
|
}
|
|
@@ -13531,6 +14315,7 @@ async function resolveTenantForRequest(request, resolver) {
|
|
|
13531
14315
|
}
|
|
13532
14316
|
function createTenantMiddleware(options = {}) {
|
|
13533
14317
|
const resolver = options.resolveTenant ?? resolveTenant;
|
|
14318
|
+
const selectPublicTenant = options.publicTenancy ? createPublicTenantSelector(options.publicTenancy) : undefined;
|
|
13534
14319
|
return async (request, next) => {
|
|
13535
14320
|
const pathname = new URL(request.url).pathname;
|
|
13536
14321
|
if (pathname.startsWith("/scim/")) {
|
|
@@ -13549,7 +14334,7 @@ function createTenantMiddleware(options = {}) {
|
|
|
13549
14334
|
throw new Error("Tenant resolver returned a different tenant identity.");
|
|
13550
14335
|
}
|
|
13551
14336
|
return tenant;
|
|
13552
|
-
});
|
|
14337
|
+
}, selectPublicTenant);
|
|
13553
14338
|
return await runWithTenantDatabase(tenant, async () => {
|
|
13554
14339
|
return await next();
|
|
13555
14340
|
});
|
|
@@ -14424,6 +15209,7 @@ export {
|
|
|
14424
15209
|
namespacedRedisKey,
|
|
14425
15210
|
noContentResponse,
|
|
14426
15211
|
normalizeFieldErrors,
|
|
15212
|
+
normalizeTenantHostname,
|
|
14427
15213
|
notFoundHtmlResponse,
|
|
14428
15214
|
notModifiedResponse,
|
|
14429
15215
|
otelServiceName,
|