@getstrata/bootstrap 2.0.2 → 2.0.4

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.4
4
+
5
+ - Place cache rollout guidance beside outbox docs without overlapping API additions
6
+
7
+ ## 2.0.3
8
+
9
+ - Persist transactional events with leased tenant-aware SQL outbox workers
10
+
3
11
  ## 2.0.2
4
12
 
5
13
  - Adopt TypeScript 7.0.2 in framework and generated apps; preserve tested TypeScript 5.9 and 6.x consumer compatibility.
@@ -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 };
@@ -7,7 +7,7 @@ type WebGuestHome = string | ((user: AuthUser) => string | Promise<string>);
7
7
  declare class HttpKernel {
8
8
  private readonly dependencies;
9
9
  constructor(dependencies: AppDependencies);
10
- globalMiddleware(): Middleware[];
10
+ globalMiddleware(errorFormat?: "json" | "web"): Middleware[];
11
11
  group(name: MiddlewareGroupName): Middleware[];
12
12
  wrap(groups: MiddlewareGroupName | MiddlewareGroupName[], handler: RouteHandler): RouteHandler;
13
13
  wrapApi(handler: RouteHandler): RouteHandler;
@@ -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 };
@@ -2,7 +2,6 @@
2
2
  // ../../src/bootstrap/buildModuleRoutes.ts
3
3
  import { conditionalJsonResponse } from "@getstrata/core/http/conditionalResponse";
4
4
  import { applyMiddlewareToRoutes } from "@getstrata/core/http/middleware";
5
- import { createJsonErrorMiddleware } from "@getstrata/core/http/response";
6
5
 
7
6
  // ../../src/bootstrap/httpKernel.ts
