@getstrata/core 2.2.13 → 2.2.15

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/dist/index.js CHANGED
@@ -125,6 +125,10 @@ function getDefaultDatabaseQuery() {
125
125
  }
126
126
  return query;
127
127
  }
128
+ function isDefaultDatabaseConnection(connection) {
129
+ const { pool, query } = defaultConnectionState();
130
+ return connection !== null && (connection === pool || connection === query);
131
+ }
128
132
  function resetDefaultDatabasePoolForTests() {
129
133
  const holder = defaultConnectionState();
130
134
  holder.pool = null;
@@ -5781,6 +5785,31 @@ class RepositoryQuery {
5781
5785
  // ../../src/core/database/baseRepository.ts
5782
5786
  init_transaction();
5783
5787
  init_transactionControl();
5788
+ function listOrders(orderBy) {
5789
+ if (!orderBy)
5790
+ return [];
5791
+ if (Array.isArray(orderBy))
5792
+ return orderBy.map(readOrder);
5793
+ if ("column" in orderBy)
5794
+ return [readOrder(orderBy)];
5795
+ return Object.entries(orderBy).flatMap(([column, direction]) => {
5796
+ if (direction === undefined)
5797
+ return [];
5798
+ return [
5799
+ {
5800
+ column,
5801
+ direction: String(direction).toUpperCase() === "DESC" ? "desc" : "asc"
5802
+ }
5803
+ ];
5804
+ });
5805
+ }
5806
+ function readOrder(order) {
5807
+ return {
5808
+ column: String(order.column),
5809
+ direction: String(order.direction).toUpperCase() === "DESC" ? "desc" : "asc",
5810
+ ...order.nulls === undefined ? {} : { nulls: order.nulls }
5811
+ };
5812
+ }
5784
5813
 
5785
5814
  class BaseRepository {
5786
5815
  table;
@@ -5824,6 +5853,10 @@ class BaseRepository {
5824
5853
  joins: options.joins,
5825
5854
  groupBy: options.groupBy
5826
5855
  }, whereNodes);
5856
+ const meta = buildPaginationMeta({ page, perPage, total });
5857
+ if (total === 0) {
5858
+ return { data: [], meta };
5859
+ }
5827
5860
  const offset = (page - 1) * perPage;
5828
5861
  const data = await this.findAll({
5829
5862
  ...queryOptions,
@@ -5834,13 +5867,19 @@ class BaseRepository {
5834
5867
  });
5835
5868
  return {
5836
5869
  data,
5837
- meta: buildPaginationMeta({ page, perPage, total })
5870
+ meta
5838
5871
  };
5839
5872
  }
5840
5873
  async chunk(count, callback, options = {}) {
5841
5874
  if (!Number.isInteger(count) || count <= 0) {
5842
5875
  throw new Error("Chunk size must be a positive integer.");
5843
5876
  }
5877
+ if (this.primaryKeySeekDirection(options) === "asc") {
5878
+ const seekOptions = { ...options };
5879
+ delete seekOptions.orderBy;
5880
+ await this.chunkById(count, async (rows) => await callback(rows) === false ? false : undefined, seekOptions);
5881
+ return;
5882
+ }
5844
5883
  let offset = 0;
5845
5884
  while (true) {
5846
5885
  const rows = await this.findAll({
@@ -6125,6 +6164,17 @@ class BaseRepository {
6125
6164
  return entity;
6126
6165
  }));
6127
6166
  }
6167
+ primaryKeySeekDirection(options) {
6168
+ if (options.joins?.length || options.groupBy || options.having || options.select?.length || options.offset !== undefined || options.limit !== undefined || options.lock) {
6169
+ return null;
6170
+ }
6171
+ const orders = listOrders(options.orderBy ?? this.table.defaultOrderBy);
6172
+ const order = orders[0];
6173
+ if (orders.length !== 1 || !order || order.nulls || order.column !== this.table.primaryKey) {
6174
+ return null;
6175
+ }
6176
+ return order.direction;
6177
+ }
6128
6178
  withConnection(connection) {
6129
6179
  const clone = Object.create(Object.getPrototypeOf(this));
6130
6180
  Object.assign(clone, this);
@@ -7599,7 +7649,12 @@ import { isDeepStrictEqual } from "util";
7599
7649
  init_deferredModelEvents();
7600
7650
 
7601
7651
  // ../../src/core/database/relationQuery.ts
7652
+ init_boundConnection();
7653
+ init_connectionContext();
7654
+ init_defaultConnection();
7602
7655
  init_dialect();
7656
+ init_repositoryConnection();
7657
+ init_transaction();
7603
7658
  function asWhere(where) {
7604
7659
  return where;
7605
7660
  }
@@ -7869,6 +7924,26 @@ class BelongsToRelationQuery {
7869
7924
  });
7870
7925
  }
7871
7926
  }
7927
+ function pivotIdKey(id) {
7928
+ return typeof id === "number" || typeof id === "bigint" ? String(id) : id;
7929
+ }
7930
+ function hasRepeatedId(ids) {
7931
+ const seen = new Set;
7932
+ for (const id of ids) {
7933
+ const key = pivotIdKey(id);
7934
+ if (seen.has(key))
7935
+ return true;
7936
+ seen.add(key);
7937
+ }
7938
+ return false;
7939
+ }
7940
+ function canBatchPivotToggle(ids) {
7941
+ return ids.every((id) => typeof id === "bigint" && id >= BigInt(Number.MIN_SAFE_INTEGER) && id <= BigInt(Number.MAX_SAFE_INTEGER) || typeof id === "number" && Number.isSafeInteger(id) || typeof id === "string" && /^(?:0|-?[1-9]\d*)$/.test(id) && Number.isSafeInteger(Number(id)));
7942
+ }
7943
+ function isConfiguredConnection(connection) {
7944
+ const bound = getBoundDatabaseConnection();
7945
+ return connection === repositoryConnection || (bound ? connection === bound : isDefaultDatabaseConnection(connection));
7946
+ }
7872
7947
 
7873
7948
  class BelongsToManyRelationQuery {
7874
7949
  parent;
@@ -7910,7 +7985,20 @@ class BelongsToManyRelationQuery {
7910
7985
  return { sql, params: extra.params };
7911
7986
  }
7912
7987
  connection() {
7913
- return this.parent.getRepository().getConnection();
7988
+ const connection = this.parent.getRepository().getConnection();
7989
+ return isConfiguredConnection(connection) ? getActiveDatabaseConnection(connection) : connection;
7990
+ }
7991
+ async atomically(work) {
7992
+ const connection = this.connection();
7993
+ if (isConfiguredConnection(connection) || hasActiveDatabaseConnection() && connection === getActiveDatabaseConnection(connection)) {
7994
+ await runInTransaction(work);
7995
+ return;
7996
+ }
7997
+ if (typeof connection.begin !== "function") {
7998
+ await work(connection);
7999
+ return;
8000
+ }
8001
+ await connection.begin(work);
7914
8002
  }
7915
8003
  async get() {
7916
8004
  const query = await this.relatedQuery();
@@ -7948,36 +8036,72 @@ class BelongsToManyRelationQuery {
7948
8036
  }
7949
8037
  async attach(ids) {
7950
8038
  const list = Array.isArray(ids) ? ids : [ids];
7951
- const parentId = this.parent.get(this.relation.parentKey);
7952
- const extraKeys = Object.keys(this.pivotValues);
7953
- const extraColumns = extraKeys.length > 0 ? `, ${extraKeys.map((key) => quoteIdentifier(key)).join(", ")}` : "";
7954
- const extraPlaceholders = extraKeys.map((_, index) => `$${index + 3}`).join(", ");
7955
- const extraValues = extraKeys.map((key) => this.pivotValues[key]);
7956
- for (const id of list) {
7957
- await this.connection().unsafe(extraKeys.length > 0 ? `INSERT INTO ${quoteIdentifier(this.relation.pivotTable)} (${quoteIdentifier(String(this.relation.foreignPivotKey))}, ${quoteIdentifier(String(this.relation.relatedPivotKey))}${extraColumns}) VALUES ($1, $2, ${extraPlaceholders})` : `INSERT INTO ${quoteIdentifier(this.relation.pivotTable)} (${quoteIdentifier(String(this.relation.foreignPivotKey))}, ${quoteIdentifier(String(this.relation.relatedPivotKey))}) VALUES ($1, $2)`, [parentId, id, ...extraValues]);
7958
- }
8039
+ if (list.length === 0)
8040
+ return;
8041
+ await this.atomically((connection) => this.insertPivotRows(connection, list));
7959
8042
  }
7960
8043
  async toggle(ids) {
7961
8044
  const list = Array.isArray(ids) ? ids : [ids];
7962
- const parentId = this.parent.get(this.relation.parentKey);
7963
- for (const id of list) {
7964
- const existing = await this.connection().unsafe(`SELECT 1 FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = $1 AND ${quoteIdentifier(String(this.relation.relatedPivotKey))} = $2 LIMIT 1`, [parentId, id]);
7965
- if (existing.length > 0) {
7966
- await this.detach(id);
7967
- } else {
7968
- await this.attach(id);
8045
+ if (list.length === 0)
8046
+ return;
8047
+ await this.atomically(async (connection) => {
8048
+ if (!canBatchPivotToggle(list) || hasRepeatedId(list)) {
8049
+ for (const id of list)
8050
+ await this.toggleOne(connection, id);
8051
+ return;
7969
8052
  }
7970
- }
8053
+ const parentId = this.parent.get(this.relation.parentKey);
8054
+ const relatedColumn = quoteIdentifier(String(this.relation.relatedPivotKey));
8055
+ const parentColumn = quoteIdentifier(String(this.relation.foreignPivotKey));
8056
+ const placeholders = list.map((_, index) => currentSqlDialect().placeholder(index + 2));
8057
+ const existing = await connection.unsafe(`SELECT ${relatedColumn} FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${parentColumn} = ${currentSqlDialect().placeholder(1)} AND ${relatedColumn} IN (${placeholders.join(", ")})`, [parentId, ...list]);
8058
+ const existingIds = new Set(existing.map((row) => pivotIdKey(row[String(this.relation.relatedPivotKey)])));
8059
+ const present = list.filter((id) => existingIds.has(pivotIdKey(id)));
8060
+ const missing = list.filter((id) => !existingIds.has(pivotIdKey(id)));
8061
+ if (present.length > 0)
8062
+ await this.deletePivotRows(connection, present);
8063
+ if (missing.length > 0)
8064
+ await this.insertPivotRows(connection, missing);
8065
+ });
7971
8066
  }
7972
8067
  async detach(ids) {
7973
8068
  const parentId = this.parent.get(this.relation.parentKey);
7974
8069
  if (ids === undefined) {
7975
- await this.connection().unsafe(`DELETE FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = $1`, [parentId]);
8070
+ await this.connection().unsafe(`DELETE FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = ${currentSqlDialect().placeholder(1)}`, [parentId]);
7976
8071
  return;
7977
8072
  }
7978
8073
  const list = Array.isArray(ids) ? ids : [ids];
7979
- const placeholders = list.map((_, index) => `$${index + 2}`).join(", ");
7980
- await this.connection().unsafe(`DELETE FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = $1 AND ${quoteIdentifier(String(this.relation.relatedPivotKey))} IN (${placeholders})`, [parentId, ...list]);
8074
+ if (list.length === 0)
8075
+ return;
8076
+ await this.deletePivotRows(this.connection(), list);
8077
+ }
8078
+ async toggleOne(connection, id) {
8079
+ const parentId = this.parent.get(this.relation.parentKey);
8080
+ const existing = await connection.unsafe(`SELECT 1 FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = ${currentSqlDialect().placeholder(1)} AND ${quoteIdentifier(String(this.relation.relatedPivotKey))} = ${currentSqlDialect().placeholder(2)} LIMIT 1`, [parentId, id]);
8081
+ if (existing.length > 0)
8082
+ await this.deletePivotRows(connection, [id]);
8083
+ else
8084
+ await this.insertPivotRows(connection, [id]);
8085
+ }
8086
+ async insertPivotRows(connection, list) {
8087
+ const parentId = this.parent.get(this.relation.parentKey);
8088
+ const extraKeys = Object.keys(this.pivotValues);
8089
+ const extraColumns = extraKeys.length > 0 ? `, ${extraKeys.map((key) => quoteIdentifier(key)).join(", ")}` : "";
8090
+ const extraValues = extraKeys.map((key) => this.pivotValues[key]);
8091
+ const width = 2 + extraKeys.length;
8092
+ const tuples = list.map((_, row) => {
8093
+ const slots = Array.from({ length: width }, (__, column) => currentSqlDialect().placeholder(row * width + column + 1));
8094
+ return `(${slots.join(", ")})`;
8095
+ });
8096
+ const params = [];
8097
+ for (const id of list)
8098
+ params.push(parentId, id, ...extraValues);
8099
+ await connection.unsafe(`INSERT INTO ${quoteIdentifier(this.relation.pivotTable)} (${quoteIdentifier(String(this.relation.foreignPivotKey))}, ${quoteIdentifier(String(this.relation.relatedPivotKey))}${extraColumns}) VALUES ${tuples.join(", ")}`, params);
8100
+ }
8101
+ async deletePivotRows(connection, list) {
8102
+ const parentId = this.parent.get(this.relation.parentKey);
8103
+ const placeholders = list.map((_, index) => currentSqlDialect().placeholder(index + 2));
8104
+ await connection.unsafe(`DELETE FROM ${quoteIdentifier(this.relation.pivotTable)} WHERE ${quoteIdentifier(String(this.relation.foreignPivotKey))} = ${currentSqlDialect().placeholder(1)} AND ${quoteIdentifier(String(this.relation.relatedPivotKey))} IN (${placeholders.join(", ")})`, [parentId, ...list]);
7981
8105
  }
7982
8106
  async sync(ids) {
7983
8107
  await this.detach();
@@ -8568,10 +8692,20 @@ function applyCasts(values, casts, direction) {
8568
8692
  return result;
8569
8693
  }
8570
8694
  return (async () => {
8695
+ const hashes = [];
8571
8696
  for (const [key, cast] of entries) {
8572
- if (key in result && cast)
8573
- result[key] = await dehydrateValue(result[key], cast);
8697
+ if (!(key in result) || !cast)
8698
+ continue;
8699
+ if (cast === "hashed") {
8700
+ const current = result[key];
8701
+ hashes.push(hashCastValue(current).then((hashed) => {
8702
+ result[key] = hashed;
8703
+ }));
8704
+ continue;
8705
+ }
8706
+ result[key] = dehydrateValue(result[key], cast);
8574
8707
  }
8708
+ await Promise.all(hashes);
8575
8709
  return result;
8576
8710
  })();
8577
8711
  }
@@ -11352,7 +11486,16 @@ function createCorsMiddleware(options = {}) {
11352
11486
  const response = await next();
11353
11487
  const headers = new Headers(response.headers);
11354
11488
  for (const [key, value] of buildCorsHeaders(request, captured)) {
11355
- headers.set(key, value);
11489
+ if (key === "vary") {
11490
+ const fields = (headers.get("Vary") ?? "").split(",").map((field) => field.trim()).filter(Boolean);
11491
+ if (fields.includes("*"))
11492
+ continue;
11493
+ if (!fields.some((field) => field.toLowerCase() === "origin"))
11494
+ fields.push(value);
11495
+ headers.set("Vary", fields.join(", "));
11496
+ } else {
11497
+ headers.set(key, value);
11498
+ }
11356
11499
  }
11357
11500
  return new Response(response.body, {
11358
11501
  status: response.status,
@@ -12390,6 +12533,48 @@ function withErrorHandling(handler) {
12390
12533
  };
12391
12534
  }
12392
12535
  // ../../src/core/http/route.ts
12536
+ function resourceRoutes(path, actions) {
12537
+ if (!/^\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*$/.test(path) || path.split("/").some((part) => part === "." || part === "..")) {
12538
+ throw new TypeError("Resource path must be a static absolute path without a trailing slash.");
12539
+ }
12540
+ const routes = {};
12541
+ const definitions = {
12542
+ index: [path, ["GET"]],
12543
+ create: [`${path}/create`, ["GET"]],
12544
+ store: [path, ["POST"]],
12545
+ show: [`${path}/:id`, ["GET"]],
12546
+ edit: [`${path}/:id/edit`, ["GET"]],
12547
+ update: [`${path}/:id`, ["PUT", "PATCH"]],
12548
+ destroy: [`${path}/:id`, ["DELETE"]]
12549
+ };
12550
+ for (const [action, handler] of Object.entries(actions)) {
12551
+ if (!Object.hasOwn(definitions, action) || typeof handler !== "function") {
12552
+ throw new TypeError(`Invalid resource action: ${action}`);
12553
+ }
12554
+ const [route, methods] = definitions[action];
12555
+ const methodMap = routes[route] ?? {};
12556
+ routes[route] = methodMap;
12557
+ for (const method of methods)
12558
+ methodMap[method] = handler;
12559
+ }
12560
+ return routes;
12561
+ }
12562
+ function mergeRouteMaps(...maps) {
12563
+ const routes = {};
12564
+ for (const map of maps) {
12565
+ for (const [path, handler] of Object.entries(map)) {
12566
+ if (Object.hasOwn(routes, path))
12567
+ throw new Error(`Duplicate route path: ${path}`);
12568
+ Object.defineProperty(routes, path, {
12569
+ value: handler,
12570
+ enumerable: true,
12571
+ writable: true,
12572
+ configurable: true
12573
+ });
12574
+ }
12575
+ }
12576
+ return routes;
12577
+ }
12393
12578
  function getRouteParams(request) {
12394
12579
  return request.params;
12395
12580
  }
@@ -14133,21 +14318,6 @@ redis.call('XDEL', KEYS[1], ARGV[2])
14133
14318
  if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[3]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[3]) end
14134
14319
  return 1
14135
14320
  `;
14136
- var PROMOTE_RETRIES = `
14137
- local kinds = {'stream','zset'}
14138
- for i=1,2 do
14139
- local kind = redis.call('TYPE', KEYS[i]).ok
14140
- if kind ~= 'none' and kind ~= kinds[i] then return redis.error_reply('Invalid stream retry promotion key type') end
14141
- end
14142
- local now = redis.call('TIME')
14143
- local due = tonumber(now[1])*1000 + math.floor(tonumber(now[2])/1000)
14144
- local entries = redis.call('ZRANGE', KEYS[2], '-inf', due, 'BYSCORE', 'LIMIT', 0, 100)
14145
- for _, payload in ipairs(entries) do
14146
- redis.call('XADD', KEYS[1], '*', 'payload', payload)
14147
- redis.call('ZREM', KEYS[2], payload)
14148
- end
14149
- return #entries
14150
- `;
14151
14321
  async function scheduleStreamRetry(client, key, reservation, envelope, delayMs) {
14152
14322
  if (!Number.isSafeInteger(delayMs) || delayMs < 0 || delayMs > Number.MAX_SAFE_INTEGER / 2)
14153
14323
  throw new Error("Invalid Streams retry delay");
@@ -14169,8 +14339,93 @@ async function scheduleStreamRetry(client, key, reservation, envelope, delayMs)
14169
14339
  if (Number(scheduled) !== 1)
14170
14340
  throw new Error("Queue lease lost while scheduling retry");
14171
14341
  }
14172
- async function promoteStreamRetries(client, key) {
14173
- return Number(await client.send("EVAL", [PROMOTE_RETRIES, "2", streamQueueKey(key), streamRetryKey(key)]));
14342
+ var POLL_STREAM_PRIORITIES = `
14343
+ local group = ARGV[1]
14344
+ local owner = ARGV[2]
14345
+ local visibility = ARGV[3]
14346
+ local function payloadOf(fields)
14347
+ for index = 1, #fields, 2 do
14348
+ if fields[index] == 'payload' and type(fields[index + 1]) == 'string' then return fields[index + 1] end
14349
+ end
14350
+ return ''
14351
+ end
14352
+ local cursors = {}
14353
+ for i = 1, #KEYS / 2 do cursors[i] = ARGV[3 + i] end
14354
+ for i = 1, #KEYS / 2 do
14355
+ local stream = KEYS[(i - 1) * 2 + 1]
14356
+ local retries = KEYS[(i - 1) * 2 + 2]
14357
+ local cursor = ARGV[3 + i]
14358
+ local streamKind = redis.call('TYPE', stream).ok
14359
+ local retryKind = redis.call('TYPE', retries).ok
14360
+ if streamKind ~= 'none' and streamKind ~= 'stream' then return redis.error_reply('Invalid stream retry promotion key type') end
14361
+ if retryKind ~= 'none' and retryKind ~= 'zset' then return redis.error_reply('Invalid stream retry promotion key type') end
14362
+ if streamKind == 'stream' then
14363
+ local now = redis.call('TIME')
14364
+ local due = tonumber(now[1]) * 1000 + math.floor(tonumber(now[2]) / 1000)
14365
+ local ready = redis.call('ZRANGE', retries, '-inf', due, 'BYSCORE', 'LIMIT', 0, 100)
14366
+ for _, payload in ipairs(ready) do
14367
+ redis.call('XADD', stream, '*', 'payload', payload)
14368
+ redis.call('ZREM', retries, payload)
14369
+ end
14370
+ local window = redis.call('XPENDING', stream, group, cursor, '+', 10)
14371
+ local claimed = redis.call('XAUTOCLAIM', stream, group, owner, visibility, cursor, 'COUNT', 1)
14372
+ for _, entry in ipairs(window) do
14373
+ if entry[2] ~= owner and #redis.call('XPENDING', stream, group, '-', '+', 1, entry[2]) == 0 then
14374
+ redis.call('XGROUP', 'DELCONSUMER', stream, group, entry[2])
14375
+ end
14376
+ end
14377
+ if #redis.call('XPENDING', stream, group, '-', '+', 1, owner) == 0 then
14378
+ redis.call('XGROUP', 'DELCONSUMER', stream, group, owner)
14379
+ end
14380
+ cursors[i] = claimed[1]
14381
+ if #claimed[2] > 0 then
14382
+ return {'1', tostring(i), claimed[2][1][1], payloadOf(claimed[2][1][2]), unpack(cursors)}
14383
+ end
14384
+ local fresh = redis.call('XREADGROUP', 'GROUP', group, owner, 'COUNT', 1, 'STREAMS', stream, '>')
14385
+ if not fresh and #redis.call('XPENDING', stream, group, '-', '+', 1, owner) == 0 then
14386
+ redis.call('XGROUP', 'DELCONSUMER', stream, group, owner)
14387
+ end
14388
+ if fresh and #fresh > 0 and #fresh[1][2] > 0 then
14389
+ return {'1', tostring(i), fresh[1][2][1][1], payloadOf(fresh[1][2][1][2]), unpack(cursors)}
14390
+ end
14391
+ end
14392
+ end
14393
+ return {'0', unpack(cursors)}
14394
+ `;
14395
+ async function pollStreamPriorities(client, queueKeys, owner, cursors) {
14396
+ const keys = [];
14397
+ for (const key of queueKeys)
14398
+ keys.push(streamQueueKey(key), streamRetryKey(key));
14399
+ const raw = await client.send("EVAL", [
14400
+ POLL_STREAM_PRIORITIES,
14401
+ String(keys.length),
14402
+ ...keys,
14403
+ STREAM_GROUP,
14404
+ owner,
14405
+ String(readQueueVisibilityMs()),
14406
+ ...cursors
14407
+ ]);
14408
+ if (!Array.isArray(raw))
14409
+ throw new Error("Invalid Redis stream poll response");
14410
+ const flag = String(raw[0]);
14411
+ const cursorCount = queueKeys.length;
14412
+ if (flag === "0") {
14413
+ if (raw.length !== cursorCount + 1)
14414
+ throw new Error("Invalid Redis stream poll response");
14415
+ return { cursors: raw.slice(1).map(String), hit: null };
14416
+ }
14417
+ if (flag !== "1" || raw.length !== cursorCount + 4)
14418
+ throw new Error("Invalid Redis stream poll response");
14419
+ const index = Number(raw[1]);
14420
+ if (!Number.isInteger(index) || index < 1 || index > queueKeys.length)
14421
+ throw new Error("Invalid Redis stream poll response");
14422
+ return {
14423
+ cursors: raw.slice(4).map(String),
14424
+ hit: {
14425
+ index: index - 1,
14426
+ reservation: { id: String(raw[2]), owner, payload: String(raw[3]) }
14427
+ }
14428
+ };
14174
14429
  }
14175
14430
  async function readStreamQueueDepth(client, key) {
14176
14431
  return Number(await client.send("EVAL", [
@@ -14219,20 +14474,6 @@ end
14219
14474
  if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[3]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[3]) end
14220
14475
  return acknowledged
14221
14476
  `;
14222
- var CLAIM_STREAM = `
14223
- local window = redis.call('XPENDING', KEYS[1], ARGV[1], ARGV[4], '+', 10)
14224
- local claimed = redis.call('XAUTOCLAIM', KEYS[1], ARGV[1], ARGV[2], ARGV[3], ARGV[4], 'COUNT', 1)
14225
- for _, entry in ipairs(window) do
14226
- 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
14227
- end
14228
- if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[2]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[2]) end
14229
- return claimed
14230
- `;
14231
- var READ_STREAM = `
14232
- local entries = redis.call('XREADGROUP', 'GROUP', ARGV[1], ARGV[2], 'COUNT', 1, 'STREAMS', KEYS[1], '>')
14233
- 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
14234
- return entries
14235
- `;
14236
14477
  async function ensureStreamGroup(client, key) {
14237
14478
  await assertLegacyDrained(client, key);
14238
14479
  try {
@@ -14242,17 +14483,6 @@ async function ensureStreamGroup(client, key) {
14242
14483
  throw error;
14243
14484
  }
14244
14485
  }
14245
- function readStreamEntry(value, owner) {
14246
- if (!Array.isArray(value) || typeof value[0] !== "string" || !Array.isArray(value[1]))
14247
- throw new Error("Invalid Redis stream entry response");
14248
- const fields = value[1];
14249
- let payload;
14250
- for (let index = 0;index < fields.length; index += 2) {
14251
- if (fields[index] === "payload" && typeof fields[index + 1] === "string")
14252
- payload = fields[index + 1];
14253
- }
14254
- return { id: value[0], owner, payload: payload ?? "" };
14255
- }
14256
14486
  async function updateStreamReservation(client, key, reservation, action, controlKey) {
14257
14487
  return Number(await client.send("EVAL", [
14258
14488
  UPDATE_STREAM,
@@ -14269,35 +14499,6 @@ async function updateStreamReservation(client, key, reservation, action, control
14269
14499
  controlKey ? "1" : "0"
14270
14500
  ])) === 1;
14271
14501
  }
14272
- async function reserveStreamJob(client, key, owner, cursor = "0-0") {
14273
- const claimed = await client.send("EVAL", [
14274
- CLAIM_STREAM,
14275
- "1",
14276
- streamQueueKey(key),
14277
- STREAM_GROUP,
14278
- owner,
14279
- String(readQueueVisibilityMs()),
14280
- cursor
14281
- ]);
14282
- if (!Array.isArray(claimed) || typeof claimed[0] !== "string" || !Array.isArray(claimed[1]))
14283
- throw new Error("Invalid Redis XAUTOCLAIM response");
14284
- const nextCursor = claimed[0];
14285
- if (claimed[1].length > 0)
14286
- return { reservation: readStreamEntry(claimed[1][0], owner), cursor: nextCursor };
14287
- const fresh = await client.send("EVAL", [
14288
- READ_STREAM,
14289
- "1",
14290
- streamQueueKey(key),
14291
- STREAM_GROUP,
14292
- owner
14293
- ]);
14294
- if (fresh === null)
14295
- return { reservation: null, cursor: nextCursor };
14296
- if (!Array.isArray(fresh) || !Array.isArray(fresh[0]) || !Array.isArray(fresh[0][1]))
14297
- throw new Error("Invalid Redis XREADGROUP response");
14298
- return { reservation: readStreamEntry(fresh[0][1][0], owner), cursor: nextCursor };
14299
- }
14300
-
14301
14502
  class RedisStreamsQueue {
14302
14503
  client;
14303
14504
  constructor(redisUrl) {
@@ -14395,12 +14596,6 @@ class RedisStreamsWorker {
14395
14596
  await ensureStreamGroup(this.client, key);
14396
14597
  this.initialized = true;
14397
14598
  }
14398
- async reserve(key) {
14399
- await promoteStreamRetries(this.client, key);
14400
- const result = await reserveStreamJob(this.client, key, this.owner, this.cursors.get(key));
14401
- this.cursors.set(key, result.cursor);
14402
- return result.reservation;
14403
- }
14404
14599
  async processNext() {
14405
14600
  if (this.processing)
14406
14601
  throw new Error("Queue worker already processing a job.");
@@ -14417,13 +14612,19 @@ class RedisStreamsWorker {
14417
14612
  await this.initialize();
14418
14613
  const deadline = performance.now() + this.timeoutSeconds * 1000;
14419
14614
  do {
14420
- for (const key of this.queueKeys) {
14421
- if (this.stopping)
14422
- return false;
14423
- const reservation = await this.reserve(key);
14424
- if (!reservation)
14425
- continue;
14426
- await this.handle(key, reservation);
14615
+ if (this.stopping)
14616
+ return false;
14617
+ const polled = await pollStreamPriorities(this.client, this.queueKeys, this.owner, this.queueKeys.map((key) => this.cursors.get(key) ?? "0-0"));
14618
+ this.queueKeys.forEach((key, index) => {
14619
+ const cursor = polled.cursors[index];
14620
+ if (cursor)
14621
+ this.cursors.set(key, cursor);
14622
+ });
14623
+ if (polled.hit) {
14624
+ const key = this.queueKeys[polled.hit.index];
14625
+ if (!key)
14626
+ throw new Error("Invalid Redis stream poll response");
14627
+ await this.handle(key, polled.hit.reservation);
14427
14628
  return true;
14428
14629
  }
14429
14630
  if (performance.now() >= deadline || this.stopping)
@@ -14650,25 +14851,69 @@ for _, record in ipairs(redis.call('LRANGE', KEYS[2], 0, -1)) do
14650
14851
  end
14651
14852
  return reclaimed
14652
14853
  `;
14854
+ var POLL_LIST_PRIORITIES = `${SCRIPT_HEADER}
14855
+ local owner = ARGV[1]
14856
+ local visibility = ARGV[2]
14857
+ local reservationId = ARGV[3]
14858
+ local reclaim = ARGV[4] == '1'
14859
+ local function reclaimQueue(listKey, processingKey, leaseKey)
14860
+ local err = checkType(listKey, 'list') or checkType(processingKey, 'list') or checkType(leaseKey, 'hash')
14861
+ if err then return err end
14862
+ local now = nowMs()
14863
+ for _, record in ipairs(redis.call('LRANGE', processingKey, 0, -1)) do
14864
+ local payload, field = record, record
14865
+ local modern = false
14866
+ local ok, reservation = pcall(cjson.decode, record)
14867
+ if ok and type(reservation) == 'table' and reservation.strataReservation == 1 then
14868
+ payload, field, modern = reservation.payload, reservation.id, true
14869
+ end
14870
+ local raw = redis.call('HGET', leaseKey, field)
14871
+ local expired = true
14872
+ if raw then
14873
+ if modern then
14874
+ local leaseOk, lease = pcall(cjson.decode, raw)
14875
+ expired = not leaseOk or type(lease) ~= 'table' or not tonumber(lease.expiresAt) or tonumber(lease.expiresAt) <= now
14876
+ else
14877
+ local reservedAt = tonumber(raw)
14878
+ expired = not reservedAt or now - reservedAt >= tonumber(visibility)
14879
+ end
14880
+ end
14881
+ if expired then
14882
+ redis.call('LPUSH', listKey, payload)
14883
+ redis.call('LREM', processingKey, 1, record)
14884
+ redis.call('HDEL', leaseKey, field)
14885
+ end
14886
+ end
14887
+ end
14888
+ local function reserveQueue(listKey, processingKey, leaseKey)
14889
+ local err = checkType(listKey, 'list') or checkType(processingKey, 'list') or checkType(leaseKey, 'hash')
14890
+ if err then return err end
14891
+ local payload = redis.call('LINDEX', listKey, -1)
14892
+ if not payload then return nil end
14893
+ local record = cjson.encode({strataReservation=1, id=reservationId, owner=owner, payload=payload})
14894
+ local lease = cjson.encode({owner=owner, expiresAt=nowMs()+tonumber(visibility)})
14895
+ redis.call('LPUSH', processingKey, record)
14896
+ redis.call('HSET', leaseKey, reservationId, lease)
14897
+ redis.call('RPOP', listKey)
14898
+ return record
14899
+ end
14900
+ local count = #KEYS / 3
14901
+ if reclaim then
14902
+ for i = 1, count do
14903
+ local err = reclaimQueue(KEYS[(i - 1) * 3 + 1], KEYS[(i - 1) * 3 + 2], KEYS[(i - 1) * 3 + 3])
14904
+ if err then return err end
14905
+ end
14906
+ end
14907
+ for i = 1, count do
14908
+ local record = reserveQueue(KEYS[(i - 1) * 3 + 1], KEYS[(i - 1) * 3 + 2], KEYS[(i - 1) * 3 + 3])
14909
+ if type(record) == 'string' then return {'1', tostring(i), record} end
14910
+ if record then return record end
14911
+ end
14912
+ return {'0'}
14913
+ `;
14653
14914
  function queueInvalidKey(queueKey) {
14654
14915
  return `${queueKey}:invalid`;
14655
14916
  }
14656
- async function reserveQueueJob(client, queueKey, owner) {
14657
- const raw = await client.send("EVAL", [
14658
- RESERVE_JOB,
14659
- "3",
14660
- queueKey,
14661
- queueProcessingKey(queueKey),
14662
- queueProcessingLeaseKey(queueKey),
14663
- crypto.randomUUID(),
14664
- owner,
14665
- String(readQueueVisibilityMs())
14666
- ]);
14667
- if (typeof raw !== "string")
14668
- return null;
14669
- const reservation = JSON.parse(raw);
14670
- return { ...reservation, record: raw };
14671
- }
14672
14917
  async function updateQueueReservation(client, queueKey, reservation, action) {
14673
14918
  return Number(await client.send("EVAL", [
14674
14919
  RENEW_OR_ACK,
@@ -14684,21 +14929,41 @@ async function updateQueueReservation(client, queueKey, reservation, action) {
14684
14929
  reservation.payload
14685
14930
  ])) === 1;
14686
14931
  }
14687
- async function reclaimExpiredQueueReservations(client, queueKeys = QUEUE_KEYS) {
14688
- let reclaimed = 0;
14932
+ async function pollListPriorities(client, queueKeys, owner, reservationId, reclaim) {
14933
+ const keys = [];
14689
14934
  for (const key of queueKeys) {
14690
- reclaimed += Number(await client.send("EVAL", [
14691
- RECLAIM_JOBS,
14692
- "3",
14693
- key,
14694
- queueProcessingKey(key),
14695
- queueProcessingLeaseKey(key),
14696
- String(readQueueVisibilityMs())
14697
- ]));
14935
+ keys.push(key, queueProcessingKey(key), queueProcessingLeaseKey(key));
14698
14936
  }
14699
- return reclaimed;
14937
+ const raw = await client.send("EVAL", [
14938
+ POLL_LIST_PRIORITIES,
14939
+ String(keys.length),
14940
+ ...keys,
14941
+ owner,
14942
+ String(readQueueVisibilityMs()),
14943
+ reservationId,
14944
+ reclaim ? "1" : "0"
14945
+ ]);
14946
+ if (!Array.isArray(raw))
14947
+ throw new Error("Invalid Redis list poll response");
14948
+ const flag = String(raw[0]);
14949
+ if (flag === "0") {
14950
+ if (raw.length !== 1)
14951
+ throw new Error("Invalid Redis list poll response");
14952
+ return null;
14953
+ }
14954
+ if (flag !== "1" || raw.length !== 3)
14955
+ throw new Error("Invalid Redis list poll response");
14956
+ const index = Number(raw[1]);
14957
+ const record = raw[2];
14958
+ if (!Number.isInteger(index) || index < 1 || index > queueKeys.length || typeof record !== "string") {
14959
+ throw new Error("Invalid Redis list poll response");
14960
+ }
14961
+ const queueKey = queueKeys[index - 1];
14962
+ if (!queueKey)
14963
+ throw new Error("Invalid Redis list poll response");
14964
+ const reservation = JSON.parse(record);
14965
+ return { queueKey, reservation: { ...reservation, record } };
14700
14966
  }
14701
-
14702
14967
  class RedisQueue {
14703
14968
  client;
14704
14969
  constructor(redisUrl) {
@@ -14744,14 +15009,14 @@ class QueueWorker {
14744
15009
  }
14745
15010
  async reserve() {
14746
15011
  const deadline = performance.now() + this.timeoutSeconds * 1000;
15012
+ let reclaim = true;
14747
15013
  do {
14748
- for (const key of this.queueKeys) {
14749
- if (this.stopping)
14750
- return null;
14751
- const reservation = await reserveQueueJob(this.client, key, this.owner);
14752
- if (reservation)
14753
- return { queueKey: key, reservation };
14754
- }
15014
+ if (this.stopping)
15015
+ return null;
15016
+ const reserved = await pollListPriorities(this.client, this.queueKeys, this.owner, crypto.randomUUID(), reclaim);
15017
+ reclaim = false;
15018
+ if (reserved)
15019
+ return reserved;
14755
15020
  if (this.stopping || performance.now() >= deadline)
14756
15021
  return null;
14757
15022
  await Bun.sleep(Math.min(100, Math.max(1, deadline - performance.now())));
@@ -14761,7 +15026,6 @@ class QueueWorker {
14761
15026
  async processNext() {
14762
15027
  if (this.stopping)
14763
15028
  return false;
14764
- await reclaimExpiredQueueReservations(this.client, this.queueKeys);
14765
15029
  const reserved = await this.reserve();
14766
15030
  if (!reserved)
14767
15031
  return false;
@@ -16119,6 +16383,11 @@ class EtaViewEngine {
16119
16383
  }
16120
16384
  }
16121
16385
  // ../../src/core/view/viewRenderer.ts
16386
+ function validateRelativeViewName(name) {
16387
+ if (!/^[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*$/.test(name) || name.split("/").some((part) => part === "." || part === "..")) {
16388
+ throw new TypeError("View namespace and names must be relative template paths without dot segments.");
16389
+ }
16390
+ }
16122
16391
  var HTMX_VARY_HEADERS = ["HX-Request", "HX-Boosted", "HX-History-Restore-Request"];
16123
16392
  function varyByHtmx(headers) {
16124
16393
  const existing = (headers.get("Vary") ?? "").split(",").map((field) => field.trim());
@@ -16139,6 +16408,20 @@ class ViewRenderer {
16139
16408
  this.engine = engine;
16140
16409
  this.defaultLayout = defaultLayout;
16141
16410
  }
16411
+ namespace(prefix) {
16412
+ validateRelativeViewName(prefix);
16413
+ const qualify = (name) => {
16414
+ validateRelativeViewName(name);
16415
+ return `${prefix}/${name}`;
16416
+ };
16417
+ return {
16418
+ render: async (name, data, options) => this.render(qualify(name), data, options),
16419
+ renderHtmx: async (name, data, options = {}) => this.renderHtmx(qualify(name), data, {
16420
+ ...options,
16421
+ ...options.fragment === undefined ? {} : { fragment: qualify(options.fragment) }
16422
+ })
16423
+ };
16424
+ }
16142
16425
  async render(name, data = {}, options = {}) {
16143
16426
  const html = await this.engine.render(name, data, {
16144
16427
  request: options.request,
@@ -16509,6 +16792,7 @@ export {
16509
16792
  installGracefulShutdownSignals,
16510
16793
  isBlockedHostname,
16511
16794
  isBlockedIpAddress,
16795
+ isDefaultDatabaseConnection,
16512
16796
  isEtagEnabled,
16513
16797
  isGlobalAdmin,
16514
16798
  isHtmxRequest,
@@ -16534,6 +16818,7 @@ export {
16534
16818
  maxLength,
16535
16819
  membershipContext,
16536
16820
  membershipRepository,
16821
+ mergeRouteMaps,
16537
16822
  migrateDatabase,
16538
16823
  minLength,
16539
16824
  modelEventName,
@@ -16636,6 +16921,7 @@ export {
16636
16921
  resolveUserId,
16637
16922
  resolveUserTenantId,
16638
16923
  resolveWebLayoutData,
16924
+ resourceRoutes,
16639
16925
  rollbackDatabase,
16640
16926
  rssResponse,
16641
16927
  runDueScheduledTasks,