@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/core/contracts/serviceTokens.d.ts +3 -2
  3. package/dist/core/database/baseRepository.d.ts +5 -0
  4. package/dist/core/database/model.d.ts +3 -1
  5. package/dist/core/database/query.d.ts +6 -1
  6. package/dist/core/database/repositoryQuery.d.ts +7 -1
  7. package/dist/core/database/schema/blueprint.d.ts +2 -0
  8. package/dist/core/database/schema/columnDefinition.d.ts +1 -1
  9. package/dist/core/database/types.d.ts +10 -1
  10. package/dist/core/http/requestMetaContext.d.ts +2 -0
  11. package/dist/core/queue/createAppQueue.d.ts +2 -1
  12. package/dist/core/queue/failedJobService.d.ts +3 -1
  13. package/dist/core/queue/index.d.ts +10 -2
  14. package/dist/core/queue/jobRunner.d.ts +7 -1
  15. package/dist/core/queue/publicQueue.d.ts +2 -1
  16. package/dist/core/queue/queueConfig.d.ts +3 -0
  17. package/dist/core/queue/redisQueue.d.ts +5 -7
  18. package/dist/core/queue/redisQueueKeys.d.ts +8 -0
  19. package/dist/core/queue/redisStreams.d.ts +73 -0
  20. package/dist/core/queue/types.d.ts +1 -0
  21. package/dist/core/tenant/publicTenantHost.d.ts +11 -0
  22. package/dist/core/tenant/tenantMiddleware.d.ts +4 -0
  23. package/dist/entries/auth/basicAuthGuard.js +1 -0
  24. package/dist/entries/auth/jwtGuard.js +1 -0
  25. package/dist/entries/auth/sessionGuard.js +1 -0
  26. package/dist/entries/contracts/serviceTokens.js +2 -0
  27. package/dist/entries/database/query.js +70 -1
  28. package/dist/entries/database/repositoryQuery.js +99 -1
  29. package/dist/entries/database/schema.js +9 -0
  30. package/dist/entries/database/sqliteConnection.js +1 -1
  31. package/dist/entries/queue/createAppQueue.js +681 -66
  32. package/dist/entries/queue/failedJobRepository.js +1 -1
  33. package/dist/entries/queue/failedJobService.js +5 -1
  34. package/dist/entries/queue/jobRunner.js +67 -2
  35. package/dist/entries/queue/publicQueue.js +679 -65
  36. package/dist/entries/queue/queueMetrics.js +694 -72
  37. package/dist/entries/queue/redisQueue.js +765 -148
  38. package/dist/framework/public-api.d.ts +2 -2
  39. package/dist/index.js +855 -69
  40. 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 = [...params];
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
- await new Promise((resolve) => setTimeout(resolve, backoffMs * nextAttempt));
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: RedisClient3 } = globalThis.Bun;
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
- var QUEUE_KEYS = [QUEUE_HIGH_KEY, QUEUE_LIST_KEY, QUEUE_LOW_KEY];
12618
- var DEFAULT_VISIBILITY_MS = 60000;
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
- function parseQueueJobEnvelope(rawPayload) {
12643
- let parsed;
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
- parsed = JSON.parse(rawPayload);
12646
- } catch {
12647
- console.error("[QueueWorker] Ignoring malformed queue payload");
12648
- return null;
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
- if (!parsed || typeof parsed !== "object") {
12651
- console.error("[QueueWorker] Ignoring non-object queue payload");
12652
- return null;
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
- const envelope = parsed;
12655
- if (typeof envelope.name !== "string" || envelope.name.length === 0) {
12656
- console.error("[QueueWorker] Ignoring queue payload without job name");
12657
- return null;
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
- if (!jobRegistry.create(envelope.name)) {
12660
- console.error(`[QueueWorker] Ignoring unknown job name: ${envelope.name}`);
12661
- return null;
13124
+ close() {
13125
+ this.client.close();
12662
13126
  }
12663
- if (envelope.payload !== undefined && (typeof envelope.payload !== "object" || envelope.payload === null)) {
12664
- console.error("[QueueWorker] Ignoring queue payload with invalid payload object");
12665
- return null;
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
- return {
12668
- name: envelope.name,
12669
- payload: envelope.payload ?? {},
12670
- attempts: typeof envelope.attempts === "number" ? envelope.attempts : 0
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(readVisibilityMs())
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(readVisibilityMs()),
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(readVisibilityMs())
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 RedisClient3(redisUrl);
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 RedisClient3(redisUrl);
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(readVisibilityMs() / 3)));
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: RedisClient4 } = globalThis.Bun;
13696
+ var {RedisClient: RedisClient5 } = globalThis.Bun;
12974
13697
  async function readRedisQueueDepth(redisUrl) {
12975
- const client = new RedisClient4(redisUrl);
12976
- const [high, defaultQueue, low] = await Promise.all([
12977
- client.llen(QUEUE_HIGH_KEY),
12978
- client.llen(QUEUE_LIST_KEY),
12979
- client.llen(QUEUE_LOW_KEY)
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: RedisClient5 } = globalThis.Bun;
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 RedisClient5(options.redisUrl, { autoReconnect: false, maxRetries: 0 });
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.parseInt(headerValue, 10) : Number.NaN;
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.isInteger(parsedHeader) && parsedHeader > 0 && parsedHeader !== userTenantId) {
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.isInteger(parsedHeader) && parsedHeader > 0) {
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.isInteger(parsedHeader) && parsedHeader > 0 ? parsedHeader : null;
13525
- const tenantId = isPublicReadsEnabled() && headerTenantId !== null ? headerTenantId : DEFAULT_TENANT.id;
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,