@getstrata/core 2.1.0 → 2.2.1

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 (74) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/core/cache/createCacheStore.d.ts +3 -1
  3. package/dist/core/cache/redisCacheStore.d.ts +21 -8
  4. package/dist/core/cache/repository.d.ts +3 -2
  5. package/dist/core/cache/store.d.ts +4 -1
  6. package/dist/core/cache/taggedCache.d.ts +1 -1
  7. package/dist/core/contracts/di.d.ts +4 -2
  8. package/dist/core/contracts/serviceTokens.d.ts +7 -1
  9. package/dist/core/database/baseRepository.d.ts +0 -1
  10. package/dist/core/database/index.d.ts +1 -1
  11. package/dist/core/database/model.d.ts +51 -22
  12. package/dist/core/database/repositoryQuery.d.ts +16 -1
  13. package/dist/core/database/types.d.ts +25 -2
  14. package/dist/core/database/whereBuilder.d.ts +5 -5
  15. package/dist/core/events/deferredModelEvents.d.ts +2 -1
  16. package/dist/core/http/loginThrottleMiddleware.d.ts +6 -5
  17. package/dist/core/http/memoryThrottleMiddleware.d.ts +11 -3
  18. package/dist/core/http/requestMetaContext.d.ts +2 -0
  19. package/dist/core/http/scimThrottleMiddleware.d.ts +12 -3
  20. package/dist/core/http/throttleMiddleware.d.ts +24 -6
  21. package/dist/core/lifecycle/gracefulShutdown.d.ts +33 -5
  22. package/dist/core/queue/createAppQueue.d.ts +2 -1
  23. package/dist/core/queue/failedJobService.d.ts +3 -1
  24. package/dist/core/queue/index.d.ts +17 -2
  25. package/dist/core/queue/jobRunner.d.ts +7 -1
  26. package/dist/core/queue/publicQueue.d.ts +2 -1
  27. package/dist/core/queue/queueConfig.d.ts +3 -0
  28. package/dist/core/queue/redisQueue.d.ts +6 -7
  29. package/dist/core/queue/redisQueueKeys.d.ts +8 -0
  30. package/dist/core/queue/redisStreams.d.ts +73 -0
  31. package/dist/core/queue/resilientQueue.d.ts +2 -2
  32. package/dist/core/queue/types.d.ts +1 -0
  33. package/dist/core/runtime/applicationRegistry.d.ts +2 -1
  34. package/dist/core/runtime/boundedThrottleStore.d.ts +29 -0
  35. package/dist/core/scheduler/leases.d.ts +40 -0
  36. package/dist/core/scheduler/schedule.d.ts +29 -5
  37. package/dist/core/tenant/publicTenantHost.d.ts +11 -0
  38. package/dist/core/tenant/tenantContext.d.ts +5 -4
  39. package/dist/core/tenant/tenantMiddleware.d.ts +11 -1
  40. package/dist/core/tracing/otel.d.ts +28 -26
  41. package/dist/core/tracing/tracingMiddleware.d.ts +4 -1
  42. package/dist/entries/audit/exportAuditLogs.js +14 -2
  43. package/dist/entries/auth/basicAuthGuard.js +81 -0
  44. package/dist/entries/auth/jwtGuard.js +81 -0
  45. package/dist/entries/auth/scimAuthMiddleware.js +17 -7
  46. package/dist/entries/auth/sessionGuard.js +81 -0
  47. package/dist/entries/cache/createCacheStore.js +365 -119
  48. package/dist/entries/cache/repository.js +5 -0
  49. package/dist/entries/contracts/serviceTokens.js +85 -0
  50. package/dist/entries/database/factory.js +0 -3
  51. package/dist/entries/database/model.js +1 -2486
  52. package/dist/entries/database/repositoryQuery.js +67 -9
  53. package/dist/entries/http/bodySizeLimitMiddleware.js +76 -10
  54. package/dist/entries/http/memoryThrottleMiddleware.js +276 -21
  55. package/dist/entries/http/scimThrottleMiddleware.js +576 -54
  56. package/dist/entries/http/throttleMiddleware.js +65 -16
  57. package/dist/entries/jobs/exportAuditLogsJob.js +44 -7
  58. package/dist/entries/jobs/invalidateCacheTagsJob.js +30 -5
  59. package/dist/entries/queue/createAppQueue.js +748 -72
  60. package/dist/entries/queue/failedJobRepository.js +1 -1
  61. package/dist/entries/queue/failedJobService.js +5 -1
  62. package/dist/entries/queue/jobRunner.js +67 -2
  63. package/dist/entries/queue/publicQueue.js +746 -71
  64. package/dist/entries/queue/queueMetrics.js +761 -78
  65. package/dist/entries/queue/redisQueue.js +768 -148
  66. package/dist/entries/queue.js +30 -5
  67. package/dist/entries/scheduler/schedule.js +1 -72
  68. package/dist/entries/tenant/databaseTenantContext.js +14 -2
  69. package/dist/entries/tenant/tenantDatabaseScope.js +14 -2
  70. package/dist/entries/tracing/tracingMiddleware.js +186 -454
  71. package/dist/framework/public-api.d.ts +14 -10
  72. package/dist/index.js +2444 -688
  73. package/dist/types/services.d.ts +3 -3
  74. package/package.json +17 -8
