@getstrata/bootstrap 2.0.3 → 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,9 @@
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
+
3
7
  ## 2.0.3
4
8
 
5
9
  - 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
@@ -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
@@ -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
@@ -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
@@ -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
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";
@@ -823,14 +838,73 @@ import { eventBus as eventBus2, modelEventName } from "@getstrata/core/events";
823
838
  import { InvalidateCacheTagsJob } from "@getstrata/core/jobs/invalidateCacheTagsJob";
824
839
  import { createTrackedJob } from "@getstrata/core/queue/createAppQueue";
825
840
  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 () => {
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 () => {
830
905
  const tags = cacheTagsForModelWrite(tableName, action);
831
- if (tags.length === 0) {
906
+ if (!tags.length)
832
907
  return;
833
- }
834
908
  let cache;
835
909
  let queue;
836
910
  try {
@@ -841,9 +915,11 @@ function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2) {
841
915
  }
842
916
  const job = createTrackedJob("cache.invalidate-tags", new InvalidateCacheTagsJob(cache));
843
917
  await queue.dispatch(job, { tags });
844
- });
918
+ }));
845
919
  }
846
920
  }
921
+ registration.resume();
922
+ return registration.dispose;
847
923
  }
848
924
 
849
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.3",
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.3",
188
+ "@getstrata/core": "^2.0.4",
189
189
  "typescript": "^5.9.0 || ^6.0.0 || ^7.0.0"
190
190
  },
191
191
  "engines": {