@cosmicdrift/kumiko-framework 0.340.0 → 0.342.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/api/index.d.ts +2 -1
  2. package/dist/api/index.js +1 -1
  3. package/dist/api/route-registrars.d.ts +4 -2
  4. package/dist/api/route-registrars.js +9 -1
  5. package/dist/api/server.d.ts +2 -1
  6. package/dist/api/server.js +43 -5
  7. package/dist/crypto/kek-source.d.ts +3 -4
  8. package/dist/crypto/kek-source.js +2 -5
  9. package/dist/crypto/kms-wiring.d.ts +2 -2
  10. package/dist/crypto/kms-wiring.js +2 -2
  11. package/dist/db/index.d.ts +2 -1
  12. package/dist/db/index.js +2 -1
  13. package/dist/db/migrate-generator.d.ts +6 -0
  14. package/dist/db/migrate-generator.js +15 -1
  15. package/dist/db/migrate-runner.d.ts +1 -0
  16. package/dist/db/migrate-runner.js +11 -1
  17. package/dist/db/queries/shadow-swap.d.ts +2 -1
  18. package/dist/db/queries/shadow-swap.js +70 -7
  19. package/dist/db/retired-framework-tables.d.ts +10 -0
  20. package/dist/db/retired-framework-tables.js +72 -0
  21. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  22. package/dist/engine/boot-validator/entity-handler.js +48 -0
  23. package/dist/engine/define-handler.js +1 -0
  24. package/dist/engine/feature-ast/extractors/handlers.d.ts +2 -1
  25. package/dist/engine/feature-ast/extractors/handlers.js +11 -1
  26. package/dist/engine/feature-ast/extractors/hooks.d.ts +2 -1
  27. package/dist/engine/feature-ast/extractors/hooks.js +14 -0
  28. package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
  29. package/dist/engine/feature-ast/extractors/index.js +1 -1
  30. package/dist/engine/feature-ast/patch.js +1 -0
  31. package/dist/engine/feature-ast/pattern-change-schema.d.ts +20 -0
  32. package/dist/engine/feature-ast/pattern-change-schema.js +10 -0
  33. package/dist/engine/feature-ast/patterns.d.ts +2 -1
  34. package/dist/engine/feature-ast/render.js +3 -0
  35. package/dist/engine/feature-entity-handlers.d.ts +2 -1
  36. package/dist/engine/feature-entity-handlers.js +4 -0
  37. package/dist/engine/feature-ui-extensions.js +9 -0
  38. package/dist/engine/index.d.ts +1 -1
  39. package/dist/engine/membership-roles.js +5 -1
  40. package/dist/engine/pattern-library/mixed-schemas.js +5 -0
  41. package/dist/engine/registry-state.js +38 -5
  42. package/dist/engine/registry-validate.js +2 -0
  43. package/dist/engine/soft-delete-cleanup.d.ts +2 -0
  44. package/dist/engine/soft-delete-cleanup.js +28 -9
  45. package/dist/engine/tier-resolver-extension.d.ts +2 -0
  46. package/dist/engine/types/index.d.ts +2 -2
  47. package/dist/entrypoint/index.js +25 -6
  48. package/dist/errors/classes.d.ts +5 -0
  49. package/dist/errors/classes.js +13 -0
  50. package/dist/errors/index.d.ts +1 -1
  51. package/dist/errors/index.js +1 -1
  52. package/dist/files/file-routes.js +7 -2
  53. package/dist/files/index.d.ts +1 -0
  54. package/dist/files/index.js +1 -0
  55. package/dist/files/provider-resolver.d.ts +2 -0
  56. package/dist/files/provider-resolver.js +16 -1
  57. package/dist/files/resolve-content-type.d.ts +11 -0
  58. package/dist/files/resolve-content-type.js +71 -0
  59. package/dist/files/types.d.ts +1 -1
  60. package/dist/files/types.js +5 -1
  61. package/dist/jobs/job-runner.js +8 -2
  62. package/dist/observability/escape-hatch-report.d.ts +10 -1
  63. package/dist/observability/escape-hatch-report.js +19 -1
  64. package/dist/observability/index.d.ts +2 -2
  65. package/dist/observability/index.js +2 -2
  66. package/dist/observability/standard-metrics.d.ts +1 -0
  67. package/dist/observability/standard-metrics.js +14 -0
  68. package/dist/pipeline/dispatch-shared.d.ts +4 -1
  69. package/dist/pipeline/dispatch-shared.js +70 -9
  70. package/dist/pipeline/dispatch-write.js +35 -10
  71. package/dist/pipeline/dispatcher.d.ts +2 -0
  72. package/dist/pipeline/dispatcher.js +17 -1
  73. package/dist/pipeline/projection-rebuild.js +1 -1
  74. package/dist/rate-limit/bucket.d.ts +4 -0
  75. package/dist/rate-limit/bucket.js +25 -0
  76. package/dist/rate-limit/index.d.ts +2 -2
  77. package/dist/rate-limit/index.js +2 -2
  78. package/dist/rate-limit/middleware.d.ts +9 -0
  79. package/dist/rate-limit/middleware.js +33 -2
  80. package/dist/rate-limit/resolver.js +2 -1
  81. package/dist/redis/cache-sync-bus.d.ts +24 -0
  82. package/dist/redis/cache-sync-bus.js +152 -0
  83. package/dist/redis/cache-sync-topics.d.ts +17 -0
  84. package/dist/redis/cache-sync-topics.js +22 -0
  85. package/dist/redis/index.d.ts +4 -0
  86. package/dist/redis/index.js +2 -0
  87. package/dist/redis/pubsub-signal.d.ts +1 -0
  88. package/dist/redis/pubsub-signal.js +28 -0
  89. package/dist/schema-cli.d.ts +8 -6
  90. package/dist/schema-cli.js +37 -4
  91. package/dist/stack/redis.d.ts +6 -1
  92. package/dist/stack/redis.js +7 -2
  93. package/dist/stack/test-stack.d.ts +15 -0
  94. package/dist/stack/test-stack.js +21 -1
  95. package/package.json +4 -4
  96. package/src/changes.json +75 -0