8
7
  import {
@@ -27,7 +26,12 @@ import { createRequireGlobalAdminMiddleware } from "@getstrata/core/http/require
27
26
  import { createRequirePasswordConfirmMiddleware } from "@getstrata/core/http/requirePasswordConfirmMiddleware";
28
27
  import { createRequireVerifiedMiddleware } from "@getstrata/core/http/requireVerifiedMiddleware";
29
28
  import { createRequireWebAuthMiddleware } from "@getstrata/core/http/requireWebAuthMiddleware";
30
- import { withErrorHandling, withJsonErrorHandling } from "@getstrata/core/http/response";
29
+ import {
30
+ createJsonErrorMiddleware,
31
+ createWebErrorMiddleware,
32
+ withErrorHandling,
33
+ withJsonErrorHandling
34
+ } from "@getstrata/core/http/response";
31
35
  import { withMiddleware } from "@getstrata/core/http/routeMiddleware";
32
36
  import { createSecurityHeadersMiddleware } from "@getstrata/core/http/securityHeadersMiddleware";
33
37
  import { createValidateSignatureMiddleware } from "@getstrata/core/http/signedUrl";
@@ -116,7 +120,7 @@ class HttpKernel {
116
120
  constructor(dependencies) {
117
121
  this.dependencies = dependencies;
118
122
  }
119
- globalMiddleware() {
123
+ globalMiddleware(errorFormat = "json") {
120
124
  const auth = this.dependencies.container.resolve(CORE_AUTH_TOKEN);
121
125
  return [
122
126
  createCorsMiddleware(),
@@ -126,6 +130,7 @@ class HttpKernel {
126
130
  createMetricsMiddleware(),
127
131
  createRequestLoggingMiddleware(),
128
132
  requestIdMiddleware,
133
+ errorFormat === "json" ? createJsonErrorMiddleware() : createWebErrorMiddleware(),
129
134
  createAuthMiddleware(auth),
130
135
  createMembershipMiddleware(),
131
136
  createTenantMiddleware()
@@ -423,22 +428,22 @@ function readSharedRouteRegistry() {
423
428
  var routeRegistry = readSharedRouteRegistry();
424
429
 
425
430
  // ../../src/bootstrap/buildModuleRoutes.ts
426
- function registerOpenApiRoute(method, path, middleware) {
427
- routeRegistry.register({ method, path, middleware });
431
+ function registerOpenApiRoute(method, path, middleware, openApi) {
432
+ routeRegistry.register({ method, path, middleware, ...openApi ? { openApi } : {} });
428
433
  }
429
- function registerOpenApiRouteMap(routes, middleware) {
434
+ function registerOpenApiRouteMap(routes, middleware, metadata = {}) {
430
435
  const registered = {};
431
436
  for (const [path, handler] of Object.entries(routes)) {
432
437
  if (handler && typeof handler === "object" && !Array.isArray(handler)) {
433
438
  const methodMap = handler;
434
439
  registered[path] = methodMap;
435
440
  for (const method of Object.keys(methodMap)) {
436
- registerOpenApiRoute(method.toUpperCase(), path, middleware);
441
+ registerOpenApiRoute(method.toUpperCase(), path, middleware, metadata[path]?.[method.toUpperCase()]);
437
442
  }
438
443
  continue;
439
444
  }
440
445
  registered[path] = handler;
441
- registerOpenApiRoute("GET", path, middleware);
446
+ registerOpenApiRoute("GET", path, middleware, metadata[path]?.GET);
442
447
  }
443
448
  return registered;
444
449
  }
@@ -454,21 +459,32 @@ function buildModuleRoutes(dependencies, options = {}) {
454
459
  routeRegistry.clear();
455
460
  }
456
461
  const kernel = createHttpKernel(dependencies);
457
- const middleware = [
458
- ...kernel.globalMiddleware(),
459
- createJsonErrorMiddleware(),
460
- ...kernel.group("api")
461
- ];
462
+ const middleware = [...kernel.globalMiddleware(), ...kernel.group("api")];
462
463
  const cachedJson = createCachedJson(dependencies);
463
464
  const moduleRoutes = {};
465
+ const metadata = {};
464
466
  for (const module of modules) {
465
467
  if (!module.routes) {
466
468
  continue;
467
469
  }
468
- 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);
469
485
  }
470
486
  const prefixedModuleRoutes = prefixRouteMap(apiPrefix, moduleRoutes);
471
- return applyMiddlewareToRoutes(registerOpenApiRouteMap(prefixedModuleRoutes, ["global", "api"]), middleware);
487
+ return applyMiddlewareToRoutes(registerOpenApiRouteMap(prefixedModuleRoutes, ["global", "api"], prefixRouteMap(apiPrefix, metadata)), middleware);
472
488
  }
473
489
  export {
474
490
  buildModuleRoutes,
@@ -5,7 +5,6 @@ import { applyMiddlewareToRoutes as applyMiddlewareToRoutes2 } from "@getstrata/
5
5
  // ../../src/bootstrap/buildModuleRoutes.ts
6
6
  import { conditionalJsonResponse } from "@getstrata/core/http/conditionalResponse";
7
7
  import { applyMiddlewareToRoutes } from "@getstrata/core/http/middleware";
8
- import { createJsonErrorMiddleware } from "@getstrata/core/http/response";
9
8
 
10
9
  // ../../src/bootstrap/httpKernel.ts
11
10
  import {
@@ -30,7 +29,12 @@ import { createRequireGlobalAdminMiddleware } from "@getstrata/core/http/require
30
29
  import { createRequirePasswordConfirmMiddleware } from "@getstrata/core/http/requirePasswordConfirmMiddleware";
31
30
  import { createRequireVerifiedMiddleware } from "@getstrata/core/http/requireVerifiedMiddleware";
32
31
  import { createRequireWebAuthMiddleware } from "@getstrata/core/http/requireWebAuthMiddleware";
33
- import { withErrorHandling, withJsonErrorHandling } from "@getstrata/core/http/response";
32
+ import {
33
+ createJsonErrorMiddleware,
34
+ createWebErrorMiddleware,
35
+ withErrorHandling,
36
+ withJsonErrorHandling
37
+ } from "@getstrata/core/http/response";
34
38
  import { withMiddleware } from "@getstrata/core/http/routeMiddleware";
35
39
  import { createSecurityHeadersMiddleware } from "@getstrata/core/http/securityHeadersMiddleware";
36
40
  import { createValidateSignatureMiddleware } from "@getstrata/core/http/signedUrl";
@@ -119,7 +123,7 @@ class HttpKernel {
119
123
  constructor(dependencies) {
120
124
  this.dependencies = dependencies;
121
125
  }
122
- globalMiddleware() {
126
+ globalMiddleware(errorFormat = "json") {
123
127
  const auth = this.dependencies.container.resolve(CORE_AUTH_TOKEN);
124
128
  return [
125
129
  createCorsMiddleware(),
@@ -129,6 +133,7 @@ class HttpKernel {
129
133
  createMetricsMiddleware(),
130
134
  createRequestLoggingMiddleware(),
131
135
  requestIdMiddleware,
136
+ errorFormat === "json" ? createJsonErrorMiddleware() : createWebErrorMiddleware(),
132
137
  createAuthMiddleware(auth),
133
138
  createMembershipMiddleware(),
134
139
  createTenantMiddleware()
@@ -426,22 +431,22 @@ function readSharedRouteRegistry() {
426
431
  var routeRegistry = readSharedRouteRegistry();
427
432
 
428
433
  // ../../src/bootstrap/buildModuleRoutes.ts
429
- function registerOpenApiRoute(method, path, middleware) {
430
- routeRegistry.register({ method, path, middleware });
434
+ function registerOpenApiRoute(method, path, middleware, openApi) {
435
+ routeRegistry.register({ method, path, middleware, ...openApi ? { openApi } : {} });
431
436
  }
432
- function registerOpenApiRouteMap(routes, middleware) {
437
+ function registerOpenApiRouteMap(routes, middleware, metadata = {}) {
433
438
  const registered = {};
434
439
  for (const [path, handler] of Object.entries(routes)) {
435
440
  if (handler && typeof handler === "object" && !Array.isArray(handler)) {
436
441
  const methodMap = handler;
437
442
  registered[path] = methodMap;
438
443
  for (const method of Object.keys(methodMap)) {
439
- registerOpenApiRoute(method.toUpperCase(), path, middleware);
444
+ registerOpenApiRoute(method.toUpperCase(), path, middleware, metadata[path]?.[method.toUpperCase()]);
440
445
  }
441
446
  continue;
442
447
  }
443
448
  registered[path] = handler;
444
- registerOpenApiRoute("GET", path, middleware);
449
+ registerOpenApiRoute("GET", path, middleware, metadata[path]?.GET);
445
450
  }
446
451
  return registered;
447
452
  }
@@ -457,21 +462,32 @@ function buildModuleRoutes(dependencies, options = {}) {
457
462
  routeRegistry.clear();
458
463
  }
459
464
  const kernel = createHttpKernel(dependencies);
460
- const middleware = [
461
- ...kernel.globalMiddleware(),
462
- createJsonErrorMiddleware(),
463
- ...kernel.group("api")
464
- ];
465
+ const middleware = [...kernel.globalMiddleware(), ...kernel.group("api")];
465
466
  const cachedJson = createCachedJson(dependencies);
466
467
  const moduleRoutes = {};
468
+ const metadata = {};
467
469
  for (const module of modules) {
468
470
  if (!module.routes) {
469
471
  continue;
470
472
  }
471
- 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);
472
488
  }
473
489
  const prefixedModuleRoutes = prefixRouteMap(apiPrefix, moduleRoutes);
474
- return applyMiddlewareToRoutes(registerOpenApiRouteMap(prefixedModuleRoutes, ["global", "api"]), middleware);
490
+ return applyMiddlewareToRoutes(registerOpenApiRouteMap(prefixedModuleRoutes, ["global", "api"], prefixRouteMap(apiPrefix, metadata)), middleware);
475
491
  }
476
492
 
477
493
  // ../../src/bootstrap/buildWebModuleRoutes.ts
@@ -481,7 +497,7 @@ function buildWebModuleRoutes(dependencies, options = {}) {
481
497
  routeRegistry.clear();
482
498
  }
483
499
  const kernel = createHttpKernel(dependencies);
484
- const middleware = kernel.globalMiddleware();
500
+ const middleware = kernel.globalMiddleware("web");
485
501
  const moduleRoutes = { ...seedRoutes };
486
502
  for (const module of modules) {
487
503
  if (!module.webRoutes) {
@@ -437,14 +437,88 @@ function discoverModelTableNames() {
437
437
 
438
438
  // ../../src/bootstrap/listeners/invalidateCacheOnModelWrite.ts
439
439
  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 () => {
440
+ var CACHE_EVENT = "strata.cache.invalidate-tags";
441
+ var STATE_KEY = Symbol.for("@getstrata/modelCacheInvalidationRegistrations");
442
+ function registrations() {
443
+ const global = globalThis;
444
+ global[STATE_KEY] ??= new WeakMap;
445
+ return global[STATE_KEY];
446
+ }
447
+ function requireCacheTags(value) {
448
+ if (!Array.isArray(value) || !value.length || value.length > 128 || !value.every((tag) => typeof tag === "string" && tag.length > 0 && tag.length <= 256))
449
+ throw new Error("Invalid durable cache invalidation payload.");
450
+ return value;
451
+ }
452
+ function createModelCacheInvalidationListener(resolveCache = resolveApplicationCache) {
453
+ return {
454
+ name: "strata.cache.invalidate-tags.v1",
455
+ event: CACHE_EVENT,
456
+ async handle(event, { signal }) {
457
+ const payload = event.payload;
458
+ if (event.version !== 1)
459
+ throw new Error("Invalid durable cache invalidation version.");
460
+ const tags = requireCacheTags(payload?.tags);
461
+ signal.throwIfAborted();
462
+ await new InvalidateCacheTagsJob(resolveCache()).handle({ tags });
463
+ signal.throwIfAborted();
464
+ }
465
+ };
466
+ }
467
+ function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2, options = {}) {
468
+ if (options.outbox && !options.outbox.hasListener("strata.cache.invalidate-tags.v1", CACHE_EVENT))
469
+ throw new Error("The application outbox must include createModelCacheInvalidationListener().");
470
+ const writes = discoverModelTableNames().flatMap((tableName) => MODEL_WRITE_ACTIONS.map((action) => ({
471
+ tableName,
472
+ action,
473
+ tags: cacheTagsForModelWrite(tableName, action)
474
+ })));
475
+ if (options.outbox) {
476
+ for (const write of writes)
477
+ if (write.tags.length)
478
+ requireCacheTags(write.tags);
479
+ }
480
+ const state = registrations();
481
+ const previous = state.get(bus);
482
+ if (previous?.durable && !options.outbox)
483
+ return () => {};
484
+ previous?.suspend();
485
+ const unsubscribe = [];
486
+ const subscribe = [];
487
+ const registration = {
488
+ durable: !!options.outbox,
489
+ suspend() {
490
+ for (const stop of unsubscribe.splice(0))
491
+ stop();
492
+ },
493
+ resume() {
494
+ for (const start of subscribe)
495
+ unsubscribe.push(start());
496
+ state.set(bus, registration);
497
+ },
498
+ dispose() {
499
+ if (state.get(bus) !== registration)
500
+ return;
501
+ registration.suspend();
502
+ if (previous)
503
+ previous.resume();
504
+ else
505
+ state.delete(bus);
506
+ }
507
+ };
508
+ for (const { tableName, action, tags: durableTags } of writes) {
509
+ const event = modelEventName(tableName, action);
510
+ if (options.outbox) {
511
+ if (!durableTags.length)
512
+ continue;
513
+ const outbox = options.outbox;
514
+ subscribe.push(() => bus.listenTransactional(event, async () => {
515
+ await outbox.publish(CACHE_EVENT, { tags: durableTags });
516
+ }));
517
+ } else {
518
+ subscribe.push(() => bus.listen(event, async () => {
444
519
  const tags = cacheTagsForModelWrite(tableName, action);
445
- if (tags.length === 0) {
520
+ if (!tags.length)
446
521
  return;
447
- }
448
522
  let cache;
449
523
  let queue;
450
524
  try {
@@ -455,9 +529,11 @@ function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2) {
455
529
  }
456
530
  const job = createTrackedJob("cache.invalidate-tags", new InvalidateCacheTagsJob(cache));
457
531
  await queue.dispatch(job, { tags });
458
- });
532
+ }));
459
533
  }
460
534
  }
535
+ registration.resume();
536
+ return registration.dispose;
461
537
  }
462
538
 
463
539
  // ../../src/bootstrap/providers/listeners.ts
@@ -11,7 +11,6 @@ import { applyMiddlewareToRoutes as applyMiddlewareToRoutes2 } from "@getstrata/
11
11
  // ../../src/bootstrap/buildModuleRoutes.ts
12
12
  import { conditionalJsonResponse } from "@getstrata/core/http/conditionalResponse";
13
13
  import { applyMiddlewareToRoutes } from "@getstrata/core/http/middleware";
14
- import { createJsonErrorMiddleware } from "@getstrata/core/http/response";
15
14
 
16
15
  // ../../src/bootstrap/httpKernel.ts
17
16
  import {
@@ -36,7 +35,12 @@ import { createRequireGlobalAdminMiddleware } from "@getstrata/core/http/require
36
35
  import { createRequirePasswordConfirmMiddleware } from "@getstrata/core/http/requirePasswordConfirmMiddleware";
37
36
  import { createRequireVerifiedMiddleware } from "@getstrata/core/http/requireVerifiedMiddleware";
38
37
  import { createRequireWebAuthMiddleware } from "@getstrata/core/http/requireWebAuthMiddleware";
39
- import { withErrorHandling, withJsonErrorHandling } from "@getstrata/core/http/response";
38
+ import {
39
+ createJsonErrorMiddleware,
40
+ createWebErrorMiddleware,
41
+ withErrorHandling,
42
+ withJsonErrorHandling
43
+ } from "@getstrata/core/http/response";
40
44
  import { withMiddleware } from "@getstrata/core/http/routeMiddleware";
41
45
  import { createSecurityHeadersMiddleware } from "@getstrata/core/http/securityHeadersMiddleware";
42
46
  import { createValidateSignatureMiddleware } from "@getstrata/core/http/signedUrl";
@@ -125,7 +129,7 @@ class HttpKernel {
125
129
  constructor(dependencies) {
126
130
  this.dependencies = dependencies;
127
131
  }
128
- globalMiddleware() {
132
+ globalMiddleware(errorFormat = "json") {
129
133
  const auth = this.dependencies.container.resolve(CORE_AUTH_TOKEN);
130
134
  return [
131
135
  createCorsMiddleware(),
@@ -135,6 +139,7 @@ class HttpKernel {
135
139
  createMetricsMiddleware(),
136
140
  createRequestLoggingMiddleware(),
137
141
  requestIdMiddleware,
142
+ errorFormat === "json" ? createJsonErrorMiddleware() : createWebErrorMiddleware(),
138
143
  createAuthMiddleware(auth),
139
144
  createMembershipMiddleware(),
140
145
  createTenantMiddleware()
@@ -432,22 +437,22 @@ function readSharedRouteRegistry() {
432
437
  var routeRegistry = readSharedRouteRegistry();
433
438
 
434
439
  // ../../src/bootstrap/buildModuleRoutes.ts
435
- function registerOpenApiRoute(method, path, middleware) {
436
- routeRegistry.register({ method, path, middleware });
440
+ function registerOpenApiRoute(method, path, middleware, openApi) {
441
+ routeRegistry.register({ method, path, middleware, ...openApi ? { openApi } : {} });
437
442
  }
438
- function registerOpenApiRouteMap(routes, middleware) {
443
+ function registerOpenApiRouteMap(routes, middleware, metadata = {}) {
439
444
  const registered = {};
440
445
  for (const [path, handler] of Object.entries(routes)) {
441
446
  if (handler && typeof handler === "object" && !Array.isArray(handler)) {
442
447
  const methodMap = handler;
443
448
  registered[path] = methodMap;
444
449
  for (const method of Object.keys(methodMap)) {
445
- registerOpenApiRoute(method.toUpperCase(), path, middleware);
450
+ registerOpenApiRoute(method.toUpperCase(), path, middleware, metadata[path]?.[method.toUpperCase()]);
446
451
  }
447
452
  continue;
448
453
  }
449
454
  registered[path] = handler;
450
- registerOpenApiRoute("GET", path, middleware);
455
+ registerOpenApiRoute("GET", path, middleware, metadata[path]?.GET);
451
456
  }
452
457
  return registered;
453
458
  }
@@ -463,21 +468,32 @@ function buildModuleRoutes(dependencies, options = {}) {
463
468
  routeRegistry.clear();
464
469
  }
465
470
  const kernel = createHttpKernel(dependencies);
466
- const middleware = [
467
- ...kernel.globalMiddleware(),
468
- createJsonErrorMiddleware(),
469
- ...kernel.group("api")
470
- ];
471
+ const middleware = [...kernel.globalMiddleware(), ...kernel.group("api")];
471
472
  const cachedJson = createCachedJson(dependencies);
472
473
  const moduleRoutes = {};
474
+ const metadata = {};
473
475
  for (const module of modules) {
474
476
  if (!module.routes) {
475
477
  continue;
476
478
  }
477
- 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);
478
494
  }
479
495
  const prefixedModuleRoutes = prefixRouteMap(apiPrefix, moduleRoutes);
480
- return applyMiddlewareToRoutes(registerOpenApiRouteMap(prefixedModuleRoutes, ["global", "api"]), middleware);
496
+ return applyMiddlewareToRoutes(registerOpenApiRouteMap(prefixedModuleRoutes, ["global", "api"], prefixRouteMap(apiPrefix, metadata)), middleware);
481
497
  }
482
498
 
483
499
  // ../../src/bootstrap/buildWebModuleRoutes.ts
@@ -487,7 +503,7 @@ function buildWebModuleRoutes(dependencies, options = {}) {
487
503
  routeRegistry.clear();
488
504
  }
489
505
  const kernel = createHttpKernel(dependencies);
490
- const middleware = kernel.globalMiddleware();
506
+ const middleware = kernel.globalMiddleware("web");
491
507
  const moduleRoutes = { ...seedRoutes };
492
508
  for (const module of modules) {
493
509
  if (!module.webRoutes) {
@@ -437,14 +437,88 @@ function discoverModelTableNames() {
437
437
 
438
438
  // ../../src/bootstrap/listeners/invalidateCacheOnModelWrite.ts
439
439
  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 () => {
440
+ var CACHE_EVENT = "strata.cache.invalidate-tags";
441
+ var STATE_KEY = Symbol.for("@getstrata/modelCacheInvalidationRegistrations");
442
+ function registrations() {
443
+ const global = globalThis;
444
+ global[STATE_KEY] ??= new WeakMap;
445
+ return global[STATE_KEY];
446
+ }
447
+ function requireCacheTags(value) {
448
+ if (!Array.isArray(value) || !value.length || value.length > 128 || !value.every((tag) => typeof tag === "string" && tag.length > 0 && tag.length <= 256))
449
+ throw new Error("Invalid durable cache invalidation payload.");
450
+ return value;
451
+ }
452
+ function createModelCacheInvalidationListener(resolveCache = resolveApplicationCache) {
453
+ return {
454
+ name: "strata.cache.invalidate-tags.v1",
455
+ event: CACHE_EVENT,
456
+ async handle(event, { signal }) {
457
+ const payload = event.payload;
458
+ if (event.version !== 1)
459
+ throw new Error("Invalid durable cache invalidation version.");
460
+ const tags = requireCacheTags(payload?.tags);
461
+ signal.throwIfAborted();
462
+ await new InvalidateCacheTagsJob(resolveCache()).handle({ tags });
463
+ signal.throwIfAborted();
464
+ }
465
+ };
466
+ }
467
+ function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2, options = {}) {
468
+ if (options.outbox && !options.outbox.hasListener("strata.cache.invalidate-tags.v1", CACHE_EVENT))
469
+ throw new Error("The application outbox must include createModelCacheInvalidationListener().");
470
+ const writes = discoverModelTableNames().flatMap((tableName) => MODEL_WRITE_ACTIONS.map((action) => ({
471
+ tableName,
472
+ action,
473
+ tags: cacheTagsForModelWrite(tableName, action)
474
+ })));
475
+ if (options.outbox) {
476
+ for (const write of writes)
477
+ if (write.tags.length)
478
+ requireCacheTags(write.tags);
479
+ }
480
+ const state = registrations();
481
+ const previous = state.get(bus);
482
+ if (previous?.durable && !options.outbox)
483
+ return () => {};
484
+ previous?.suspend();
485
+ const unsubscribe = [];
486
+ const subscribe = [];
487
+ const registration = {
488
+ durable: !!options.outbox,
489
+ suspend() {
490
+ for (const stop of unsubscribe.splice(0))
491
+ stop();
492
+ },
493
+ resume() {
494
+ for (const start of subscribe)
495
+ unsubscribe.push(start());
496
+ state.set(bus, registration);
497
+ },
498
+ dispose() {
499
+ if (state.get(bus) !== registration)
500
+ return;
501
+ registration.suspend();
502
+ if (previous)
503
+ previous.resume();
504
+ else
505
+ state.delete(bus);
506
+ }
507
+ };
508
+ for (const { tableName, action, tags: durableTags } of writes) {
509
+ const event = modelEventName(tableName, action);
510
+ if (options.outbox) {
511
+ if (!durableTags.length)
512
+ continue;
513
+ const outbox = options.outbox;
514
+ subscribe.push(() => bus.listenTransactional(event, async () => {
515
+ await outbox.publish(CACHE_EVENT, { tags: durableTags });
516
+ }));
517
+ } else {
518
+ subscribe.push(() => bus.listen(event, async () => {
444
519
  const tags = cacheTagsForModelWrite(tableName, action);
445
- if (tags.length === 0) {
520
+ if (!tags.length)
446
521
  return;
447
- }
448
522
  let cache;
449
523
  let queue;
450
524
  try {
@@ -455,9 +529,11 @@ function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2) {
455
529
  }
456
530
  const job = createTrackedJob("cache.invalidate-tags", new InvalidateCacheTagsJob(cache));
457
531
  await queue.dispatch(job, { tags });
458
- });
532
+ }));
459
533
  }
460
534
  }
535
+ registration.resume();
536
+ return registration.dispose;
461
537
  }
462
538
 
463
539
  // ../../src/bootstrap/providers/listeners.ts
@@ -22,7 +22,12 @@ import { createRequireGlobalAdminMiddleware } from "@getstrata/core/http/require
22
22
  import { createRequirePasswordConfirmMiddleware } from "@getstrata/core/http/requirePasswordConfirmMiddleware";
23
23
  import { createRequireVerifiedMiddleware } from "@getstrata/core/http/requireVerifiedMiddleware";
24
24
  import { createRequireWebAuthMiddleware } from "@getstrata/core/http/requireWebAuthMiddleware";
25
- import { withErrorHandling, withJsonErrorHandling } from "@getstrata/core/http/response";
25
+ import {
26
+ createJsonErrorMiddleware,
27
+ createWebErrorMiddleware,
28
+ withErrorHandling,
29
+ withJsonErrorHandling
30
+ } from "@getstrata/core/http/response";
26
31
  import { withMiddleware } from "@getstrata/core/http/routeMiddleware";
27
32
  import { createSecurityHeadersMiddleware } from "@getstrata/core/http/securityHeadersMiddleware";
28
33
  import { createValidateSignatureMiddleware } from "@getstrata/core/http/signedUrl";
@@ -111,7 +116,7 @@ class HttpKernel {
111
116
  constructor(dependencies) {
112
117
  this.dependencies = dependencies;
113
118
  }
114
- globalMiddleware() {
119
+ globalMiddleware(errorFormat = "json") {
115
120
  const auth = this.dependencies.container.resolve(CORE_AUTH_TOKEN);
116
121
  return [
117
122
  createCorsMiddleware(),
@@ -121,6 +126,7 @@ class HttpKernel {
121
126
  createMetricsMiddleware(),
122
127
  createRequestLoggingMiddleware(),
123
128
  requestIdMiddleware,
129
+ errorFormat === "json" ? createJsonErrorMiddleware() : createWebErrorMiddleware(),
124
130
  createAuthMiddleware(auth),
125
131
  createMembershipMiddleware(),
126
132
  createTenantMiddleware()
@@ -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
  };
@@ -427,14 +427,88 @@ function discoverModelTableNames() {
427
427
 
428
428
  // ../../src/bootstrap/listeners/invalidateCacheOnModelWrite.ts
429
429
  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 () => {
430
+ var CACHE_EVENT = "strata.cache.invalidate-tags";
431
+ var STATE_KEY = Symbol.for("@getstrata/modelCacheInvalidationRegistrations");
432
+ function registrations() {
433
+ const global = globalThis;
434
+ global[STATE_KEY] ??= new WeakMap;
435
+ return global[STATE_KEY];
436
+ }
437
+ function requireCacheTags(value) {
438
+ if (!Array.isArray(value) || !value.length || value.length > 128 || !value.every((tag) => typeof tag === "string" && tag.length > 0 && tag.length <= 256))
439
+ throw new Error("Invalid durable cache invalidation payload.");
440
+ return value;
441
+ }
442
+ function createModelCacheInvalidationListener(resolveCache = resolveApplicationCache) {
443
+ return {
444
+ name: "strata.cache.invalidate-tags.v1",
445
+ event: CACHE_EVENT,
446
+ async handle(event, { signal }) {
447
+ const payload = event.payload;
448
+ if (event.version !== 1)
449
+ throw new Error("Invalid durable cache invalidation version.");
450
+ const tags = requireCacheTags(payload?.tags);
451
+ signal.throwIfAborted();
452
+ await new InvalidateCacheTagsJob(resolveCache()).handle({ tags });
453
+ signal.throwIfAborted();
454
+ }
455
+ };
456
+ }
457
+ function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2, options = {}) {
458
+ if (options.outbox && !options.outbox.hasListener("strata.cache.invalidate-tags.v1", CACHE_EVENT))
459
+ throw new Error("The application outbox must include createModelCacheInvalidationListener().");
460
+ const writes = discoverModelTableNames().flatMap((tableName) => MODEL_WRITE_ACTIONS.map((action) => ({
461
+ tableName,
462
+ action,
463
+ tags: cacheTagsForModelWrite(tableName, action)
464
+ })));
465
+ if (options.outbox) {
466
+ for (const write of writes)
467
+ if (write.tags.length)
468
+ requireCacheTags(write.tags);
469
+ }
470
+ const state = registrations();
471
+ const previous = state.get(bus);
472
+ if (previous?.durable && !options.outbox)
473
+ return () => {};
474
+ previous?.suspend();
475
+ const unsubscribe = [];
476
+ const subscribe = [];
477
+ const registration = {
478
+ durable: !!options.outbox,
479
+ suspend() {
480
+ for (const stop of unsubscribe.splice(0))
481
+ stop();
482
+ },
483
+ resume() {
484
+ for (const start of subscribe)
485
+ unsubscribe.push(start());
486
+ state.set(bus, registration);
487
+ },
488
+ dispose() {
489
+ if (state.get(bus) !== registration)
490
+ return;
491
+ registration.suspend();
492
+ if (previous)
493
+ previous.resume();
494
+ else
495
+ state.delete(bus);
496
+ }
497
+ };
498
+ for (const { tableName, action, tags: durableTags } of writes) {
499
+ const event = modelEventName(tableName, action);
500
+ if (options.outbox) {
501
+ if (!durableTags.length)
502
+ continue;
503
+ const outbox = options.outbox;
504
+ subscribe.push(() => bus.listenTransactional(event, async () => {
505
+ await outbox.publish(CACHE_EVENT, { tags: durableTags });
506
+ }));
507
+ } else {
508
+ subscribe.push(() => bus.listen(event, async () => {
434
509
  const tags = cacheTagsForModelWrite(tableName, action);
435
- if (tags.length === 0) {
510
+ if (!tags.length)
436
511
  return;
437
- }
438
512
  let cache;
439
513
  let queue;
440
514
  try {
@@ -445,9 +519,11 @@ function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2) {
445
519
  }
446
520
  const job = createTrackedJob("cache.invalidate-tags", new InvalidateCacheTagsJob(cache));
447
521
  await queue.dispatch(job, { tags });
448
- });
522
+ }));
449
523
  }
450
524
  }
525
+ registration.resume();
526
+ return registration.dispose;
451
527
  }
452
528
 
453
529
  // ../../src/bootstrap/providers/listeners.ts
@@ -32,7 +32,12 @@ import { createRequireGlobalAdminMiddleware } from "@getstrata/core/http/require
32
32
  import { createRequirePasswordConfirmMiddleware } from "@getstrata/core/http/requirePasswordConfirmMiddleware";
33
33
  import { createRequireVerifiedMiddleware } from "@getstrata/core/http/requireVerifiedMiddleware";
34
34
  import { createRequireWebAuthMiddleware } from "@getstrata/core/http/requireWebAuthMiddleware";
35
- import { withErrorHandling, withJsonErrorHandling } from "@getstrata/core/http/response";
35
+ import {
36
+ createJsonErrorMiddleware,
37
+ createWebErrorMiddleware,
38
+ withErrorHandling,
39
+ withJsonErrorHandling
40
+ } from "@getstrata/core/http/response";
36
41
  import { withMiddleware } from "@getstrata/core/http/routeMiddleware";
37
42
  import { createSecurityHeadersMiddleware } from "@getstrata/core/http/securityHeadersMiddleware";
38
43
  import { createValidateSignatureMiddleware } from "@getstrata/core/http/signedUrl";
@@ -121,7 +126,7 @@ class HttpKernel {
121
126
  constructor(dependencies) {
122
127
  this.dependencies = dependencies;
123
128
  }
124
- globalMiddleware() {
129
+ globalMiddleware(errorFormat = "json") {
125
130
  const auth = this.dependencies.container.resolve(CORE_AUTH_TOKEN);
126
131
  return [
127
132
  createCorsMiddleware(),
@@ -131,6 +136,7 @@ class HttpKernel {
131
136
  createMetricsMiddleware(),
132
137
  createRequestLoggingMiddleware(),
133
138
  requestIdMiddleware,
139
+ errorFormat === "json" ? createJsonErrorMiddleware() : createWebErrorMiddleware(),
134
140
  createAuthMiddleware(auth),
135
141
  createMembershipMiddleware(),
136
142
  createTenantMiddleware()
package/dist/index.js CHANGED
@@ -17,7 +17,6 @@ import {
17
17
  // ../../src/bootstrap/buildModuleRoutes.ts
18
18
  import { conditionalJsonResponse } from "@getstrata/core/http/conditionalResponse";
19
19
  import { applyMiddlewareToRoutes } from "@getstrata/core/http/middleware";
20
- import { createJsonErrorMiddleware } from "@getstrata/core/http/response";
21
20
 
22
21
  // ../../src/bootstrap/httpKernel.ts
23
22
  import {
@@ -42,7 +41,12 @@ import { createRequireGlobalAdminMiddleware } from "@getstrata/core/http/require
42
41
  import { createRequirePasswordConfirmMiddleware } from "@getstrata/core/http/requirePasswordConfirmMiddleware";
43
42
  import { createRequireVerifiedMiddleware } from "@getstrata/core/http/requireVerifiedMiddleware";
44
43
  import { createRequireWebAuthMiddleware } from "@getstrata/core/http/requireWebAuthMiddleware";
45
- import { withErrorHandling, withJsonErrorHandling } from "@getstrata/core/http/response";
44
+ import {
45
+ createJsonErrorMiddleware,
46
+ createWebErrorMiddleware,
47
+ withErrorHandling,
48
+ withJsonErrorHandling
49
+ } from "@getstrata/core/http/response";
46
50
  import { withMiddleware } from "@getstrata/core/http/routeMiddleware";
47
51
  import { createSecurityHeadersMiddleware } from "@getstrata/core/http/securityHeadersMiddleware";
48
52
  import { createValidateSignatureMiddleware } from "@getstrata/core/http/signedUrl";
@@ -130,7 +134,7 @@ class HttpKernel {
130
134
  constructor(dependencies) {
131
135
  this.dependencies = dependencies;
132
136
  }
133
- globalMiddleware() {
137
+ globalMiddleware(errorFormat = "json") {
134
138
  const auth = this.dependencies.container.resolve(CORE_AUTH_TOKEN);
135
139
  return [
136
140
  createCorsMiddleware(),
@@ -140,6 +144,7 @@ class HttpKernel {
140
144
  createMetricsMiddleware(),
141
145
  createRequestLoggingMiddleware(),
142
146
  requestIdMiddleware,
147
+ errorFormat === "json" ? createJsonErrorMiddleware() : createWebErrorMiddleware(),
143
148
  createAuthMiddleware(auth),
144
149
  createMembershipMiddleware(),
145
150
  createTenantMiddleware()
@@ -431,22 +436,22 @@ function readSharedRouteRegistry() {
431
436
  var routeRegistry = readSharedRouteRegistry();
432
437
 
433
438
  // ../../src/bootstrap/buildModuleRoutes.ts
434
- function registerOpenApiRoute(method, path, middleware) {
435
- routeRegistry.register({ method, path, middleware });
439
+ function registerOpenApiRoute(method, path, middleware, openApi) {
440
+ routeRegistry.register({ method, path, middleware, ...openApi ? { openApi } : {} });
436
441
  }
437
- function registerOpenApiRouteMap(routes, middleware) {
442
+ function registerOpenApiRouteMap(routes, middleware, metadata = {}) {
438
443
  const registered = {};
439
444
  for (const [path, handler] of Object.entries(routes)) {
440
445
  if (handler && typeof handler === "object" && !Array.isArray(handler)) {
441
446
  const methodMap = handler;
442
447
  registered[path] = methodMap;
443
448
  for (const method of Object.keys(methodMap)) {
444
- registerOpenApiRoute(method.toUpperCase(), path, middleware);
449
+ registerOpenApiRoute(method.toUpperCase(), path, middleware, metadata[path]?.[method.toUpperCase()]);
445
450
  }
446
451
  continue;
447
452
  }
448
453
  registered[path] = handler;
449
- registerOpenApiRoute("GET", path, middleware);
454
+ registerOpenApiRoute("GET", path, middleware, metadata[path]?.GET);
450
455
  }
451
456
  return registered;
452
457
  }
@@ -462,21 +467,32 @@ function buildModuleRoutes(dependencies, options = {}) {
462
467
  routeRegistry.clear();
463
468
  }
464
469
  const kernel = createHttpKernel(dependencies);
465
- const middleware = [
466
- ...kernel.globalMiddleware(),
467
- createJsonErrorMiddleware(),
468
- ...kernel.group("api")
469
- ];
470
+ const middleware = [...kernel.globalMiddleware(), ...kernel.group("api")];
470
471
  const cachedJson = createCachedJson(dependencies);
471
472
  const moduleRoutes = {};
473
+ const metadata = {};
472
474
  for (const module of modules) {
473
475
  if (!module.routes) {
474
476
  continue;
475
477
  }
476
- 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);
477
493
  }
478
494
  const prefixedModuleRoutes = prefixRouteMap(apiPrefix, moduleRoutes);
479
- return applyMiddlewareToRoutes(registerOpenApiRouteMap(prefixedModuleRoutes, ["global", "api"]), middleware);
495
+ return applyMiddlewareToRoutes(registerOpenApiRouteMap(prefixedModuleRoutes, ["global", "api"], prefixRouteMap(apiPrefix, metadata)), middleware);
480
496
  }
481
497
  // ../../src/bootstrap/buildWebModuleRoutes.ts
482
498
  import { applyMiddlewareToRoutes as applyMiddlewareToRoutes2 } from "@getstrata/core/http/middleware";
@@ -486,7 +502,7 @@ function buildWebModuleRoutes(dependencies, options = {}) {
486
502
  routeRegistry.clear();
487
503
  }
488
504
  const kernel = createHttpKernel(dependencies);
489
- const middleware = kernel.globalMiddleware();
505
+ const middleware = kernel.globalMiddleware("web");
490
506
  const moduleRoutes = { ...seedRoutes };
491
507
  for (const module of modules) {
492
508
  if (!module.webRoutes) {
@@ -822,14 +838,73 @@ import { eventBus as eventBus2, modelEventName } from "@getstrata/core/events";
822
838
  import { InvalidateCacheTagsJob } from "@getstrata/core/jobs/invalidateCacheTagsJob";
823
839
  import { createTrackedJob } from "@getstrata/core/queue/createAppQueue";
824
840
  var MODEL_WRITE_ACTIONS = ["created", "updated", "deleted", "restored", "force-deleted"];
825
- function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2) {
826
- for (const tableName of discoverModelTableNames()) {
827
- for (const action of MODEL_WRITE_ACTIONS) {
828
- bus.listen(modelEventName(tableName, action), async () => {
841
+ var CACHE_EVENT = "strata.cache.invalidate-tags";
842
+ var STATE_KEY = Symbol.for("@getstrata/modelCacheInvalidationRegistrations");
843
+ function registrations() {
844
+ const global = globalThis;
845
+ global[STATE_KEY] ??= new WeakMap;
846
+ return global[STATE_KEY];
847
+ }
848
+ function requireCacheTags(value) {
849
+ if (!Array.isArray(value) || !value.length || value.length > 128 || !value.every((tag) => typeof tag === "string" && tag.length > 0 && tag.length <= 256))
850
+ throw new Error("Invalid durable cache invalidation payload.");
851
+ return value;
852
+ }
853
+ function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2, options = {}) {
854
+ if (options.outbox && !options.outbox.hasListener("strata.cache.invalidate-tags.v1", CACHE_EVENT))
855
+ throw new Error("The application outbox must include createModelCacheInvalidationListener().");
856
+ const writes = discoverModelTableNames().flatMap((tableName) => MODEL_WRITE_ACTIONS.map((action) => ({
857
+ tableName,
858
+ action,
859
+ tags: cacheTagsForModelWrite(tableName, action)
860
+ })));
861
+ if (options.outbox) {
862
+ for (const write of writes)
863
+ if (write.tags.length)
864
+ requireCacheTags(write.tags);
865
+ }
866
+ const state = registrations();
867
+ const previous = state.get(bus);
868
+ if (previous?.durable && !options.outbox)
869
+ return () => {};
870
+ previous?.suspend();
871
+ const unsubscribe = [];
872
+ const subscribe = [];
873
+ const registration = {
874
+ durable: !!options.outbox,
875
+ suspend() {
876
+ for (const stop of unsubscribe.splice(0))
877
+ stop();
878
+ },
879
+ resume() {
880
+ for (const start of subscribe)
881
+ unsubscribe.push(start());
882
+ state.set(bus, registration);
883
+ },
884
+ dispose() {
885
+ if (state.get(bus) !== registration)
886
+ return;
887
+ registration.suspend();
888
+ if (previous)
889
+ previous.resume();
890
+ else
891
+ state.delete(bus);
892
+ }
893
+ };
894
+ for (const { tableName, action, tags: durableTags } of writes) {
895
+ const event = modelEventName(tableName, action);
896
+ if (options.outbox) {
897
+ if (!durableTags.length)
898
+ continue;
899
+ const outbox = options.outbox;
900
+ subscribe.push(() => bus.listenTransactional(event, async () => {
901
+ await outbox.publish(CACHE_EVENT, { tags: durableTags });
902
+ }));
903
+ } else {
904
+ subscribe.push(() => bus.listen(event, async () => {
829
905
  const tags = cacheTagsForModelWrite(tableName, action);
830
- if (tags.length === 0) {
906
+ if (!tags.length)
831
907
  return;
832
- }
833
908
  let cache;
834
909
  let queue;
835
910
  try {
@@ -840,9 +915,11 @@ function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2) {
840
915
  }
841
916
  const job = createTrackedJob("cache.invalidate-tags", new InvalidateCacheTagsJob(cache));
842
917
  await queue.dispatch(job, { tags });
843
- });
918
+ }));
844
919
  }
845
920
  }
921
+ registration.resume();
922
+ return registration.dispose;
846
923
  }
847
924
 
848
925
  // ../../src/bootstrap/providers/listeners.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/bootstrap",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
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.2",
188
+ "@getstrata/core": "^2.0.4",
189
189
  "typescript": "^5.9.0 || ^6.0.0 || ^7.0.0"
190
190
  },
191
191
  "engines": {