@@ -29,7 +29,7 @@ var init_asyncContextStore = () => {};
29
29
  // ../../src/core/http/throttleMiddleware.ts
30
30
  import { createHash } from "crypto";
31
31
  import { currentAuthUser } from "@getstrata/core/auth/authContext";
32
- import { currentTenant, rateLimitMultiplierForPlan } from "@getstrata/core/tenant/tenantContext";
32
+ import { currentTenant } from "@getstrata/core/tenant/tenantContext";
33
33
  var {RedisClient } = globalThis.Bun;
34
34
 
35
35
  // ../../src/core/runtime/appEnv.ts
@@ -397,28 +397,32 @@ if ttl < 0 then
397
397
  end
398
398
  return count
399
399
  `;
400
- function redisThrottleKey(request, prefix, identity) {
400
+ function redisThrottleKey(request, prefix, identity, tenantId = currentTenant()?.id ?? null) {
401
401
  const bucket = JSON.stringify([
402
- currentTenant()?.id ?? null,
402
+ tenantId,
403
403
  request.method,
404
404
  currentRequestMeta2().routeTemplate ?? "__unmatched__",
405
405
  identity
406
406
  ]);
407
407
  return namespacedRedisKey(`${prefix}:${createHash("sha256").update(bucket).digest("hex")}`);
408
408
  }
409
- async function consumeRedisThrottle(client, key, decaySeconds, timeoutMs = 1000) {
410
- if (!Number.isFinite(decaySeconds) || decaySeconds < 0 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
409
+ async function consumeRedisThrottle(client, key, decaySeconds, timeoutMs = 1000, signal) {
410
+ const expiryMs = Math.max(1, Math.ceil(decaySeconds * 1000));
411
+ if (!Number.isFinite(decaySeconds) || decaySeconds < 0 || !Number.isSafeInteger(expiryMs) || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
411
412
  throw new Error("Invalid throttle expiry or command timeout.");
412
413
  }
414
+ if (signal?.aborted)
415
+ throw new Error("Throttle consumer is disposed.");
416
+ let onAbort;
413
417
  let timer;
414
418
  try {
419
+ const cancelled = new Promise((_resolve, reject) => {
420
+ onAbort = () => reject(new Error("Throttle consumer is disposed."));
421
+ signal?.addEventListener("abort", onAbort, { once: true });
422
+ });
415
423
  const result = await Promise.race([
416
- client.send("EVAL", [
417
- CONSUME_THROTTLE,
418
- "1",
419
- key,
420
- String(Math.max(1, Math.ceil(decaySeconds * 1000)))
421
- ]),
424
+ cancelled,
425
+ client.send("EVAL", [CONSUME_THROTTLE, "1", key, String(expiryMs)]),
422
426
  new Promise((_resolve, reject) => {
423
427
  timer = setTimeout(() => reject(new Error("Throttle store deadline exceeded.")), timeoutMs);
424
428
  })
@@ -429,11 +433,16 @@ async function consumeRedisThrottle(client, key, decaySeconds, timeoutMs = 1000)
429
433
  return count;
430
434
  } finally {
431
435
  clearTimeout(timer);
436
+ if (onAbort)
437
+ signal?.removeEventListener("abort", onAbort);
432
438
  }
433
439
  }
434
440
  function createRedisThrottleConsumer(options) {
435
441
  let ownedClient;
436
- return async (key, decaySeconds) => {
442
+ const controller = new AbortController;
443
+ const consume = async (key, decaySeconds) => {
444
+ if (controller.signal.aborted)
445
+ throw new Error("Throttle consumer is disposed.");
437
446
  if (!options.redisClient && !ownedClient) {
438
447
  ownedClient = new RedisClient(options.redisUrl, { autoReconnect: false, maxRetries: 0 });
439
448
  }
@@ -441,7 +450,10 @@ function createRedisThrottleConsumer(options) {
441
450
  if (!client)
442
451
  throw new Error("Throttle client is required.");
443
452
  try {
444
- return await consumeRedisThrottle(client, key, decaySeconds, options.commandTimeoutMs);
453
+ const attempts = await consumeRedisThrottle(client, key, decaySeconds, options.commandTimeoutMs, controller.signal);
454
+ if (controller.signal.aborted)
455
+ throw new Error("Throttle consumer is disposed.");
456
+ return attempts;
445
457
  } catch (error) {
446
458
  if (client === ownedClient) {
447
459
  ownedClient.close();
@@ -450,6 +462,17 @@ function createRedisThrottleConsumer(options) {
450
462
  throw error;
451
463
  }
452
464
  };
465
+ return Object.assign(consume, {
466
+ isDisposed: () => controller.signal.aborted,
467
+ dispose() {
468
+ if (controller.signal.aborted)
469
+ return;
470
+ controller.abort();
471
+ const client = ownedClient;
472
+ ownedClient = undefined;
473
+ client?.close();
474
+ }
475
+ });
453
476
  }
454
477
  function throttleUnavailableResponse() {
455
478
  return Response.json({ error: "Rate limiting is temporarily unavailable." }, {
@@ -457,23 +480,48 @@ function throttleUnavailableResponse() {
457
480
  headers: { "retry-after": "1" }
458
481
  });
459
482
  }
483
+ function resolveThrottleQuota(request, options, identity = resolveThrottleIdentity(request)) {
484
+ const limit = options.quotaPolicy ? options.quotaPolicy({
485
+ request,
486
+ tenant: currentTenant(),
487
+ identity,
488
+ routeTemplate: currentRequestMeta2().routeTemplate ?? "__unmatched__",
489
+ maxAttempts: options.maxAttempts
490
+ }) : options.maxAttempts;
491
+ if (!Number.isSafeInteger(limit) || limit < 0) {
492
+ throw new Error("Invalid application quota.");
493
+ }
494
+ return limit;
495
+ }
460
496
  function createThrottleMiddleware(options) {
497
+ if (!Number.isSafeInteger(options.maxAttempts) || options.maxAttempts < 0) {
498
+ throw new Error("Throttle maxAttempts must be a non-negative safe integer.");
499
+ }
461
500
  const consume = createRedisThrottleConsumer(options);
462
501
  const prefix = options.keyPrefix ?? "throttle";
463
- return async (request, next) => {
464
- const key = redisThrottleKey(request, prefix, resolveThrottleIdentity(request));
502
+ const middleware = async (request, next) => {
503
+ const identity = resolveThrottleIdentity(request);
504
+ let maxAttempts;
505
+ try {
506
+ maxAttempts = resolveThrottleQuota(request, options, identity);
507
+ } catch {
508
+ return throttleUnavailableResponse();
509
+ }
510
+ const key = redisThrottleKey(request, prefix, identity);
465
511
  let attempts;
466
512
  try {
467
513
  attempts = await consume(key, options.decaySeconds);
468
514
  } catch {
469
515
  return throttleUnavailableResponse();
470
516
  }
471
- const maxAttempts = options.maxAttempts * rateLimitMultiplierForPlan(currentTenant()?.plan ?? "free");
517
+ if (consume.isDisposed())
518
+ return throttleUnavailableResponse();
472
519
  if (attempts > maxAttempts) {
473
520
  return await tooManyRequestsResponse(request, "Too many requests.", options.decaySeconds);
474
521
  }
475
522
  return await next();
476
523
  };
524
+ return Object.assign(middleware, { dispose: () => consume.dispose() });
477
525
  }
478
526
  export {
479
527
  consumeRedisThrottle,
@@ -481,5 +529,6 @@ export {
481
529
  createThrottleMiddleware,
482
530
  redisThrottleKey,
483
531
  resolveThrottleIdentity,
532
+ resolveThrottleQuota,
484
533
  throttleUnavailableResponse
485
534
  };
@@ -306,9 +306,12 @@ async function runWithDeferredModelEvents(callback) {
306
306
  const existing = deferredModelEvents.getStore();
307
307
  if (existing) {
308
308
  existing.frames.push([]);
309
+ existing.rollbackFrames.push([]);
309
310
  try {
310
311
  const result = await callback();
311
312
  const nested = existing.frames.pop() ?? [];
313
+ const rollback = existing.rollbackFrames.pop() ?? [];
314
+ existing.rollbackFrames.at(-1)?.push(...rollback);
312
315
  const parent = existing.frames[existing.frames.length - 1];
313
316
  if (parent) {
314
317
  parent.push(...nested);
@@ -316,11 +319,20 @@ async function runWithDeferredModelEvents(callback) {
316
319
  return result;
317
320
  } catch (error) {
318
321
  existing.frames.pop();
322
+ for (const rollback of (existing.rollbackFrames.pop() ?? []).reverse())
323
+ rollback();
319
324
  throw error;
320
325
  }
321
326
  }
322
- const state = { frames: [[]] };
323
- const result = await deferredModelEvents.run(state, callback);
327
+ const state = { frames: [[]], rollbackFrames: [[]] };
328
+ let result;
329
+ try {
330
+ result = await deferredModelEvents.run(state, callback);
331
+ } catch (error) {
332
+ for (const rollback of (state.rollbackFrames[0] ?? []).reverse())
333
+ rollback();
334
+ throw error;
335
+ }
324
336
  await dispatchQueuedModelEvents(state.frames[0] ?? []);
325
337
  return result;
326
338
  }
@@ -1450,12 +1462,37 @@ class SyncQueue {
1450
1462
  }
1451
1463
 
1452
1464
  class AsyncQueue {
1465
+ active = new Set;
1466
+ closing;
1467
+ closed = false;
1468
+ assertOpen() {
1469
+ if (this.closed)
1470
+ throw new Error("Async queue is closed.");
1471
+ }
1472
+ enqueue(task, onError) {
1473
+ this.assertOpen();
1474
+ const running = new Promise((resolve) => {
1475
+ setTimeout(() => {
1476
+ Promise.resolve().then(task).catch(onError).finally(resolve);
1477
+ }, 0);
1478
+ });
1479
+ this.active.add(running);
1480
+ running.then(() => {
1481
+ this.active.delete(running);
1482
+ });
1483
+ }
1453
1484
  async dispatch(job, payload) {
1454
- setTimeout(() => {
1455
- job.handle(payload).catch((error) => {
1456
- console.error("[AsyncQueue] Job failed:", error);
1457
- });
1458
- }, 0);
1485
+ this.enqueue(() => job.handle(payload), (error) => {
1486
+ console.error("[AsyncQueue] Job failed:", error);
1487
+ });
1488
+ }
1489
+ close() {
1490
+ this.closing ??= (async () => {
1491
+ while (this.active.size)
1492
+ await Promise.all([...this.active]);
1493
+ this.closed = true;
1494
+ })();
1495
+ return this.closing;
1459
1496
  }
1460
1497
  }
1461
1498
  function createQueue(driver) {
@@ -57,12 +57,37 @@ class SyncQueue {
57
57
  }
58
58
 
59
59
  class AsyncQueue {
60
+ active = new Set;
61
+ closing;
62
+ closed = false;
63
+ assertOpen() {
64
+ if (this.closed)
65
+ throw new Error("Async queue is closed.");
66
+ }
67
+ enqueue(task, onError) {
68
+ this.assertOpen();
69
+ const running = new Promise((resolve) => {
70
+ setTimeout(() => {
71
+ Promise.resolve().then(task).catch(onError).finally(resolve);
72
+ }, 0);
73
+ });
74
+ this.active.add(running);
75
+ running.then(() => {
76
+ this.active.delete(running);
77
+ });
78
+ }
60
79
  async dispatch(job, payload) {
61
- setTimeout(() => {
62
- job.handle(payload).catch((error) => {
63
- console.error("[AsyncQueue] Job failed:", error);
64
- });
65
- }, 0);
80
+ this.enqueue(() => job.handle(payload), (error) => {
81
+ console.error("[AsyncQueue] Job failed:", error);
82
+ });
83
+ }
84
+ close() {
85
+ this.closing ??= (async () => {
86
+ while (this.active.size)
87
+ await Promise.all([...this.active]);
88
+ this.closed = true;
89
+ })();
90
+ return this.closing;
66
91
  }
67
92
  }
68
93
  function createQueue(driver) {