@@ -21,7 +21,8 @@ export type { RedisSseBroker, RedisSseBrokerOptions } from "./redis-sse-broker.j
21
21
  export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker, } from "./redis-sse-broker.js";
22
22
  export { type RequestContextData, requestContext, runAsDirectCallEntry, } from "./request-context.js";
23
23
  export { buildRequestContextDataFromRequest, requestIdMiddleware, } from "./request-id-middleware.js";
24
- export { DEFAULT_MAX_REQUEST_BYTES } from "./route-registrars.js";
24
+ export type { MetricsRouteOptions } from "./route-registrars.js";
25
+ export { createMetricsApp, DEFAULT_MAX_REQUEST_BYTES, isPrometheusMeter, } from "./route-registrars.js";
25
26
  export { createApiRoutes } from "./routes.js";
26
27
  export type { KumikoServer, ServerOptions } from "./server.js";
27
28
  export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite } from "./server.js";
package/dist/api/index.js CHANGED
@@ -12,7 +12,7 @@ export { isSafeLandingPath } from "./post-auth-landing.js";
12
12
  export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker, } from "./redis-sse-broker.js";
13
13
  export { requestContext, runAsDirectCallEntry, } from "./request-context.js";
14
14
  export { buildRequestContextDataFromRequest, requestIdMiddleware, } from "./request-id-middleware.js";
15
- export { DEFAULT_MAX_REQUEST_BYTES } from "./route-registrars.js";
15
+ export { createMetricsApp, DEFAULT_MAX_REQUEST_BYTES, isPrometheusMeter, } from "./route-registrars.js";
16
16
  export { createApiRoutes } from "./routes.js";
17
17
  export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite } from "./server.js";
18
18
  export { createSseBroker } from "./sse-broker.js";
@@ -1,7 +1,7 @@
1
- import type { Hono } from "hono";
1
+ import { Hono } from "hono";
2
2
  import type { DbConnection } from "../db/connection.js";
3
3
  import type { Lifecycle } from "../lifecycle/index.js";
4
- import type { Meter } from "../observability/index.js";
4
+ import type { Meter, PrometheusMeter } from "../observability/index.js";
5
5
  import type { EventConsumer } from "../pipeline/event-dispatcher.js";
6
6
  export declare const DEFAULT_MAX_REQUEST_BYTES = 1048576;
7
7
  export declare function registerBodyLimit(app: Hono, maxBytes: number): void;
@@ -9,7 +9,9 @@ export type MetricsRouteOptions = {
9
9
  readonly token?: string;
10
10
  readonly path?: string;
11
11
  };
12
+ export declare function isPrometheusMeter(m: Meter): m is PrometheusMeter;
12
13
  export declare function registerMetricsRoute(app: Hono, meter: Meter, options: MetricsRouteOptions): void;
14
+ export declare function createMetricsApp(meter: Meter, options: MetricsRouteOptions): Hono;
13
15
  export declare function registerVersionRoute(app: Hono): void;
