@getstrata/bootstrap 2.0.3 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @getstrata/bootstrap changelog
2
2
 
3
+ ## 2.0.5
4
+
5
+ - Allow explicitly approved application headers in CORS
6
+
7
+ ## 2.0.4
8
+
9
+ - Place cache rollout guidance beside outbox docs without overlapping API additions
10
+
3
11
  ## 2.0.3
4
12
 
5
13
  - Persist transactional events with leased tenant-aware SQL outbox workers
@@ -1,10 +1,11 @@
1
+ import type { OpenApiRouteMap } from "@getstrata/core/openapi/registeredRoute";
1
2
  import type { AppDependencies, AppModule, AppRouteMap } from "./contracts";
2
3
  interface BuildModuleRoutesOptions {
3
4
  apiPrefix?: string;
4
5
  modules?: AppModule[];
5
6
  clearRegistry?: boolean;
6
7
  }
7
- declare function registerOpenApiRouteMap(routes: Record<string, unknown>, middleware: string[]): Record<string, unknown>;
8
+ declare function registerOpenApiRouteMap(routes: Record<string, unknown>, middleware: string[], metadata?: OpenApiRouteMap): Record<string, unknown>;
8
9
  declare function buildModuleRoutes(dependencies: AppDependencies, options?: BuildModuleRoutesOptions): AppRouteMap;
9
10
  export type { BuildModuleRoutesOptions };
10
11
  export { buildModuleRoutes, registerOpenApiRouteMap };
@@ -8,7 +8,10 @@ interface ModuleRouteContext {
8
8
  cachedJson: CachedJson;
9
9
  kernel: HttpKernel;
10
10
  }