14
16
  export type HealthRoutesOptions = {
15
17
  readonly lifecycle?: Lifecycle;
@@ -3,6 +3,7 @@
3
3
  // branches. Every registrar takes the Hono app + whatever state it
4
4
  // actually reads — no monolithic `options` parameter, because these
5
5
  // helpers exist to make each step's dependencies visible at the call-site.
6
+ import { Hono } from "hono";
6
7
  import { bodyLimit } from "hono/body-limit";
7
8
  import { serializeOpenMetrics } from "../observability/index.js";
8
9
  import { BODY_LIMIT_OPT_OUT_PATHS, Routes } from "./api-constants.js";
@@ -45,7 +46,7 @@ function constantTimeEqual(a, b) {
45
46
  // Type-guard: duck-types the meter on `snapshot()`. An alternative
46
47
  // Prometheus-compatible meter (future OTLP bridge) works without an
47
48
  // explicit union — if it exposes `snapshot()` it's serialisable.
48
- function isPrometheusMeter(m) {
49
+ export function isPrometheusMeter(m) {
49
50
  return typeof m.snapshot === "function"; // @cast-boundary schema-walk
50
51
  }
51
52
  // Mount `/metrics` (or the caller-supplied path). Takes just the Meter —
@@ -73,6 +74,13 @@ export function registerMetricsRoute(app, meter, options) {
73
74
  return c.body(body);
74
75
  });
75
76
  }
77
+ // Standalone fetch-handler serving only the scrape route — for HTTP-less
78
+ // processes (worker pods) that expose /metrics on their own port.
79
+ export function createMetricsApp(meter, options) {
80
+ const app = new Hono();
81
+ registerMetricsRoute(app, meter, options);
82
+ return app;
83
+ }
76
84
  // --- /version ---------------------------------------------------------------
77
85
  // Anonymous endpoint that returns build-identity. Used by ops tooling
78
86
  // (prod-version.sh) and the Telegram deploy notification so nobody needs
@@ -1,3 +1,4 @@
1
+ import type { CacheSyncBus } from "@cosmicdrift/kumiko-types/cache-sync-types";
1
2
  import { Hono } from "hono";
2
3
  import type { PgClient } from "../db/connection.js";
3
4
  import { type AppContext, type Registry, type RunIn, type WriteResult } from "../engine/types/index.js";
@@ -75,7 +76,7 @@ export type KumikoServer = {
75
76
  lifecycle?: Lifecycle;
76
77
  context: AppContext;
77
78
  };
78
- export declare function withFileProviderResolver(registry: Registry, context: AppContext): AppContext;
79
+ export declare function withFileProviderResolver(registry: Registry, context: AppContext, cacheSync?: CacheSyncBus): AppContext;
79
80
  export declare function buildServer(options: ServerOptions): KumikoServer;
80
81
  export declare function makeDispatchSystemWrite(dispatcher: Dispatcher): (args: SystemDispatchArgs) => Promise<WriteResult>;
81
82
  export declare function makeDispatchSystemQuery(dispatcher: Dispatcher): (args: SystemDispatchArgs) => Promise<unknown>;
@@ -19,7 +19,8 @@ import { createEventDispatcher } from "../pipeline/event-dispatcher.js";
19
19
  import { createLifecycleHooks } from "../pipeline/lifecycle-pipeline.js";
20
20
  import { createMultiStreamApplyContext } from "../pipeline/multi-stream-apply-context.js";
21
21
  import { createAccessInvalidationEventConsumer, createJobTriggerEventConsumer, createSearchEventConsumer, createSseBroadcastEventConsumer, } from "../pipeline/system-hooks.js";
22
- import { authEndpointRateLimit, createRateLimitResolver, globalIpRateLimit, } from "../rate-limit/index.js";
22
+ import { authEndpointRateLimit, createPayloadDigest, createRateLimitResolver, globalIpRateLimit, httpRouteRateLimit, } from "../rate-limit/index.js";
23
+ import { resolveCacheSyncBus } from "../redis/cache-sync-bus.js";
23
24
  import { deriveSearchAdapterConfig } from "../search/derive-search-adapter-config.js";
24
25
  import { assertUnreachable, generateId } from "../utils/index.js";
25
26
  import { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
@@ -47,7 +48,7 @@ import { buildWebSocketSessionRevalidator, createWebSocketConnectionLimiter, has
47
48
  // per-tenant provider cache). Mirrors buildServer's own resolution exactly —
48
49
  // including NOT inventing a resolver when no `file-provider-*` plugin is
49
50
  // mounted, which is what keeps buildServer's boot-guard below able to fire.
50
- export function withFileProviderResolver(registry, context) {
51
+ export function withFileProviderResolver(registry, context, cacheSync) {
51
52
  if (context._fileProviderResolver !== undefined)
52
53
  return context;
53
54
  if (registry.getExtensionUsages(EXT_FILE_PROVIDER).length === 0)
@@ -59,6 +60,7 @@ export function withFileProviderResolver(registry, context) {
59
60
  _configAccessorFactory: context._configAccessorFactory,
60
61
  secrets: context.secrets,
61
62
  db: context.db,
63
+ ...(cacheSync ? { cacheSync } : {}),
62
64
  }),
63
65
  };
64
66
  }
@@ -127,6 +129,10 @@ export function buildServer(options) {
127
129
  sseBroker = defaults.sseBroker;
128
130
  ownedRedisSseBroker = defaults.ownedRedisSseBroker;
129
131
  }
132
+ // Same decision as the SSE broker: an explicit dispatcherOptions value
133
+ // (incl. null = opt out) wins, otherwise REDIS_URL turns on cross-replica
134
+ // cache invalidation (no REDIS_URL = process-local bus).
135
+ const { bus: cacheSync, owned: ownedCacheSync } = resolveCacheSyncBus(options.dispatcherOptions?.cacheSync);
130
136
  // Resolve the per-process instance identifier. Prefer explicit
131
137
  // ServerOptions.instanceId (tests, deliberate wiring), fall back to the
132
138
  // deploy-env variable, finally a boot-time UUID. Validator rejects the
@@ -184,7 +190,7 @@ export function buildServer(options) {
184
190
  // resolver once (when a provider plugin is mounted) — the dispatcher uses it
185
191
  // to materialise `ctx.files`, the upload routes + MSP-applies share it. The
186
192
  // resolver reads config + the s3.secretAccessKey secret under SYSTEM identity.
187
- const contextWithFiles = withFileProviderResolver(options.registry, options.context);
193
+ const contextWithFiles = withFileProviderResolver(options.registry, options.context, cacheSync);
188
194
  const fileProviderResolver = contextWithFiles._fileProviderResolver;
189
195
  // Auto-wire the rate-limit resolver, but ONLY when at least one
190
196
  // handler actually declared a rateLimit option. Apps that don't use
@@ -197,13 +203,22 @@ export function buildServer(options) {
197
203
  // both share the same instance to avoid duplicate Lua-script registration.
198
204
  const wantsL3 = options.registry.hasRateLimitedHandler();
199
205
  const wantsL1L2 = options.rateLimit?.global !== undefined || options.rateLimit?.auth !== undefined;
200
- const wantsResolver = wantsL3 || wantsL1L2;
206
+ const wantsHttpRouteLimit = [...options.registry.features.values()].some((feature) => Object.values(feature.httpRoutes).some((route) => route.rateLimit !== undefined));
207
+ const wantsResolver = wantsL3 || wantsL1L2 || wantsHttpRouteLimit;
208
+ // Master for the payload-bucket HMAC: the current signing secret (the
209
+ // keyring's signKid for rotation setups). buildServer always has one, so
210
+ // the digest is available whenever a handler declares additionalRateLimits.
211
+ const payloadDigest = createPayloadDigest(typeof options.jwtSecret === "string"
212
+ ? options.jwtSecret
213
+ : (options.jwtSecret.keys[options.jwtSecret.signKid] ?? ""));
201
214
  const rateLimitResolver = options.context.rateLimit ??
202
215
  (wrappedRedis && wantsResolver ? createRateLimitResolver({ redis: wrappedRedis }) : undefined);
203
216
  const contextWithObservability = {
204
217
  ...contextWithFiles,
205
218
  ...(wrappedRedis ? { redis: wrappedRedis } : {}),
206
219
  ...(rateLimitResolver ? { rateLimit: rateLimitResolver } : {}),
220
+ _rateLimitPayloadDigest: payloadDigest,
221
+ ...(cacheSync ? { cacheSync } : {}),
207
222
  // Propagate the feature-toggle resolver to the context so the event-
208
223
  // dispatcher (and any future context-reading consumer) sees the same
209
224
  // source as the command dispatcher's handler-gate. Options take
@@ -220,6 +235,7 @@ export function buildServer(options) {
220
235
  ...options.dispatcherOptions,
221
236
  lifecycle,
222
237
  sseBroker,
238
+ cacheSync: cacheSync ?? null,
223
239
  ...(options.auth ? { membershipQuery: options.auth.membershipQuery } : {}),
224
240
  });
225
241
  // Async event-dispatcher — the replacement for the old transactional
@@ -379,6 +395,12 @@ export function buildServer(options) {
379
395
  await broker.close();
380
396
  });
381
397
  }
398
+ if (options.lifecycle && ownedCacheSync) {
399
+ const bus = ownedCacheSync;
400
+ options.lifecycle.registerShutdownHook("cacheSync", async () => {
401
+ await bus.close();
402
+ });
403
+ }
382
404
  const app = new Hono();
383
405
  // Sub-apps mounted via app.route() without their own handler inherit this one.
384
406
  app.onError(handleUncaughtRouteError);
@@ -657,7 +679,23 @@ export function buildServer(options) {
657
679
  systemQuery: makeSystemQuery(c, dispatcher, clientIpResolver),
658
680
  clientIp: clientIpResolver.resolve(clientIpSourceFromHonoContext(c)),
659
681
  });
660
- mountHonoRoute(app, route.method, route.path, honoHandler, route.anonymous ? [] : sessionOnlyHttpRouteGuards);
682
+ // Without a resolver (no Redis in the context — dev/test stacks) the
683
+ // declared limit is skipped rather than failing the boot of every app
684
+ // that mounts a bundled feature whose routes carry a default limit.
685
+ const routeLimit = route.rateLimit && rateLimitResolver
686
+ ? [
687
+ httpRouteRateLimit({
688
+ resolver: rateLimitResolver,
689
+ rateLimit: route.rateLimit,
690
+ routeKey: `${route.method} ${route.path}`,
691
+ clientIpResolver,
692
+ }),
693
+ ]
694
+ : [];
695
+ mountHonoRoute(app, route.method, route.path, honoHandler, [
696
+ ...routeLimit,
697
+ ...(route.anonymous ? [] : sessionOnlyHttpRouteGuards),
698
+ ]);
661
699
  }
662
700
  }
663
701
  // r.webSocketRoute — GET routes under /api/ws/*, so the jwt/pat/rate-limit
@@ -16,8 +16,7 @@ export type KekSourceOptions = {
16
16
  readonly logPrefix?: string;
17
17
  /** Where the boot line naming the KEK source goes. Defaults to `console.info`. */
18
18
  readonly log?: (message: string) => void;
19
- /** Env names to resolve, e.g. `kmsSlotsOf(schema)`. Defaults to the three
20
- * platform slots. */
21
- readonly slots?: readonly string[];
19
+ /** Env names to resolve, e.g. `kmsSlotsOf(schema)`. */
20
+ readonly slots: readonly string[];
22
21
  };
23
- export declare function resolvePlatformKeks(env: KekSourceEnv, options?: KekSourceOptions): Promise<KekSourceEnv>;
22
+ export declare function resolvePlatformKeks(env: KekSourceEnv, options: KekSourceOptions): Promise<KekSourceEnv>;
@@ -7,9 +7,6 @@ const SCALEWAY_KEY_MANAGER_API_VERSION = "v1alpha1";
7
7
  const DEFAULT_REGION = "fr-par";
8
8
  const DECRYPT_TIMEOUT_MS = 5_000;
9
9
  const RETRY_DELAYS_MS = [200, 800];
10
- // Used when the caller names no slots. Apps that predate `kms` schema meta
11
- // rely on it; drop once every consumer declares its slots in the env schema.
12
- const LEGACY_SLOTS = ["PLATFORM_KEK", "PLATFORM_KEK_PREVIOUS", "KUMIKO_BLIND_INDEX_KEY"];
13
10
  function isRetryableStatus(status) {
14
11
  return status >= 500;
15
12
  }