11
+ import type { OpenApiRouteMap } from "@getstrata/core/openapi/registeredRoute";
11
12
  interface AppModule {
13
+ /** Paths match this module's API route map before apiPrefix is applied. */
14
+ openApi?: OpenApiRouteMap;
12
15
  name: string;
13
16
  order?: number;
14
17
  tableName?: string;
@@ -18,4 +21,5 @@ interface AppModule {
18
21
  routes?(context: ModuleRouteContext): AppRouteMap;
19
22
  webRoutes?(context: ModuleRouteContext): AppRouteMap;
20
23
  }
24
+ export type { OpenApiOperation, OpenApiRouteMap, OpenApiSchema, } from "@getstrata/core/openapi/registeredRoute";
21
25
  export type { AppModule, ModuleRouteContext };
@@ -1,3 +1,12 @@
1
1
  import { type EventBus } from "@getstrata/core/events";
2
- declare function registerInvalidateCacheOnModelWriteListeners(bus?: EventBus): void;
3
- export { registerInvalidateCacheOnModelWriteListeners };
2
+ import type { DurableListener, SqlOutbox } from "@getstrata/core/events/outbox";
3
+ import type { CacheLike } from "../../types/services";
4
+ interface ModelCacheInvalidationOptions {
5
+ outbox?: SqlOutbox;
6
+ }
7
+ /** Include in the application's one outbox registry; never dispatch through Redis queues. */
8
+ declare function createModelCacheInvalidationListener(resolveCache?: () => CacheLike): DurableListener;
9
+ /** Durable mode requires the outbox schema and an explicit business transaction. */
10
+ declare function registerInvalidateCacheOnModelWriteListeners(bus?: EventBus, options?: ModelCacheInvalidationOptions): () => void;
11
+ export type { ModelCacheInvalidationOptions };
12
+ export { createModelCacheInvalidationListener, registerInvalidateCacheOnModelWriteListeners };
@@ -428,22 +428,22 @@ function readSharedRouteRegistry() {
428
428
  var routeRegistry = readSharedRouteRegistry();
429
429
 
430
430
  // ../../src/bootstrap/buildModuleRoutes.ts
431
- function registerOpenApiRoute(method, path, middleware) {
432
- routeRegistry.register({ method, path, middleware });
431
+ function registerOpenApiRoute(method, path, middleware, openApi) {
432
+ routeRegistry.register({ method, path, middleware, ...openApi ? { openApi } : {} });
433
433
  }
434
- function registerOpenApiRouteMap(routes, middleware) {
434
+ function registerOpenApiRouteMap(routes, middleware, metadata = {}) {
435
435
  const registered = {};
436
436
  for (const [path, handler] of Object.entries(routes)) {
437
437
  if (handler && typeof handler === "object" && !Array.isArray(handler)) {
438
438
  const methodMap = handler;
439
439
  registered[path] = methodMap;
440
440
  for (const method of Object.keys(methodMap)) {
441
- registerOpenApiRoute(method.toUpperCase(), path, middleware);
441
+ registerOpenApiRoute(method.toUpperCase(), path, middleware, metadata[path]?.[method.toUpperCase()]);
442
442
  }
443
443
  continue;
444
444
  }
445
445
  registered[path] = handler;
446
- registerOpenApiRoute("GET", path, middleware);
446
+ registerOpenApiRoute("GET", path, middleware, metadata[path]?.GET);
447
447
  }
448
448
  return registered;
449
449
  }
@@ -462,14 +462,29 @@ function buildModuleRoutes(dependencies, options = {}) {
462
462
  const middleware = [...kernel.globalMiddleware(), ...kernel.group("api")];
463
463
  const cachedJson = createCachedJson(dependencies);
464
464
  const moduleRoutes = {};
465
+ const metadata = {};
465
466
  for (const module of modules) {
466
467
  if (!module.routes) {
467
468
  continue;
468
469
  }
469
- Object.assign(moduleRoutes, module.routes({ dependencies, cachedJson, kernel }));
470
+ const routes = module.routes({ dependencies, cachedJson, kernel });
471
+ for (const [path, operations] of Object.entries(module.openApi ?? {})) {
472
+ if (!(path in routes))
473
+ throw new Error(`OpenAPI metadata has no API route: ${module.name} ${path}`);
474
+ const methods = typeof routes[path] === "function" ? ["GET"] : Object.keys(routes[path]).map((method) => method.toUpperCase());
475
+ for (const method of Object.keys(operations))
476
+ if (!methods.includes(method))
477
+ throw new Error(`OpenAPI metadata has no API handler: ${method} ${path}`);
478
+ }
479
+ for (const path of Object.keys(routes)) {
480
+ delete metadata[path];
481
+ if (module.openApi?.[path])
482
+ metadata[path] = module.openApi[path];
483
+ }
484
+ Object.assign(moduleRoutes, routes);
470
485
  }
471
486
  const prefixedModuleRoutes = prefixRouteMap(apiPrefix, moduleRoutes);
472
- return applyMiddlewareToRoutes(registerOpenApiRouteMap(prefixedModuleRoutes, ["global", "api"]), middleware);
487
+ return applyMiddlewareToRoutes(registerOpenApiRouteMap(prefixedModuleRoutes, ["global", "api"], prefixRouteMap(apiPrefix, metadata)), middleware);
473
488
  }
474
489
  export {
475
490
  buildModuleRoutes,
@@ -431,22 +431,22 @@ function readSharedRouteRegistry() {
431
431
  var routeRegistry = readSharedRouteRegistry();
432
432
 
433
433
  // ../../src/bootstrap/buildModuleRoutes.ts
434
- function registerOpenApiRoute(method, path, middleware) {
435
- routeRegistry.register({ method, path, middleware });
434
+ function registerOpenApiRoute(method, path, middleware, openApi) {
435
+ routeRegistry.register({ method, path, middleware, ...openApi ? { openApi } : {} });
436
436
  }
437
- function registerOpenApiRouteMap(routes, middleware) {
437
+ function registerOpenApiRouteMap(routes, middleware, metadata = {}) {
438
438
  const registered = {};
439
439
  for (const [path, handler] of Object.entries(routes)) {
440
440
  if (handler && typeof handler === "object" && !Array.isArray(handler)) {
441
441
  const methodMap = handler;
442
442
  registered[path] = methodMap;
443
443
  for (const method of Object.keys(methodMap)) {
444
- registerOpenApiRoute(method.toUpperCase(), path, middleware);
444
+ registerOpenApiRoute(method.toUpperCase(), path, middleware, metadata[path]?.[method.toUpperCase()]);
445
445
  }
446
446
  continue;
447
447
  }
448
448
  registered[path] = handler;
449
- registerOpenApiRoute("GET", path, middleware);
449
+ registerOpenApiRoute("GET", path, middleware, metadata[path]?.GET);
450
450
  }
451
451
  return registered;
452
452
  }
@@ -465,14 +465,29 @@ function buildModuleRoutes(dependencies, options = {}) {
465
465
  const middleware = [...kernel.globalMiddleware(), ...kernel.group("api")];
466
466
  const cachedJson = createCachedJson(dependencies);
467
467
  const moduleRoutes = {};
468
+ const metadata = {};
468
469
  for (const module of modules) {
469
470
  if (!module.routes) {
470
471
  continue;
471
472
  }
472
- Object.assign(moduleRoutes, module.routes({ dependencies, cachedJson, kernel }));
473
+ const routes = module.routes({ dependencies, cachedJson, kernel });
474
+ for (const [path, operations] of Object.entries(module.openApi ?? {})) {
475
+ if (!(path in routes))
476
+ throw new Error(`OpenAPI metadata has no API route: ${module.name} ${path}`);
477
+ const methods = typeof routes[path] === "function" ? ["GET"] : Object.keys(routes[path]).map((method) => method.toUpperCase());
478
+ for (const method of Object.keys(operations))
479
+ if (!methods.includes(method))
480
+ throw new Error(`OpenAPI metadata has no API handler: ${method} ${path}`);
481
+ }
482
+ for (const path of Object.keys(routes)) {
483
+ delete metadata[path];
484
+ if (module.openApi?.[path])
485
+ metadata[path] = module.openApi[path];
486
+ }
487
+ Object.assign(moduleRoutes, routes);
473
488
  }
474
489
  const prefixedModuleRoutes = prefixRouteMap(apiPrefix, moduleRoutes);
475
- return applyMiddlewareToRoutes(registerOpenApiRouteMap(prefixedModuleRoutes, ["global", "api"]), middleware);
490
+ return applyMiddlewareToRoutes(registerOpenApiRouteMap(prefixedModuleRoutes, ["global", "api"], prefixRouteMap(apiPrefix, metadata)), middleware);
476
491
  }
477
492
 
478
493
  // ../../src/bootstrap/buildWebModuleRoutes.ts
@@ -285,6 +285,9 @@ var appEnvSchema = defineEnvSchema({
285
285
  API_PREFIX: {
286
286
  default: "/api/v1"
287
287
  },
288
+ CORS_ADDITIONAL_ALLOWED_HEADERS: {
289
+ default: ""
290
+ },
288
291
  CORS_ALLOWED_ORIGINS: {
289
292
  default: "*"
290
293
  },
@@ -437,14 +440,88 @@ function discoverModelTableNames() {
437
440
 
438
441
  // ../../src/bootstrap/listeners/invalidateCacheOnModelWrite.ts
439
442
  var MODEL_WRITE_ACTIONS = ["created", "updated", "deleted", "restored", "force-deleted"];
440
- function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2) {
441
- for (const tableName of discoverModelTableNames()) {
442
- for (const action of MODEL_WRITE_ACTIONS) {
443
- bus.listen(modelEventName(tableName, action), async () => {
443
+ var CACHE_EVENT = "strata.cache.invalidate-tags";
444
+ var STATE_KEY = Symbol.for("@getstrata/modelCacheInvalidationRegistrations");
445
+ function registrations() {
446
+ const global = globalThis;
447
+ global[STATE_KEY] ??= new WeakMap;
448
+ return global[STATE_KEY];
449
+ }
450
+ function requireCacheTags(value) {
451
+ if (!Array.isArray(value) || !value.length || value.length > 128 || !value.every((tag) => typeof tag === "string" && tag.length > 0 && tag.length <= 256))
452
+ throw new Error("Invalid durable cache invalidation payload.");
453
+ return value;
454
+ }
455
+ function createModelCacheInvalidationListener(resolveCache = resolveApplicationCache) {
456
+ return {
457
+ name: "strata.cache.invalidate-tags.v1",
458
+ event: CACHE_EVENT,
459
+ async handle(event, { signal }) {
460
+ const payload = event.payload;
461
+ if (event.version !== 1)
462
+ throw new Error("Invalid durable cache invalidation version.");
463
+ const tags = requireCacheTags(payload?.tags);
464
+ signal.throwIfAborted();
465
+ await new InvalidateCacheTagsJob(resolveCache()).handle({ tags });
466
+ signal.throwIfAborted();
467
+ }
468
+ };
469
+ }
470
+ function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2, options = {}) {
471
+ if (options.outbox && !options.outbox.hasListener("strata.cache.invalidate-tags.v1", CACHE_EVENT))
472
+ throw new Error("The application outbox must include createModelCacheInvalidationListener().");
473
+ const writes = discoverModelTableNames().flatMap((tableName) => MODEL_WRITE_ACTIONS.map((action) => ({
474
+ tableName,
475
+ action,
476
+ tags: cacheTagsForModelWrite(tableName, action)
477
+ })));
478
+ if (options.outbox) {
479
+ for (const write of writes)
480
+ if (write.tags.length)
481
+ requireCacheTags(write.tags);
482
+ }
483
+ const state = registrations();
484
+ const previous = state.get(bus);
485
+ if (previous?.durable && !options.outbox)
486
+ return () => {};
487
+ previous?.suspend();
488
+ const unsubscribe = [];
489
+ const subscribe = [];
490
+ const registration = {
491
+ durable: !!options.outbox,
492
+ suspend() {
493
+ for (const stop of unsubscribe.splice(0))
494
+ stop();
495
+ },
496
+ resume() {
497
+ for (const start of subscribe)
498
+ unsubscribe.push(start());
499
+ state.set(bus, registration);
500
+ },
501
+ dispose() {
502
+ if (state.get(bus) !== registration)
503
+ return;
504
+ registration.suspend();
505
+ if (previous)
506
+ previous.resume();
507
+ else
508
+ state.delete(bus);
509
+ }
510
+ };
511
+ for (const { tableName, action, tags: durableTags } of writes) {
512
+ const event = modelEventName(tableName, action);
513
+ if (options.outbox) {
514
+ if (!durableTags.length)
515
+ continue;
516
+ const outbox = options.outbox;
517
+ subscribe.push(() => bus.listenTransactional(event, async () => {
518
+ await outbox.publish(CACHE_EVENT, { tags: durableTags });
519
+ }));
520
+ } else {
521
+ subscribe.push(() => bus.listen(event, async () => {
444
522
  const tags = cacheTagsForModelWrite(tableName, action);
445
- if (tags.length === 0) {
523
+ if (!tags.length)
446
524
  return;
447
- }
448
525
  let cache;
449
526
  let queue;
450
527
  try {
@@ -455,9 +532,11 @@ function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2) {
455
532
  }
456
533
  const job = createTrackedJob("cache.invalidate-tags", new InvalidateCacheTagsJob(cache));
457
534
  await queue.dispatch(job, { tags });
458
- });
535
+ }));
459
536
  }
460
537
  }
538
+ registration.resume();
539
+ return registration.dispose;
461
540
  }
462
541
 
463
542
  // ../../src/bootstrap/providers/listeners.ts
@@ -437,22 +437,22 @@ function readSharedRouteRegistry() {
437
437
  var routeRegistry = readSharedRouteRegistry();
438
438
 
439
439
  // ../../src/bootstrap/buildModuleRoutes.ts
440
- function registerOpenApiRoute(method, path, middleware) {
441
- routeRegistry.register({ method, path, middleware });
440
+ function registerOpenApiRoute(method, path, middleware, openApi) {
441
+ routeRegistry.register({ method, path, middleware, ...openApi ? { openApi } : {} });
442
442
  }
443
- function registerOpenApiRouteMap(routes, middleware) {
443
+ function registerOpenApiRouteMap(routes, middleware, metadata = {}) {
444
444
  const registered = {};
445
445
  for (const [path, handler] of Object.entries(routes)) {
446
446
  if (handler && typeof handler === "object" && !Array.isArray(handler)) {
447
447
  const methodMap = handler;
448
448
  registered[path] = methodMap;
449
449
  for (const method of Object.keys(methodMap)) {
450
- registerOpenApiRoute(method.toUpperCase(), path, middleware);
450
+ registerOpenApiRoute(method.toUpperCase(), path, middleware, metadata[path]?.[method.toUpperCase()]);
451
451
  }
452
452
  continue;
453
453
  }
454
454
  registered[path] = handler;
455
- registerOpenApiRoute("GET", path, middleware);
455
+ registerOpenApiRoute("GET", path, middleware, metadata[path]?.GET);
456
456
  }
457
457
  return registered;
458
458
  }
@@ -471,14 +471,29 @@ function buildModuleRoutes(dependencies, options = {}) {
471
471
  const middleware = [...kernel.globalMiddleware(), ...kernel.group("api")];
472
472
  const cachedJson = createCachedJson(dependencies);
473
473
  const moduleRoutes = {};
474
+ const metadata = {};
474
475
  for (const module of modules) {
475
476
  if (!module.routes) {
476
477
  continue;
477
478
  }
478
- Object.assign(moduleRoutes, module.routes({ dependencies, cachedJson, kernel }));
479
+ const routes = module.routes({ dependencies, cachedJson, kernel });
480
+ for (const [path, operations] of Object.entries(module.openApi ?? {})) {
481
+ if (!(path in routes))
482
+ throw new Error(`OpenAPI metadata has no API route: ${module.name} ${path}`);
483
+ const methods = typeof routes[path] === "function" ? ["GET"] : Object.keys(routes[path]).map((method) => method.toUpperCase());
484
+ for (const method of Object.keys(operations))
485
+ if (!methods.includes(method))
486
+ throw new Error(`OpenAPI metadata has no API handler: ${method} ${path}`);
487
+ }
488
+ for (const path of Object.keys(routes)) {
489
+ delete metadata[path];
490
+ if (module.openApi?.[path])
491
+ metadata[path] = module.openApi[path];
492
+ }
493
+ Object.assign(moduleRoutes, routes);
479
494
  }
480
495
  const prefixedModuleRoutes = prefixRouteMap(apiPrefix, moduleRoutes);
481
- return applyMiddlewareToRoutes(registerOpenApiRouteMap(prefixedModuleRoutes, ["global", "api"]), middleware);
496
+ return applyMiddlewareToRoutes(registerOpenApiRouteMap(prefixedModuleRoutes, ["global", "api"], prefixRouteMap(apiPrefix, metadata)), middleware);
482
497
  }
483
498
 
484
499
  // ../../src/bootstrap/buildWebModuleRoutes.ts
@@ -285,6 +285,9 @@ var appEnvSchema = defineEnvSchema({
285
285
  API_PREFIX: {
286
286
  default: "/api/v1"
287
287
  },
288
+ CORS_ADDITIONAL_ALLOWED_HEADERS: {
289
+ default: ""
290
+ },
288
291
  CORS_ALLOWED_ORIGINS: {
289
292
  default: "*"
290
293
  },
@@ -437,14 +440,88 @@ function discoverModelTableNames() {
437
440
 
438
441
  // ../../src/bootstrap/listeners/invalidateCacheOnModelWrite.ts
439
442
  var MODEL_WRITE_ACTIONS = ["created", "updated", "deleted", "restored", "force-deleted"];
440
- function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2) {
441
- for (const tableName of discoverModelTableNames()) {
442
- for (const action of MODEL_WRITE_ACTIONS) {
443
- bus.listen(modelEventName(tableName, action), async () => {
443
+ var CACHE_EVENT = "strata.cache.invalidate-tags";
444
+ var STATE_KEY = Symbol.for("@getstrata/modelCacheInvalidationRegistrations");
445
+ function registrations() {
446
+ const global = globalThis;
447
+ global[STATE_KEY] ??= new WeakMap;
448
+ return global[STATE_KEY];
449
+ }
450
+ function requireCacheTags(value) {
451
+ if (!Array.isArray(value) || !value.length || value.length > 128 || !value.every((tag) => typeof tag === "string" && tag.length > 0 && tag.length <= 256))
452
+ throw new Error("Invalid durable cache invalidation payload.");
453
+ return value;
454
+ }
455
+ function createModelCacheInvalidationListener(resolveCache = resolveApplicationCache) {
456
+ return {
457
+ name: "strata.cache.invalidate-tags.v1",
458
+ event: CACHE_EVENT,
459
+ async handle(event, { signal }) {
460
+ const payload = event.payload;
461
+ if (event.version !== 1)
462
+ throw new Error("Invalid durable cache invalidation version.");
463
+ const tags = requireCacheTags(payload?.tags);
464
+ signal.throwIfAborted();
465
+ await new InvalidateCacheTagsJob(resolveCache()).handle({ tags });
466
+ signal.throwIfAborted();
467
+ }
468
+ };
469
+ }
470
+ function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2, options = {}) {
471
+ if (options.outbox && !options.outbox.hasListener("strata.cache.invalidate-tags.v1", CACHE_EVENT))
472
+ throw new Error("The application outbox must include createModelCacheInvalidationListener().");
473
+ const writes = discoverModelTableNames().flatMap((tableName) => MODEL_WRITE_ACTIONS.map((action) => ({
474
+ tableName,
475
+ action,
476
+ tags: cacheTagsForModelWrite(tableName, action)
477
+ })));
478
+ if (options.outbox) {
479
+ for (const write of writes)
480
+ if (write.tags.length)
481
+ requireCacheTags(write.tags);
482
+ }
483
+ const state = registrations();
484
+ const previous = state.get(bus);
485
+ if (previous?.durable && !options.outbox)
486
+ return () => {};
487
+ previous?.suspend();
488
+ const unsubscribe = [];
489
+ const subscribe = [];
490
+ const registration = {
491
+ durable: !!options.outbox,
492
+ suspend() {
493
+ for (const stop of unsubscribe.splice(0))
494
+ stop();
495
+ },
496
+ resume() {
497
+ for (const start of subscribe)
498
+ unsubscribe.push(start());
499
+ state.set(bus, registration);
500
+ },
501
+ dispose() {
502
+ if (state.get(bus) !== registration)
503
+ return;
504
+ registration.suspend();
505
+ if (previous)
506
+ previous.resume();
507
+ else
508
+ state.delete(bus);
509
+ }
510
+ };
511
+ for (const { tableName, action, tags: durableTags } of writes) {
512
+ const event = modelEventName(tableName, action);
513
+ if (options.outbox) {
514
+ if (!durableTags.length)
515
+ continue;
516
+ const outbox = options.outbox;
517
+ subscribe.push(() => bus.listenTransactional(event, async () => {
518
+ await outbox.publish(CACHE_EVENT, { tags: durableTags });
519
+ }));
520
+ } else {
521
+ subscribe.push(() => bus.listen(event, async () => {
444
522
  const tags = cacheTagsForModelWrite(tableName, action);
445
- if (tags.length === 0) {
523
+ if (!tags.length)
446
524
  return;
447
- }
448
525
  let cache;
449
526
  let queue;
450
527
  try {
@@ -455,9 +532,11 @@ function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2) {
455
532
  }
456
533
  const job = createTrackedJob("cache.invalidate-tags", new InvalidateCacheTagsJob(cache));
457
534
  await queue.dispatch(job, { tags });
458
- });
535
+ }));
459
536
  }
460
537
  }
538
+ registration.resume();
539
+ return registration.dispose;
461
540
  }
462
541
 
463
542
  // ../../src/bootstrap/providers/listeners.ts
@@ -102,14 +102,88 @@ function discoverModelTableNames() {
102
102
 
103
103
  // ../../src/bootstrap/listeners/invalidateCacheOnModelWrite.ts
104
104
  var MODEL_WRITE_ACTIONS = ["created", "updated", "deleted", "restored", "force-deleted"];
105
- function registerInvalidateCacheOnModelWriteListeners(bus = eventBus) {
106
- for (const tableName of discoverModelTableNames()) {
107
- for (const action of MODEL_WRITE_ACTIONS) {
108
- bus.listen(modelEventName(tableName, action), async () => {
105
+ var CACHE_EVENT = "strata.cache.invalidate-tags";
106
+ var STATE_KEY = Symbol.for("@getstrata/modelCacheInvalidationRegistrations");
107
+ function registrations() {
108
+ const global = globalThis;
109
+ global[STATE_KEY] ??= new WeakMap;
110
+ return global[STATE_KEY];
111
+ }
112
+ function requireCacheTags(value) {
113
+ if (!Array.isArray(value) || !value.length || value.length > 128 || !value.every((tag) => typeof tag === "string" && tag.length > 0 && tag.length <= 256))
114
+ throw new Error("Invalid durable cache invalidation payload.");
115
+ return value;
116
+ }
117
+ function createModelCacheInvalidationListener(resolveCache = resolveApplicationCache) {
118
+ return {
119
+ name: "strata.cache.invalidate-tags.v1",
120
+ event: CACHE_EVENT,
121
+ async handle(event, { signal }) {
122
+ const payload = event.payload;
123
+ if (event.version !== 1)
124
+ throw new Error("Invalid durable cache invalidation version.");
125
+ const tags = requireCacheTags(payload?.tags);
126
+ signal.throwIfAborted();
127
+ await new InvalidateCacheTagsJob(resolveCache()).handle({ tags });
128
+ signal.throwIfAborted();
129
+ }
130
+ };
131
+ }
132
+ function registerInvalidateCacheOnModelWriteListeners(bus = eventBus, options = {}) {
133
+ if (options.outbox && !options.outbox.hasListener("strata.cache.invalidate-tags.v1", CACHE_EVENT))
134
+ throw new Error("The application outbox must include createModelCacheInvalidationListener().");
135
+ const writes = discoverModelTableNames().flatMap((tableName) => MODEL_WRITE_ACTIONS.map((action) => ({
136
+ tableName,
137
+ action,
138
+ tags: cacheTagsForModelWrite(tableName, action)
139
+ })));
140
+ if (options.outbox) {
141
+ for (const write of writes)
142
+ if (write.tags.length)
143
+ requireCacheTags(write.tags);
144
+ }
145
+ const state = registrations();
146
+ const previous = state.get(bus);
147
+ if (previous?.durable && !options.outbox)
148
+ return () => {};
149
+ previous?.suspend();
150
+ const unsubscribe = [];
151
+ const subscribe = [];
152
+ const registration = {
153
+ durable: !!options.outbox,
154
+ suspend() {
155
+ for (const stop of unsubscribe.splice(0))
156
+ stop();
157
+ },
158
+ resume() {
159
+ for (const start of subscribe)
160
+ unsubscribe.push(start());
161
+ state.set(bus, registration);
162
+ },
163
+ dispose() {
164
+ if (state.get(bus) !== registration)
165
+ return;
166
+ registration.suspend();
167
+ if (previous)
168
+ previous.resume();
169
+ else
170
+ state.delete(bus);
171
+ }
172
+ };
173
+ for (const { tableName, action, tags: durableTags } of writes) {
174
+ const event = modelEventName(tableName, action);
175
+ if (options.outbox) {
176
+ if (!durableTags.length)
177
+ continue;
178
+ const outbox = options.outbox;
179
+ subscribe.push(() => bus.listenTransactional(event, async () => {
180
+ await outbox.publish(CACHE_EVENT, { tags: durableTags });
181
+ }));
182
+ } else {
183
+ subscribe.push(() => bus.listen(event, async () => {
109
184
  const tags = cacheTagsForModelWrite(tableName, action);
110
- if (tags.length === 0) {
185
+ if (!tags.length)
111
186
  return;
112
- }
113
187
  let cache;
114
188
  let queue;
115
189
  try {
@@ -120,10 +194,13 @@ function registerInvalidateCacheOnModelWriteListeners(bus = eventBus) {
120
194
  }
121
195
  const job = createTrackedJob("cache.invalidate-tags", new InvalidateCacheTagsJob(cache));
122
196
  await queue.dispatch(job, { tags });
123
- });
197
+ }));
124
198
  }
125
199
  }
200
+ registration.resume();
201
+ return registration.dispose;
126
202
  }
127
203
  export {
204
+ createModelCacheInvalidationListener,
128
205
  registerInvalidateCacheOnModelWriteListeners
129
206
  };
@@ -191,6 +191,9 @@ var appEnvSchema = defineEnvSchema({
191
191
  API_PREFIX: {
192
192
  default: "/api/v1"
193
193
  },
194
+ CORS_ADDITIONAL_ALLOWED_HEADERS: {
195
+ default: ""
196
+ },
194
197
  CORS_ALLOWED_ORIGINS: {
195
198
  default: "*"
196
199
  },
@@ -427,14 +430,88 @@ function discoverModelTableNames() {
427
430
 
428
431
  // ../../src/bootstrap/listeners/invalidateCacheOnModelWrite.ts
429
432
  var MODEL_WRITE_ACTIONS = ["created", "updated", "deleted", "restored", "force-deleted"];
430
- function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2) {
431
- for (const tableName of discoverModelTableNames()) {
432
- for (const action of MODEL_WRITE_ACTIONS) {
433
- bus.listen(modelEventName(tableName, action), async () => {
433
+ var CACHE_EVENT = "strata.cache.invalidate-tags";
434
+ var STATE_KEY = Symbol.for("@getstrata/modelCacheInvalidationRegistrations");
435
+ function registrations() {
436
+ const global = globalThis;
437
+ global[STATE_KEY] ??= new WeakMap;
438
+ return global[STATE_KEY];
439
+ }
440
+ function requireCacheTags(value) {
441
+ if (!Array.isArray(value) || !value.length || value.length > 128 || !value.every((tag) => typeof tag === "string" && tag.length > 0 && tag.length <= 256))
442
+ throw new Error("Invalid durable cache invalidation payload.");
443
+ return value;
444
+ }
445
+ function createModelCacheInvalidationListener(resolveCache = resolveApplicationCache) {
446
+ return {
447
+ name: "strata.cache.invalidate-tags.v1",
448
+ event: CACHE_EVENT,
449
+ async handle(event, { signal }) {
450
+ const payload = event.payload;
451
+ if (event.version !== 1)
452
+ throw new Error("Invalid durable cache invalidation version.");
453
+ const tags = requireCacheTags(payload?.tags);
454
+ signal.throwIfAborted();
455
+ await new InvalidateCacheTagsJob(resolveCache()).handle({ tags });
456
+ signal.throwIfAborted();
457
+ }
458
+ };
459
+ }
460
+ function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2, options = {}) {
461
+ if (options.outbox && !options.outbox.hasListener("strata.cache.invalidate-tags.v1", CACHE_EVENT))
462
+ throw new Error("The application outbox must include createModelCacheInvalidationListener().");
463
+ const writes = discoverModelTableNames().flatMap((tableName) => MODEL_WRITE_ACTIONS.map((action) => ({
464
+ tableName,
465
+ action,
466
+ tags: cacheTagsForModelWrite(tableName, action)
467
+ })));
468
+ if (options.outbox) {
469
+ for (const write of writes)
470
+ if (write.tags.length)
471
+ requireCacheTags(write.tags);
472
+ }
473
+ const state = registrations();
474
+ const previous = state.get(bus);
475
+ if (previous?.durable && !options.outbox)
476
+ return () => {};
477
+ previous?.suspend();
478
+ const unsubscribe = [];
479
+ const subscribe = [];
480
+ const registration = {
481
+ durable: !!options.outbox,
482
+ suspend() {
483
+ for (const stop of unsubscribe.splice(0))
484
+ stop();
485
+ },
486
+ resume() {
487
+ for (const start of subscribe)
488
+ unsubscribe.push(start());
489
+ state.set(bus, registration);
490
+ },
491
+ dispose() {
492
+ if (state.get(bus) !== registration)
493
+ return;
494
+ registration.suspend();
495
+ if (previous)
496
+ previous.resume();
497
+ else
498
+ state.delete(bus);
499
+ }
500
+ };
501
+ for (const { tableName, action, tags: durableTags } of writes) {
502
+ const event = modelEventName(tableName, action);
503
+ if (options.outbox) {
504
+ if (!durableTags.length)
505
+ continue;
506
+ const outbox = options.outbox;
507
+ subscribe.push(() => bus.listenTransactional(event, async () => {
508
+ await outbox.publish(CACHE_EVENT, { tags: durableTags });
509
+ }));
510
+ } else {
511
+ subscribe.push(() => bus.listen(event, async () => {
434
512
  const tags = cacheTagsForModelWrite(tableName, action);
435
- if (tags.length === 0) {
513
+ if (!tags.length)
436
514
  return;
437
- }
438
515
  let cache;
439
516
  let queue;
440
517
  try {
@@ -445,9 +522,11 @@ function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2) {
445
522
  }
446
523
  const job = createTrackedJob("cache.invalidate-tags", new InvalidateCacheTagsJob(cache));
447
524
  await queue.dispatch(job, { tags });
448
- });
525
+ }));
449
526
  }
450
527
  }
528
+ registration.resume();
529
+ return registration.dispose;
451
530
  }
452
531
 
453
532
  // ../../src/bootstrap/providers/listeners.ts
package/dist/index.js CHANGED
@@ -436,22 +436,22 @@ function readSharedRouteRegistry() {
436
436
  var routeRegistry = readSharedRouteRegistry();
437
437
 
438
438
  // ../../src/bootstrap/buildModuleRoutes.ts
439
- function registerOpenApiRoute(method, path, middleware) {
440
- routeRegistry.register({ method, path, middleware });
439
+ function registerOpenApiRoute(method, path, middleware, openApi) {
440
+ routeRegistry.register({ method, path, middleware, ...openApi ? { openApi } : {} });
441
441
  }
442
- function registerOpenApiRouteMap(routes, middleware) {
442
+ function registerOpenApiRouteMap(routes, middleware, metadata = {}) {
443
443
  const registered = {};
444
444
  for (const [path, handler] of Object.entries(routes)) {
445
445
  if (handler && typeof handler === "object" && !Array.isArray(handler)) {
446
446
  const methodMap = handler;
447
447
  registered[path] = methodMap;
448
448
  for (const method of Object.keys(methodMap)) {
449
- registerOpenApiRoute(method.toUpperCase(), path, middleware);
449
+ registerOpenApiRoute(method.toUpperCase(), path, middleware, metadata[path]?.[method.toUpperCase()]);
450
450
  }
451
451
  continue;
452
452
  }
453
453
  registered[path] = handler;
454
- registerOpenApiRoute("GET", path, middleware);
454
+ registerOpenApiRoute("GET", path, middleware, metadata[path]?.GET);
455
455
  }
456
456
  return registered;
457
457
  }
@@ -470,14 +470,29 @@ function buildModuleRoutes(dependencies, options = {}) {
470
470
  const middleware = [...kernel.globalMiddleware(), ...kernel.group("api")];
471
471
  const cachedJson = createCachedJson(dependencies);
472
472
  const moduleRoutes = {};
473
+ const metadata = {};
473
474
  for (const module of modules) {
474
475
  if (!module.routes) {
475
476
  continue;
476
477
  }
477
- Object.assign(moduleRoutes, module.routes({ dependencies, cachedJson, kernel }));
478
+ const routes = module.routes({ dependencies, cachedJson, kernel });
479
+ for (const [path, operations] of Object.entries(module.openApi ?? {})) {
480
+ if (!(path in routes))
481
+ throw new Error(`OpenAPI metadata has no API route: ${module.name} ${path}`);
482
+ const methods = typeof routes[path] === "function" ? ["GET"] : Object.keys(routes[path]).map((method) => method.toUpperCase());
483
+ for (const method of Object.keys(operations))
484
+ if (!methods.includes(method))
485
+ throw new Error(`OpenAPI metadata has no API handler: ${method} ${path}`);
486
+ }
487
+ for (const path of Object.keys(routes)) {
488
+ delete metadata[path];
489
+ if (module.openApi?.[path])
490
+ metadata[path] = module.openApi[path];
491
+ }
492
+ Object.assign(moduleRoutes, routes);
478
493
  }
479
494
  const prefixedModuleRoutes = prefixRouteMap(apiPrefix, moduleRoutes);
480
- return applyMiddlewareToRoutes(registerOpenApiRouteMap(prefixedModuleRoutes, ["global", "api"]), middleware);
495
+ return applyMiddlewareToRoutes(registerOpenApiRouteMap(prefixedModuleRoutes, ["global", "api"], prefixRouteMap(apiPrefix, metadata)), middleware);
481
496
  }
482
497
  // ../../src/bootstrap/buildWebModuleRoutes.ts
483
498
  import { applyMiddlewareToRoutes as applyMiddlewareToRoutes2 } from "@getstrata/core/http/middleware";
@@ -689,6 +704,9 @@ var appEnvSchema = defineEnvSchema({
689
704
  API_PREFIX: {
690
705
  default: "/api/v1"
691
706
  },
707
+ CORS_ADDITIONAL_ALLOWED_HEADERS: {
708
+ default: ""
709
+ },
692
710
  CORS_ALLOWED_ORIGINS: {
693
711
  default: "*"
694
712
  },
@@ -823,14 +841,73 @@ import { eventBus as eventBus2, modelEventName } from "@getstrata/core/events";
823
841
  import { InvalidateCacheTagsJob } from "@getstrata/core/jobs/invalidateCacheTagsJob";
824
842
  import { createTrackedJob } from "@getstrata/core/queue/createAppQueue";
825
843
  var MODEL_WRITE_ACTIONS = ["created", "updated", "deleted", "restored", "force-deleted"];
826
- function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2) {
827
- for (const tableName of discoverModelTableNames()) {
828
- for (const action of MODEL_WRITE_ACTIONS) {
829
- bus.listen(modelEventName(tableName, action), async () => {
844
+ var CACHE_EVENT = "strata.cache.invalidate-tags";
845
+ var STATE_KEY = Symbol.for("@getstrata/modelCacheInvalidationRegistrations");
846
+ function registrations() {
847
+ const global = globalThis;
848
+ global[STATE_KEY] ??= new WeakMap;
849
+ return global[STATE_KEY];
850
+ }
851
+ function requireCacheTags(value) {
852
+ if (!Array.isArray(value) || !value.length || value.length > 128 || !value.every((tag) => typeof tag === "string" && tag.length > 0 && tag.length <= 256))
853
+ throw new Error("Invalid durable cache invalidation payload.");
854
+ return value;
855
+ }
856
+ function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2, options = {}) {
857
+ if (options.outbox && !options.outbox.hasListener("strata.cache.invalidate-tags.v1", CACHE_EVENT))
858
+ throw new Error("The application outbox must include createModelCacheInvalidationListener().");
859
+ const writes = discoverModelTableNames().flatMap((tableName) => MODEL_WRITE_ACTIONS.map((action) => ({
860
+ tableName,
861
+ action,
862
+ tags: cacheTagsForModelWrite(tableName, action)
863
+ })));
864
+ if (options.outbox) {
865
+ for (const write of writes)
866
+ if (write.tags.length)
867
+ requireCacheTags(write.tags);
868
+ }
869
+ const state = registrations();
870
+ const previous = state.get(bus);
871
+ if (previous?.durable && !options.outbox)
872
+ return () => {};
873
+ previous?.suspend();
874
+ const unsubscribe = [];
875
+ const subscribe = [];
876
+ const registration = {
877
+ durable: !!options.outbox,
878
+ suspend() {
879
+ for (const stop of unsubscribe.splice(0))
880
+ stop();
881
+ },
882
+ resume() {
883
+ for (const start of subscribe)
884
+ unsubscribe.push(start());
885
+ state.set(bus, registration);
886
+ },
887
+ dispose() {
888
+ if (state.get(bus) !== registration)
889
+ return;
890
+ registration.suspend();
891
+ if (previous)
892
+ previous.resume();
893
+ else
894
+ state.delete(bus);
895
+ }
896
+ };
897
+ for (const { tableName, action, tags: durableTags } of writes) {
898
+ const event = modelEventName(tableName, action);
899
+ if (options.outbox) {
900
+ if (!durableTags.length)
901
+ continue;
902
+ const outbox = options.outbox;
903
+ subscribe.push(() => bus.listenTransactional(event, async () => {
904
+ await outbox.publish(CACHE_EVENT, { tags: durableTags });
905
+ }));
906
+ } else {
907
+ subscribe.push(() => bus.listen(event, async () => {
830
908
  const tags = cacheTagsForModelWrite(tableName, action);
831
- if (tags.length === 0) {
909
+ if (!tags.length)
832
910
  return;
833
- }
834
911
  let cache;
835
912
  let queue;
836
913
  try {
@@ -841,9 +918,11 @@ function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2) {
841
918
  }
842
919
  const job = createTrackedJob("cache.invalidate-tags", new InvalidateCacheTagsJob(cache));
843
920
  await queue.dispatch(job, { tags });
844
- });
921
+ }));
845
922
  }
846
923
  }
924
+ registration.resume();
925
+ return registration.dispose;
847
926
  }
848
927
 
849
928
  // ../../src/bootstrap/providers/listeners.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/bootstrap",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "Strata application bootstrap: HttpKernel, DI, web session helpers",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -185,7 +185,7 @@
185
185
  "access": "public"
186
186
  },
187
187
  "peerDependencies": {
188
- "@getstrata/core": "^2.0.3",
188
+ "@getstrata/core": "^2.0.5",
189
189
  "typescript": "^5.9.0 || ^6.0.0 || ^7.0.0"
190
190
  },
191
191
  "engines": {