@@ -97,10 +94,10 @@ async function resolveSlot(name, env, options, fetchImpl) {
97
94
  // another slot's fallback path — the trio check downstream still applies.
98
95
  // Slots resolve in parallel: sequentially, a slow Key Manager would cost up to
99
96
  // ~16 s per slot (3 x 5 s timeout + backoff) and could trip startup probes.
100
- export async function resolvePlatformKeks(env, options = {}) {
97
+ export async function resolvePlatformKeks(env, options) {
101
98
  const fetchImpl = options.fetch ?? globalThis.fetch;
102
99
  const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
103
- const slots = options.slots ?? LEGACY_SLOTS;
100
+ const { slots } = options;
104
101
  // Misconfiguration must not cost a Key Manager round trip.
105
102
  if (slots.includes("PLATFORM_KEK_PREVIOUS") &&
106
103
  (env.PLATFORM_KEK_PREVIOUS || env.PLATFORM_KEK_PREVIOUS_CIPHERTEXT) &&
@@ -62,6 +62,6 @@ export declare function requireKmsWiring(env: KmsWiringEnv, options?: KmsWiringO
62
62
  * from a Key Manager ciphertext first when no plaintext is set — see
63
63
  * `resolvePlatformKeks`. The validation stays in the sync function; this only
64
64
  * adds the KEK-fetching step in front of it. */
65
- export declare function resolveKmsWiringAsync(env: KmsWiringEnv, options?: KmsWiringOptions & KekSourceOptions): Promise<KmsWiring>;
65
+ export declare function resolveKmsWiringAsync(env: KmsWiringEnv, options: KmsWiringOptions & KekSourceOptions): Promise<KmsWiring>;
66
66
  /** Async counterpart to `requireKmsWiring`, KEK-resolving like `resolveKmsWiringAsync`. */
67
- export declare function requireKmsWiringAsync(env: KmsWiringEnv, options?: KmsWiringOptions & KekSourceOptions): Promise<ActiveKmsWiring>;
67
+ export declare function requireKmsWiringAsync(env: KmsWiringEnv, options: KmsWiringOptions & KekSourceOptions): Promise<ActiveKmsWiring>;
@@ -116,10 +116,10 @@ export function requireKmsWiring(env, options = {}) {
116
116
  * from a Key Manager ciphertext first when no plaintext is set — see
117
117
  * `resolvePlatformKeks`. The validation stays in the sync function; this only
118
118
  * adds the KEK-fetching step in front of it. */
119
- export async function resolveKmsWiringAsync(env, options = {}) {
119
+ export async function resolveKmsWiringAsync(env, options) {
120
120
  return resolveKmsWiring(await resolvePlatformKeks(env, options), options);
121
121
  }
122
122
  /** Async counterpart to `requireKmsWiring`, KEK-resolving like `resolveKmsWiringAsync`. */
123
- export async function requireKmsWiringAsync(env, options = {}) {
123
+ export async function requireKmsWiringAsync(env, options) {
124
124
  return requireKmsWiring(await resolvePlatformKeks(env, options), options);
125
125
  }
@@ -20,7 +20,7 @@ export { createEventStoreExecutor, entityEventName } from "./event-store-executo
20
20
  export { enumerateFeatureTableSources, type FeatureTableSource, } from "./feature-table-sources.js";
21
21
  export { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp.js";
22
22
  export { assertValidMigrationName, diffSnapshots, type GenerateMigrationInput, type GenerateMigrationOutput, generateMigration, loadSnapshotJson, renderMigrationSql, type SchemaDiff, type Snapshot, snapshotFromMetas, writeSnapshotJson, } from "./migrate-generator.js";
23
- export { type AppliedMigration, type ApplyResult, type BaselineResult, baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, type Migration, MigrationChecksumMismatchError, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.js";
23
+ export { type AppliedMigration, type ApplyResult, type BaselineResult, baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, type Migration, MigrationChecksumMismatchError, readMigrationSqlTexts, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.js";
24
24
  export { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.js";
25
25
  export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, type PgErrorInfo, } from "./pg-error.js";
26
26
  export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
@@ -31,6 +31,7 @@ export { readRebuildMarker, rebuildTablesFromDiff, writeRebuildMarker, } from ".
31
31
  export { seedReferenceData } from "./reference-data.js";
32
32
  export { renderTableDdl, renderTablesDdl } from "./render-ddl.js";
33
33
  export { diffReplayAgainstSnapshot, type ReplayedSchema, type ReplayedTable, type ReplayMismatch, replayMigrationsDir, } from "./replay-migration-sql.js";
34
+ export { findCommentedDropTables, isRetiredFrameworkTable, type RetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.js";
34
35
  export { tableExists } from "./schema-inspection.js";
35
36
  export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityTable, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
36
37
  export type { TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb } from "./tenant-db.js";
package/dist/db/index.js CHANGED
@@ -14,7 +14,7 @@ export { createEventStoreExecutor, entityEventName } from "./event-store-executo
14
14
  export { enumerateFeatureTableSources, } from "./feature-table-sources.js";
15
15
  export { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp.js";
16
16
  export { assertValidMigrationName, diffSnapshots, generateMigration, loadSnapshotJson, renderMigrationSql, snapshotFromMetas, writeSnapshotJson, } from "./migrate-generator.js";
17
- export { baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, MigrationChecksumMismatchError, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.js";
17
+ export { baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, MigrationChecksumMismatchError, readMigrationSqlTexts, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.js";
18
18
  export { flattenMoney, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.js";
19
19
  export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, } from "./pg-error.js";
20
20
  export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
@@ -24,6 +24,7 @@ export { readRebuildMarker, rebuildTablesFromDiff, writeRebuildMarker, } from ".
24
24
  export { seedReferenceData } from "./reference-data.js";
25
25
  export { renderTableDdl, renderTablesDdl } from "./render-ddl.js";
26
26
  export { diffReplayAgainstSnapshot, replayMigrationsDir, } from "./replay-migration-sql.js";
27
+ export { findCommentedDropTables, isRetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.js";
27
28
  export { tableExists } from "./schema-inspection.js";
28
29
  export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
29
30
  export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, } from "./tenant-db.js";
@@ -1,4 +1,5 @@
1
1
  import type { ColumnMeta, EntityTableMeta, IndexMeta } from "./entity-table-meta.js";
2
+ import { type RetiredFrameworkTable } from "./retired-framework-tables.js";
2
3
  declare const SNAPSHOT_VERSION: 1;
3
4
  export type Snapshot = {
4
5
  readonly version: typeof SNAPSHOT_VERSION;
@@ -62,18 +63,23 @@ export declare function managedChangeRequiresRecreate(td: TableDiff): boolean;
62
63
  export declare function renderMigrationSql(diff: SchemaDiff, options: {
63
64
  readonly name: string;
64
65
  readonly sequenceNumber: number;
66
+ readonly retiredDrops?: readonly RetiredFrameworkTable[];
65
67
  }): string;
66
68
  export type GenerateMigrationInput = {
67
69
  readonly metas: readonly EntityTableMeta[];
68
70
  readonly prevSnapshot: Snapshot | null;
69
71
  readonly name: string;
70
72
  readonly sequenceNumber: number;
73
+ /** Raw SQL of all earlier migrations, in order — lets the generator decide
74
+ * whether a retired framework table still needs its executable drop. */
75
+ readonly priorMigrationsSql?: readonly string[];
71
76
  };
72
77
  export type GenerateMigrationOutput = {
73
78
  readonly filename: string;
74
79
  readonly sqlContent: string;
75
80
  readonly snapshot: Snapshot;
76
81
  readonly diff: SchemaDiff;
82
+ readonly retiredDrops: readonly RetiredFrameworkTable[];
77
83
  };
78
84
  export declare const MIGRATION_NAME_RE: RegExp;
79
85
  export declare function assertValidMigrationName(name: string): void;
@@ -17,6 +17,7 @@
17
17
  import { readFileSync, writeFileSync } from "node:fs";
18
18
  import { compareByCodepoint } from "../utils/index.js";
19
19
  import { renderIndex, renderTableDdl } from "./render-ddl.js";
20
+ import { findRetiredTableDrops } from "./retired-framework-tables.js";
20
21
  const SNAPSHOT_VERSION = 1;
21
22
  export function snapshotFromMetas(metas) {
22
23
  // Stable ordering by tableName so the snapshot.json diff in PRs is
@@ -321,6 +322,7 @@ function describeIndexChange(change) {
321
322
  // the index serves performance) are emitted as commented-out statements
322
323
  // for explicit reviewer-action.
323
324
  export function renderMigrationSql(diff, options) {
325
+ const retiredDrops = options.retiredDrops ?? [];
324
326
  const lines = [];
325
327
  const seq = options.sequenceNumber.toString().padStart(4, "0");
326
328
  lines.push(`-- Migration ${seq}_${options.name}`);
@@ -393,9 +395,18 @@ export function renderMigrationSql(diff, options) {
393
395
  }
394
396
  lines.push("");
395
397
  }
398
+ if (retiredDrops.length > 0) {
399
+ lines.push("-- === Retired framework tables ===");
400
+ for (const retired of retiredDrops) {
401
+ lines.push(`-- ${retired.tableName}: retired since framework ${retired.sinceVersion} — ${retired.explanation}`);
402
+ lines.push(`DROP TABLE IF EXISTS ${quoteIdent(retired.tableName)};`);
403
+ }
404
+ lines.push("");
405
+ }
396
406
  if (diff.newTables.length === 0 &&
397
407
  diff.changedTables.length === 0 &&
398
- diff.droppedTables.length === 0) {
408
+ diff.droppedTables.length === 0 &&
409
+ retiredDrops.length === 0) {
399
410
  lines.push("-- No schema changes detected.");
400
411
  }
401
412
  return lines.join("\n");
@@ -413,9 +424,11 @@ export function generateMigration(input) {
413
424
  assertValidMigrationName(input.name);
414
425
  const nextSnapshot = snapshotFromMetas(input.metas);
415
426
  const diff = diffSnapshots(input.prevSnapshot, nextSnapshot);
427
+ const retiredDrops = findRetiredTableDrops(input.priorMigrationsSql ?? [], nextSnapshot);
416
428
  const sqlContent = renderMigrationSql(diff, {
417
429
  name: input.name,
418
430
  sequenceNumber: input.sequenceNumber,
431
+ retiredDrops,
419
432
  });
420
433
  const seq = input.sequenceNumber.toString().padStart(4, "0");
421
434
  return {
@@ -423,5 +436,6 @@ export function generateMigration(input) {
423
436
  sqlContent,
424
437
  snapshot: nextSnapshot,
425
438
  diff,
439
+ retiredDrops,
426
440
  };
427
441
  }
@@ -14,6 +14,7 @@ export type ApplyResult = {
14
14
  };
15
15
  export declare function splitSqlStatements(sqlText: string): readonly string[];
16
16
  export declare function loadMigrationsFromDir(dir: string, preprocess?: (sql: string) => string): readonly Migration[];
17
+ export declare function readMigrationSqlTexts(dir: string): readonly string[];
17
18
  export declare function fetchAppliedMigrations(db: DbConnection): Promise<readonly AppliedMigration[]>;
18
19
  export declare class MigrationChecksumMismatchError extends Error {
19
20
  readonly migrationId: string;
@@ -14,7 +14,7 @@
14
14
  // fehl mit klarer Fehlermeldung (production-DBs haben das alte SQL drin —
15
15
  // edit nachträglich = production-state inkonsistent mit committed-state).
16
16
  import { createHash } from "node:crypto";
17
- import { readdirSync, readFileSync } from "node:fs";
17
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
19
  // Adapter: extract raw postgres-js client from drizzle DbConnection,
20
20
  // or use Bun.sql instance directly. Either way `.unsafe()` is the
@@ -171,6 +171,16 @@ export function loadMigrationsFromDir(dir, preprocess) {
171
171
  };
172
172
  });
173
173
  }
174
+ // Raw file texts in migration order, comments intact (loadMigrationsFromDir
175
+ // strips them) — the retired-table scan reads them for DESTRUCTIVE markers.
176
+ export function readMigrationSqlTexts(dir) {
177
+ if (!existsSync(dir))
178
+ return [];
179
+ return readdirSync(dir)
180
+ .filter((f) => f.endsWith(".sql"))
181
+ .sort()
182
+ .map((file) => readFileSync(join(dir, file), "utf8"));
183
+ }
174
184
  // Raw-SQL via postgres-js or Bun.sql .unsafe() — same shape for both.
175
185
  async function executeRaw(db, sqlText) {
176
186
  await rawClient(db).unsafe(sqlText);
@@ -1,3 +1,4 @@
1
+ import type { ProjectionRowIdOf } from "@cosmicdrift/kumiko-types/projection";
1
2
  import type { DbConnection, DbTx } from "../connection.js";
2
3
  import type { EntityTableMeta } from "../entity-table-meta.js";
3
4
  import { type AnyDb } from "../query.js";
@@ -8,7 +9,7 @@ export declare function assertLiveColumnsMatchMeta(db: DbConnection | DbTx, meta
8
9
  export declare function assertLiveTableHasNoRowLevelSecurity(tx: AnyDb, tableName: string, projectionName?: string): Promise<void>;
9
10
  export declare function buildShadowTable(tx: AnyDb, meta: EntityTableMeta): Promise<void>;
10
11
  export declare function fenceLiveTable(tx: AnyDb, tableName: string, lockTimeoutMs: number): Promise<void>;
11
- export declare function assertNoUnreachableLiveRows(tx: AnyDb, projectionName: string, tableName: string, aggregateTypes: readonly string[]): Promise<void>;
12
+ export declare function assertNoUnreachableLiveRows(tx: AnyDb, projectionName: string, tableName: string, aggregateTypes: readonly string[], derivedRowIds?: Readonly<Record<string, ProjectionRowIdOf>>): Promise<void>;
12
13
  export declare function assertNoBlindIndexLoss(tx: AnyDb, tableName: string, meta: EntityTableMeta, projectionName: string): Promise<void>;
13
14
  export type ColumnDriftResult = {
14
15
  readonly rowCount: number;
@@ -17,6 +17,7 @@
17
17
  // Boundary: the shadow table is rebuilt from EntityTableMeta, so any index NOT
18
18
  // expressed in meta (hand-added in a migration) is not reconstructed, and a
19
19
  // partial index whose WHERE the renderer can't express is rejected up-front.
20
+ import { isUuid, parseTenantId } from "@cosmicdrift/kumiko-types/identifiers";
20
21
  import { configuredBlindIndexKey } from "../../crypto/index.js";
21
22
  import { asEntityTableMeta, asRawClient } from "../query.js";
22
23
  import { renderTableDdl } from "../render-ddl.js";
@@ -178,19 +179,80 @@ const UNREACHABLE_SAMPLE_LIMIT = 20;
178
179
  //
179
180
  // Implicit projections only (caller-gated). aggregate_id and the entity id are
180
181
  // both uuid, so the anti-join probes the events index without a cast.
181
- export async function assertNoUnreachableLiveRows(tx, projectionName, tableName, aggregateTypes) {
182
+ //
183
+ // Source streams whose rows are keyed by a DERIVED id (r.extendEntityProjection
184
+ // `rowIdOf`, e.g. tenant-salted) cannot be matched on aggregate_id: their
185
+ // distinct (tenant, aggregate) pairs are mapped through rowIdOf into a temp
186
+ // table, and a live row is backed when its id is in that set. The guard stays
187
+ // strict — a row keyed by the raw aggregate id of a derived stream is a ghost.
188
+ const DERIVED_ROW_ID_BATCH_SIZE = 5000;
189
+ const DERIVED_ROW_ID_TABLE = "pg_temp.kumiko_derived_row_ids";
190
+ async function fillDerivedRowIdTable(raw, projectionName, aggregateType, rowIdOf) {
191
+ let cursor;
192
+ for (;;) {
193
+ const page = await raw.unsafe(`SELECT DISTINCT "tenant_id"::text AS tenant_id, "aggregate_id"::text AS aggregate_id
194
+ FROM "kumiko_events"
195
+ WHERE "aggregate_type" = $1
196
+ ${cursor ? `AND ("tenant_id", "aggregate_id") > ($2::uuid, $3::uuid)` : ""}
197
+ ORDER BY "tenant_id", "aggregate_id"
198
+ LIMIT ${DERIVED_ROW_ID_BATCH_SIZE}`, cursor ? [aggregateType, cursor.tenant_id, cursor.aggregate_id] : [aggregateType]);
199
+ // skip: keyset exhausted — every source event of this type has been mapped
200
+ if (page.length === 0)
201
+ return;
202
+ const derivedIds = page.map((row) => {
203
+ const tenantId = parseTenantId(row.tenant_id);
204
+ if (tenantId === null) {
205
+ throw new Error(`projection-rebuild "${projectionName}": event of aggregate type "${aggregateType}" ` +
206
+ `carries a non-uuid tenant_id "${row.tenant_id}".`);
207
+ }
208
+ const derived = rowIdOf({ tenantId, aggregateId: row.aggregate_id });
209
+ if (!isUuid(derived)) {
210
+ throw new Error(`projection-rebuild "${projectionName}": rowIdOf for aggregate type "${aggregateType}" ` +
211
+ `returned "${String(derived)}", which is not a uuid (aggregate ${row.aggregate_id}).`);
212
+ }
213
+ return derived;
214
+ });
215
+ await raw.unsafe(`INSERT INTO ${DERIVED_ROW_ID_TABLE} ("id") SELECT unnest($1::uuid[]) ON CONFLICT DO NOTHING`, [derivedIds]);
216
+ // skip: a short page is the last one
217
+ if (page.length < DERIVED_ROW_ID_BATCH_SIZE)
218
+ return;
219
+ cursor = page[page.length - 1];
220
+ }
221
+ }
222
+ export async function assertNoUnreachableLiveRows(tx, projectionName, tableName, aggregateTypes, derivedRowIds = {}) {
182
223
  // skip: no source streams → no events could back any row anyway; a rebuild
183
224
  // of a subscription-less projection swaps an empty shadow (handled upstream).
184
225
  if (aggregateTypes.length === 0)
185
226
  return;
186
227
  const raw = asRawClient(tx);
187
228
  const t = quoteTableIdent(tableName);
229
+ const derivedTypes = aggregateTypes.filter((type) => derivedRowIds[type] !== undefined);
230
+ const directTypes = aggregateTypes.filter((type) => derivedRowIds[type] === undefined);
231
+ if (derivedTypes.length > 0) {
232
+ // search_path points at the shadow schema here; pg_temp is addressed explicitly.
233
+ await raw.unsafe(`CREATE TEMP TABLE IF NOT EXISTS kumiko_derived_row_ids ("id" uuid PRIMARY KEY) ON COMMIT DROP`);
234
+ await raw.unsafe(`TRUNCATE ${DERIVED_ROW_ID_TABLE}`);
235
+ for (const aggregateType of derivedTypes) {
236
+ const rowIdOf = derivedRowIds[aggregateType];
237
+ // skip: filtered by derivedTypes above, narrows the Record lookup for the compiler
238
+ if (rowIdOf === undefined)
239
+ continue;
240
+ await fillDerivedRowIdTable(raw, projectionName, aggregateType, rowIdOf);
241
+ }
242
+ }
243
+ const unbackedClauses = [];
244
+ if (directTypes.length > 0) {
245
+ unbackedClauses.push(`NOT EXISTS (
246
+ SELECT 1 FROM "kumiko_events" e
247
+ WHERE e."aggregate_id" = l."id" AND e."aggregate_type" = ANY($1::text[])
248
+ )`);
249
+ }
250
+ if (derivedTypes.length > 0) {
251
+ unbackedClauses.push(`NOT EXISTS (SELECT 1 FROM ${DERIVED_ROW_ID_TABLE} d WHERE d."id" = l."id")`);
252
+ }
188
253
  const ghosts = await raw.unsafe(`SELECT l."id" FROM public.${t} l
189
- WHERE NOT EXISTS (
190
- SELECT 1 FROM "kumiko_events" e
191
- WHERE e."aggregate_id" = l."id" AND e."aggregate_type" = ANY($1::text[])
192
- )
193
- LIMIT ${UNREACHABLE_SAMPLE_LIMIT}`, [aggregateTypes]);
254
+ WHERE ${unbackedClauses.join(" AND ")}
255
+ LIMIT ${UNREACHABLE_SAMPLE_LIMIT}`, directTypes.length > 0 ? [directTypes] : []);
194
256
  // skip: every live row has a backing event — nothing unreachable, swap is safe
195
257
  if (ghosts.length === 0)
196
258
  return;
@@ -200,7 +262,8 @@ export async function assertNoUnreachableLiveRows(tx, projectionName, tableName,
200
262
  `event in the projection's source streams and cannot be reconstructed by replay — the swap ` +
201
263
  `would silently drop them (ids: ${ids.join(", ")}). A handler direct-inserted these rows ` +
202
264
  `without emitting a .created event. Fix: register the table with r.storeTable(meta, ` +
203
- `{ reason }) to opt out of rebuild, or emit the missing events. See ` +
265
+ `{ reason }) to opt out of rebuild, or emit the missing events. Rows keyed by an id ` +
266
+ `derived from the source event declare \`rowIdOf\` on r.extendEntityProjection. See ` +
204
267
  `docs/reference/entity-write-patterns.md. Rebuild aborted; live table untouched.`);
205
268
  }
206
269
  // The bidx column is schema-driven, not key-driven: it exists NULL in a
@@ -0,0 +1,10 @@
1
+ import type { Snapshot } from "./migrate-generator.js";
2
+ export type RetiredFrameworkTable = {
3
+ readonly tableName: string;
4
+ readonly sinceVersion: string;
5
+ readonly explanation: string;
6
+ };
7
+ export declare const retiredFrameworkTables: readonly RetiredFrameworkTable[];
8
+ export declare function findRetiredTableDrops(priorMigrationsSql: readonly string[], nextSnapshot: Snapshot): readonly RetiredFrameworkTable[];
9
+ export declare function findCommentedDropTables(migrationsSql: readonly string[], snapshot: Snapshot | null): readonly string[];
10
+ export declare function isRetiredFrameworkTable(tableName: string): boolean;
@@ -0,0 +1,72 @@
1
+ // Framework tables that a framework release stopped creating and whose data
2
+ // was explicitly declared dispensable. `kumiko schema generate` drops them
3
+ // for real (instead of the commented-out DESTRUCTIVE marker app tables get),
4
+ // because the framework — not the app — owns the decision that the data is
5
+ // gone. Without this, every app carried a stale table forever behind a
6
+ // commented drop nobody uncomments.
7
+ //
8
+ // Add an entry only when the release notes state the data is not preserved.
9
+ // A rename is NOT a retirement: the data lives on under the new name.
10
+ export const retiredFrameworkTables = [
11
+ {
12
+ tableName: "read_job_runs",
13
+ sinceVersion: "0.210.0",
14
+ explanation: "replaced by store_job_runs; the job run history was not migrated (operational data, not a system of record)",
15
+ },
16
+ ];
17
+ const IDENT = `"?([^"\\s(;,.]+)"?`;
18
+ const CREATE_TABLE_RE = new RegExp(`^CREATE TABLE\\s+(?:IF NOT EXISTS\\s+)?${IDENT}`, "i");
19
+ const DROP_TABLE_RE = new RegExp(`^DROP TABLE\\s+(?:IF EXISTS\\s+)?${IDENT}`, "i");
20
+ const COMMENTED_DROP_TABLE_RE = new RegExp(`^--\\s*DESTRUCTIVE:\\s*DROP TABLE\\s+(?:IF EXISTS\\s+)?${IDENT}`, "i");
21
+ // Last known state per table across all migrations, in order — so a
22
+ // DROP followed by a re-CREATE (managed-projection recreate) counts as
23
+ // "created", and a commented drop followed by an executable one as "dropped".
24
+ function scanTableLifecycle(sqlTexts) {
25
+ const states = new Map();
26
+ for (const text of sqlTexts) {
27
+ for (const rawLine of text.split("\n")) {
28
+ const line = rawLine.trim();
29
+ const commentedDrop = COMMENTED_DROP_TABLE_RE.exec(line)?.[1];
30
+ if (commentedDrop !== undefined) {
31
+ states.set(commentedDrop, "commented-drop");
32
+ continue;
33
+ }
34
+ if (line.startsWith("--"))
35
+ continue;
36
+ const created = CREATE_TABLE_RE.exec(line)?.[1];
37
+ if (created !== undefined) {
38
+ states.set(created, "created");
39
+ continue;
40
+ }
41
+ const dropped = DROP_TABLE_RE.exec(line)?.[1];
42
+ if (dropped !== undefined)
43
+ states.set(dropped, "dropped");
44
+ }
45
+ }
46
+ return states;
47
+ }
48
+ function snapshotHasTable(snapshot, tableName) {
49
+ return snapshot?.tables.some((t) => t.tableName === tableName) ?? false;
50
+ }
51
+ // Retired tables the next migration must drop: a migration created them (or
52
+ // carries a commented drop), none dropped them for real, and the schema no
53
+ // longer declares them.
54
+ export function findRetiredTableDrops(priorMigrationsSql, nextSnapshot) {
55
+ const states = scanTableLifecycle(priorMigrationsSql);
56
+ return retiredFrameworkTables.filter((retired) => {
57
+ const state = states.get(retired.tableName);
58
+ return ((state === "created" || state === "commented-drop") &&
59
+ !snapshotHasTable(nextSnapshot, retired.tableName));
60
+ });
61
+ }
62
+ // Tables a migration only carries as a commented DESTRUCTIVE drop although
63
+ // the snapshot no longer has them: if they still exist in the live DB, the
64
+ // drop was never made.
65
+ export function findCommentedDropTables(migrationsSql, snapshot) {
66
+ return [...scanTableLifecycle(migrationsSql)]
67
+ .filter(([tableName, state]) => state === "commented-drop" && !snapshotHasTable(snapshot, tableName))
68
+ .map(([tableName]) => tableName);
69
+ }
70
+ export function isRetiredFrameworkTable(tableName) {
71
+ return retiredFrameworkTables.some((retired) => retired.tableName === tableName);
72
+ }