@cosmicdrift/kumiko-framework 0.340.0 → 0.341.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 (74) 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/db/queries/shadow-swap.d.ts +2 -1
  8. package/dist/db/queries/shadow-swap.js +70 -7
  9. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  10. package/dist/engine/boot-validator/entity-handler.js +48 -0
  11. package/dist/engine/define-handler.js +1 -0
  12. package/dist/engine/feature-ast/extractors/handlers.d.ts +2 -1
  13. package/dist/engine/feature-ast/extractors/handlers.js +11 -1
  14. package/dist/engine/feature-ast/extractors/hooks.d.ts +2 -1
  15. package/dist/engine/feature-ast/extractors/hooks.js +14 -0
  16. package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
  17. package/dist/engine/feature-ast/extractors/index.js +1 -1
  18. package/dist/engine/feature-ast/patch.js +1 -0
  19. package/dist/engine/feature-ast/pattern-change-schema.d.ts +20 -0
  20. package/dist/engine/feature-ast/pattern-change-schema.js +10 -0
  21. package/dist/engine/feature-ast/patterns.d.ts +2 -1
  22. package/dist/engine/feature-ast/render.js +3 -0
  23. package/dist/engine/feature-entity-handlers.d.ts +2 -1
  24. package/dist/engine/feature-entity-handlers.js +4 -0
  25. package/dist/engine/feature-ui-extensions.js +9 -0
  26. package/dist/engine/index.d.ts +1 -1
  27. package/dist/engine/membership-roles.js +5 -1
  28. package/dist/engine/pattern-library/mixed-schemas.js +5 -0
  29. package/dist/engine/registry-state.js +38 -5
  30. package/dist/engine/registry-validate.js +2 -0
  31. package/dist/engine/tier-resolver-extension.d.ts +2 -0
  32. package/dist/engine/types/index.d.ts +2 -2
  33. package/dist/entrypoint/index.js +25 -6
  34. package/dist/errors/classes.d.ts +5 -0
  35. package/dist/errors/classes.js +13 -0
  36. package/dist/errors/index.d.ts +1 -1
  37. package/dist/errors/index.js +1 -1
  38. package/dist/files/file-routes.js +7 -2
  39. package/dist/files/index.d.ts +1 -0
  40. package/dist/files/index.js +1 -0
  41. package/dist/files/provider-resolver.d.ts +2 -0
  42. package/dist/files/provider-resolver.js +16 -1
  43. package/dist/files/resolve-content-type.d.ts +11 -0
  44. package/dist/files/resolve-content-type.js +71 -0
  45. package/dist/files/types.d.ts +1 -1
  46. package/dist/files/types.js +5 -1
  47. package/dist/jobs/job-runner.js +1 -1
  48. package/dist/pipeline/dispatch-shared.d.ts +4 -1
  49. package/dist/pipeline/dispatch-shared.js +70 -9
  50. package/dist/pipeline/dispatch-write.js +35 -10
  51. package/dist/pipeline/dispatcher.d.ts +2 -0
  52. package/dist/pipeline/dispatcher.js +17 -1
  53. package/dist/pipeline/projection-rebuild.js +1 -1
  54. package/dist/rate-limit/bucket.d.ts +4 -0
  55. package/dist/rate-limit/bucket.js +25 -0
  56. package/dist/rate-limit/index.d.ts +2 -2
  57. package/dist/rate-limit/index.js +2 -2
  58. package/dist/rate-limit/middleware.d.ts +9 -0
  59. package/dist/rate-limit/middleware.js +33 -2
  60. package/dist/rate-limit/resolver.js +2 -1
  61. package/dist/redis/cache-sync-bus.d.ts +24 -0
  62. package/dist/redis/cache-sync-bus.js +152 -0
  63. package/dist/redis/cache-sync-topics.d.ts +17 -0
  64. package/dist/redis/cache-sync-topics.js +22 -0
  65. package/dist/redis/index.d.ts +4 -0
  66. package/dist/redis/index.js +2 -0
  67. package/dist/redis/pubsub-signal.d.ts +1 -0
  68. package/dist/redis/pubsub-signal.js +28 -0
  69. package/dist/stack/redis.d.ts +6 -1
  70. package/dist/stack/redis.js +7 -2
  71. package/dist/stack/test-stack.d.ts +15 -0
  72. package/dist/stack/test-stack.js +21 -1
  73. package/package.json +4 -4
  74. package/src/changes.json +47 -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
@@ -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
@@ -6,6 +6,7 @@ export declare const PII_USER_REFERENCE_NAME_HINTS: ReadonlySet<string>;
6
6
  /** Extensions with preSave must target an entity that has mapped write handlers. */
7
7
  export declare function validateExtensionPreSaveWiring(features: readonly FeatureDefinition[]): void;
8
8
  export declare function validateHandlerAccess(feature: FeatureDefinition): void;
9
+ export declare function validateAdditionalRateLimits(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
9
10
  export declare function validateTenantlessAnonymous(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
10
11
  export declare function validateRateLimitDisabledReason(featureName: string, kind: "write" | "query" | "stream", handlerName: string, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
11
12
  export declare function validateNoAllRoleInHandlerAccess(featureName: string, kind: "write" | "query" | "stream", handlerName: string, access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>): void;
@@ -1,4 +1,5 @@
1
1
  import { isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
2
+ import { ZodObject } from "zod";
2
3
  import { VARIANT_NAME_PATTERN } from "../../derivatives/variant-key.js";
3
4
  import { access as accessPresets } from "../config-helpers.js";
4
5
  import { parseRefTarget } from "../parse-ref-target.js";
@@ -136,6 +137,53 @@ export function validateHandlerAccess(feature) {
136
137
  }
137
138
  for (const [name, handler] of Object.entries(feature.writeHandlers)) {
138
139
  validateTenantlessAnonymous(feature.name, name, handler);
140
+ validateAdditionalRateLimits(feature.name, name, handler);
141
+ }
142
+ }
143
+ const IP_BUCKETED_RATE_LIMIT_PER = new Set(["ip", "ip+handler"]);
144
+ // Payload buckets only complement the per-IP bucket: an anonymous caller
145
+ // who rotates IPs is what they exist for, and without the IP bucket a single
146
+ // IP could still burn through unlimited distinct addresses.
147
+ export function validateAdditionalRateLimits(featureName, handlerName, handler) {
148
+ const { additionalRateLimits } = handler;
149
+ // skip: handler declares no payload buckets
150
+ if (additionalRateLimits === undefined)
151
+ return;
152
+ const where = `write handler "${featureName}:write:${handlerName}" declares additionalRateLimits`;
153
+ if (additionalRateLimits.length === 0) {
154
+ throw new Error(`${where} as an empty array — declare at least one bucket or drop the option.`);
155
+ }
156
+ validateAnonymousHasIpBucket(where, handler);
157
+ const shape = handler.schema instanceof ZodObject ? handler.schema.shape : undefined;
158
+ for (const entry of additionalRateLimits) {
159
+ validatePayloadBucket(where, shape, entry);
160
+ }
161
+ }
162
+ function validateAnonymousHasIpBucket(where, handler) {
163
+ const { access, rateLimit } = handler;
164
+ const allowsAnonymous = "roles" in access && access.roles.includes("anonymous");
165
+ // skip: only anonymous callers need an ip bucket
166
+ if (!allowsAnonymous)
167
+ return;
168
+ const hasIpBucket = rateLimit !== undefined &&
169
+ !isRateLimitDisabled(rateLimit) &&
170
+ IP_BUCKETED_RATE_LIMIT_PER.has(rateLimit.per);
171
+ if (!hasIpBucket) {
172
+ throw new Error(`${where} and allows anonymous callers, but has no ip-keyed rateLimit ` +
173
+ `(per: "ip" or "ip+handler", not disabled). Payload buckets only complement the IP bucket.`);
174
+ }
175
+ }
176
+ function validatePayloadBucket(where, shape, entry) {
177
+ const field = entry.per.payloadField;
178
+ if (shape === undefined || !Object.hasOwn(shape, field)) {
179
+ throw new Error(`${where} with payloadField "${field}", which is not a field of the handler's schema ` +
180
+ `(the schema must be a Zod object containing it).`);
181
+ }
182
+ if (!Number.isInteger(entry.limit) || entry.limit <= 0) {
183
+ throw new Error(`${where} for "${field}" with limit ${entry.limit} — must be a positive integer.`);
184
+ }
185
+ if (!Number.isInteger(entry.windowSeconds) || entry.windowSeconds <= 0) {
186
+ throw new Error(`${where} for "${field}" with windowSeconds ${entry.windowSeconds} — must be a positive integer.`);
139
187
  }
140
188
  }
141
189
  export function validateTenantlessAnonymous(featureName, handlerName, handler) {
@@ -50,6 +50,7 @@ export function defineWriteHandler(def,
50
50
  unsafeSkipTransitionGuard: def.unsafeSkipTransitionGuard,
51
51
  }),
52
52
  ...(def.rateLimit && { rateLimit: def.rateLimit }),
53
+ ...(def.additionalRateLimits && { additionalRateLimits: def.additionalRateLimits }),
53
54
  ...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
54
55
  ...(def.tenantlessAnonymous && { tenantlessAnonymous: true }),
55
56
  };
@@ -1,5 +1,5 @@
1
1
  import type { CallExpression, SourceFile } from "ts-morph";
2
- import type { AccessRule, AgentHandlerHints, AgentRisk, EscapeHatchDeclaration, RateLimitDeclaration } from "../../types/handlers.js";
2
+ import type { AccessRule, AgentHandlerHints, AgentRisk, EscapeHatchDeclaration, PayloadRateLimitOption, RateLimitDeclaration } from "../../types/handlers.js";
3
3
  import type { QueryHandlerPattern, StreamHandlerPattern, WriteHandlerPattern } from "../patterns.js";
4
4
  import type { SourceLocation } from "../source-location.js";
5
5
  import { type ExtractOutput, type RawRefSentinel } from "./shared.js";
@@ -12,6 +12,7 @@ export type ParsedHandlerCall = {
12
12
  readonly description?: string;
13
13
  readonly agent?: AgentHandlerHints | RawRefSentinel;
14
14
  readonly rateLimit?: RateLimitDeclaration | RawRefSentinel;
15
+ readonly additionalRateLimits?: readonly PayloadRateLimitOption[] | RawRefSentinel;
15
16
  readonly unsafeSkipTransitionGuard?: boolean;
16
17
  readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
17
18
  };
@@ -1,7 +1,7 @@
1
1
  import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
2
2
  import { SyntaxKind } from "ts-morph";
3
3
  import { sourceLocationFromNode } from "../source-location.js";
4
- import { readHeaderValueOrRaw, readOptionalAccessRule, readOptionalEscapeHatch, readOptionalRateLimit, } from "./hooks.js";
4
+ import { readHeaderValueOrRaw, readOptionalAccessRule, readOptionalAdditionalRateLimits, readOptionalEscapeHatch, readOptionalRateLimit, } from "./hooks.js";
5
5
  import { fail, findFunctionLiteral, isPlainObject, isRawRefSentinel, ok, readBooleanProperty, readDataLiteralNode, readNameLiteral, readObjectPropertyInitializer, readPropertyKey, } from "./shared.js";
6
6
  export const AGENT_RISK_VALUES = ["low", "mid", "high"];
7
7
  function isAgentRisk(value) {
@@ -31,6 +31,7 @@ const WRITE_HANDLER_KEY_KINDS = {
31
31
  agent: "modeled",
32
32
  unsafeSkipTransitionGuard: "modeled",
33
33
  rateLimit: "modeled",
34
+ additionalRateLimits: "modeled",
34
35
  escapeHatch: "modeled",
35
36
  tenantlessAnonymous: "opaque",
36
37
  perform: "opaque",
@@ -176,6 +177,9 @@ function readHandlerHeaderFields(obj, methodName, sourceFile) {
176
177
  const rateLimitResult = readHeaderField(readObjectPropertyInitializer(obj, "rateLimit"), readOptionalRateLimit, methodName, sourceFile, "rateLimit must be { per, limit, windowSeconds } or { disabled: true, reason }, or a reference to one");
177
178
  if (rateLimitResult.kind === "error")
178
179
  return rateLimitResult;
180
+ const additionalRateLimitsResult = readHeaderField(readObjectPropertyInitializer(obj, "additionalRateLimits"), readOptionalAdditionalRateLimits, methodName, sourceFile, "additionalRateLimits must be an array of { per: { payloadField }, limit, windowSeconds }, or a reference to one");
181
+ if (additionalRateLimitsResult.kind === "error")
182
+ return additionalRateLimitsResult;
179
183
  const escapeHatchResult = readHeaderField(readObjectPropertyInitializer(obj, "escapeHatch"), readOptionalEscapeHatch, methodName, sourceFile, "escapeHatch must be { reason: string }, or a reference to one");
180
184
  if (escapeHatchResult.kind === "error")
181
185
  return escapeHatchResult;
@@ -185,6 +189,9 @@ function readHandlerHeaderFields(obj, methodName, sourceFile) {
185
189
  return ok({
186
190
  ...(accessResult.pattern !== undefined && { access: accessResult.pattern }),
187
191
  ...(rateLimitResult.pattern !== undefined && { rateLimit: rateLimitResult.pattern }),
192
+ ...(additionalRateLimitsResult.pattern !== undefined && {
193
+ additionalRateLimits: additionalRateLimitsResult.pattern,
194
+ }),
188
195
  ...(escapeHatchResult.pattern !== undefined && { escapeHatch: escapeHatchResult.pattern }),
189
196
  ...(agentResult.pattern !== undefined && { agent: agentResult.pattern }),
190
197
  });
@@ -307,6 +314,9 @@ export function extractWriteHandler(call, sourceFile) {
307
314
  ...(parsed.pattern.description !== undefined && { description: parsed.pattern.description }),
308
315
  ...(parsed.pattern.agent !== undefined && { agent: parsed.pattern.agent }),
309
316
  ...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
317
+ ...(parsed.pattern.additionalRateLimits !== undefined && {
318
+ additionalRateLimits: parsed.pattern.additionalRateLimits,
319
+ }),
310
320
  ...(parsed.pattern.unsafeSkipTransitionGuard === true && { unsafeSkipTransitionGuard: true }),
311
321
  ...(parsed.pattern.escapeHatch !== undefined && { escapeHatch: parsed.pattern.escapeHatch }),
312
322
  });
@@ -1,6 +1,6 @@
1
1
  import type { CallExpression, Node, SourceFile } from "ts-morph";
2
2
  import type { LifecycleHookType } from "../../constants.js";
3
- import type { AccessRule, EscapeHatchDeclaration, RateLimitDeclaration } from "../../types/handlers.js";
3
+ import type { AccessRule, EscapeHatchDeclaration, PayloadRateLimitOption, RateLimitDeclaration } from "../../types/handlers.js";
4
4
  import type { HookPhase } from "../../types/hooks.js";
5
5
  import type { AuthClaimsPattern, HookPattern } from "../patterns.js";
6
6
  import { type ExtractOutput, type RawRefSentinel } from "./shared.js";
@@ -18,6 +18,7 @@ export declare function isHookType(value: string): value is LifecycleHookType |
18
18
  export declare function readOptionalPhase(node: Node | undefined): HookPhase | undefined;
19
19
  export declare function readOptionalAccessRule(value: unknown): AccessRule | undefined;
20
20
  export declare function readOptionalRateLimit(value: unknown): RateLimitDeclaration | undefined;
21
+ export declare function readOptionalAdditionalRateLimits(value: unknown): readonly PayloadRateLimitOption[] | undefined;
21
22
  export declare function readOptionalEscapeHatch(value: unknown): EscapeHatchDeclaration | undefined;
22
23
  export declare function extractHook(call: CallExpression, sourceFile: SourceFile): ExtractOutput<HookPattern>;
23
24
  export declare function extractAuthClaims(call: CallExpression, sourceFile: SourceFile): ExtractOutput<AuthClaimsPattern>;
@@ -71,6 +71,20 @@ export function readOptionalRateLimit(value) {
71
71
  return undefined;
72
72
  return value;
73
73
  }
74
+ function isPayloadRateLimitOption(value) {
75
+ if (!isPlainObject(value))
76
+ return false;
77
+ const per = value["per"];
78
+ return (isPlainObject(per) &&
79
+ typeof per["payloadField"] === "string" &&
80
+ typeof value["limit"] === "number" &&
81
+ typeof value["windowSeconds"] === "number");
82
+ }
83
+ export function readOptionalAdditionalRateLimits(value) {
84
+ if (!Array.isArray(value) || !value.every(isPayloadRateLimitOption))
85
+ return undefined;
86
+ return value;
87
+ }
74
88
  export function readOptionalEscapeHatch(value) {
75
89
  if (!isPlainObject(value))
76
90
  return undefined;
@@ -1,7 +1,7 @@
1
1
  export { extractAiClassify, extractAiExtract, extractAiGenerate, } from "./ai-steps.js";
2
2
  export { extractDefineEvent, extractNotification, } from "./events.js";
3
3
  export { extractQueryHandler, extractStreamHandler, extractWriteHandler, type ParsedHandlerCall, parseHandlerCall, } from "./handlers.js";
4
- export { extractAuthClaims, extractHook, isHookType, readOptionalAccessRule, readOptionalPhase, readOptionalRateLimit, } from "./hooks.js";
4
+ export { extractAuthClaims, extractHook, isHookType, readOptionalAccessRule, readOptionalAdditionalRateLimits, readOptionalPhase, readOptionalRateLimit, } from "./hooks.js";
5
5
  export { extractHttpRoute, extractJob, isHttpRouteMethod, } from "./jobs-routes.js";
6
6
  export { collectScreenOpaqueProps, extractMultiStreamProjection, extractProjection, extractScreen, readApplyBodies, readScreenStatic, } from "./projections-screens.js";
7
7
  export { findImportBindingForLocalName, resolveModuleFile, } from "./resolve-import.js";
@@ -1,7 +1,7 @@
1
1
  export { extractAiClassify, extractAiExtract, extractAiGenerate, } from "./ai-steps.js";
2
2
  export { extractDefineEvent, extractNotification, } from "./events.js";
3
3
  export { extractQueryHandler, extractStreamHandler, extractWriteHandler, parseHandlerCall, } from "./handlers.js";
4
- export { extractAuthClaims, extractHook, isHookType, readOptionalAccessRule, readOptionalPhase, readOptionalRateLimit, } from "./hooks.js";
4
+ export { extractAuthClaims, extractHook, isHookType, readOptionalAccessRule, readOptionalAdditionalRateLimits, readOptionalPhase, readOptionalRateLimit, } from "./hooks.js";
5
5
  export { extractHttpRoute, extractJob, isHttpRouteMethod, } from "./jobs-routes.js";
6
6
  export { collectScreenOpaqueProps, extractMultiStreamProjection, extractProjection, extractScreen, readApplyBodies, readScreenStatic, } from "./projections-screens.js";
7
7
  export { findImportBindingForLocalName, resolveModuleFile, } from "./resolve-import.js";
@@ -180,6 +180,7 @@ const WRITE_HANDLER_HEADER_KEY_FLAGS = {
180
180
  description: true,
181
181
  agent: true,
182
182
  rateLimit: true,
183
+ additionalRateLimits: true,
183
184
  unsafeSkipTransitionGuard: true,
184
185
  escapeHatch: true,
185
186
  };
@@ -195,6 +195,16 @@ export declare const PATTERN_SCHEMAS_BY_KIND: {
195
195
  }, z.core.$strict>]>, z.ZodObject<{
196
196
  __raw: z.ZodString;
197
197
  }, z.core.$strict>]>>;
198
+ additionalRateLimits: z.ZodOptional<z.ZodUnion<readonly [z.ZodArray<z.ZodObject<{
199
+ per: z.ZodObject<{
200
+ payloadField: z.ZodString;
201
+ }, z.core.$strict>;
202
+ limit: z.ZodNumber;
203
+ windowSeconds: z.ZodNumber;
204
+ cost: z.ZodOptional<z.ZodNumber>;
205
+ }, z.core.$strict>>, z.ZodObject<{
206
+ __raw: z.ZodString;
207
+ }, z.core.$strict>]>>;
198
208
  escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
199
209
  reason: z.ZodString;
200
210
  grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
@@ -707,6 +717,16 @@ export declare const patternSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
707
717
  }, z.core.$strict>]>, z.ZodObject<{
708
718
  __raw: z.ZodString;
709
719
  }, z.core.$strict>]>>;
720
+ additionalRateLimits: z.ZodOptional<z.ZodUnion<readonly [z.ZodArray<z.ZodObject<{
721
+ per: z.ZodObject<{
722
+ payloadField: z.ZodString;
723
+ }, z.core.$strict>;
724
+ limit: z.ZodNumber;
725
+ windowSeconds: z.ZodNumber;
726
+ cost: z.ZodOptional<z.ZodNumber>;
727
+ }, z.core.$strict>>, z.ZodObject<{
728
+ __raw: z.ZodString;
729
+ }, z.core.$strict>]>>;
710
730
  escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
711
731
  reason: z.ZodString;
712
732
  grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
@@ -157,6 +157,14 @@ const rateLimitOptionSchema = z
157
157
  cost: z.number().optional(),
158
158
  })
159
159
  .strict();
160
+ const additionalRateLimitsSchema = z.array(z
161
+ .object({
162
+ per: z.object({ payloadField: z.string() }).strict(),
163
+ limit: z.number(),
164
+ windowSeconds: z.number(),
165
+ cost: z.number().optional(),
166
+ })
167
+ .strict());
160
168
  const agentHandlerHintsSchema = z
161
169
  .object({
162
170
  expose: z.boolean().optional(),
@@ -192,6 +200,7 @@ const WRITE_HANDLER_HEADER_SHAPE = {
192
200
  description: z.string(),
193
201
  agent: agentHandlerHintsSchema,
194
202
  rateLimit: rateLimitOptionSchema,
203
+ additionalRateLimits: additionalRateLimitsSchema,
195
204
  unsafeSkipTransitionGuard: z.boolean(),
196
205
  escapeHatch: escapeHatchSchema,
197
206
  };
@@ -238,6 +247,7 @@ const WRITE_HANDLER_PATTERN_HEADER_SHAPE = {
238
247
  access: orRawRef(accessRuleSchema),
239
248
  agent: orRawRef(agentHandlerHintsSchema),
240
249
  rateLimit: orRawRef(z.union([rateLimitOptionSchema, rateLimitDisabledSchema])),
250
+ additionalRateLimits: orRawRef(additionalRateLimitsSchema),
241
251
  escapeHatch: orRawRef(escapeHatchSchema),
242
252
  };
243
253
  const QUERY_HANDLER_PATTERN_HEADER_SHAPE = {
@@ -2,7 +2,7 @@ import type { LifecycleHookType } from "../constants.js";
2
2
  import type { ConfigKeyDefinition, ConfigKeyType, JobDefinition, ReferenceDataDef, RunIn, TranslationKeys } from "../types/config.js";
3
3
  import type { MetricOptions, SecretNamespaceOptions, SecretOptions } from "../types/feature.js";
4
4
  import type { EntityDefinition } from "../types/fields.js";
5
- import type { AccessRule, AgentHandlerHints, ClaimKeyType, EscapeHatchDeclaration, RateLimitDeclaration } from "../types/handlers.js";
5
+ import type { AccessRule, AgentHandlerHints, ClaimKeyType, EscapeHatchDeclaration, PayloadRateLimitOption, RateLimitDeclaration } from "../types/handlers.js";
6
6
  import type { HookPhase } from "../types/hooks.js";
7
7
  import type { HttpRouteMethod } from "../types/http-route.js";
8
8
  import type { NavDefinition } from "../types/nav.js";
@@ -142,6 +142,7 @@ export type WriteHandlerPattern = {
142
142
  readonly description?: string;
143
143
  readonly agent?: AgentHandlerHints | RawRefSentinel;
144
144
  readonly rateLimit?: RateLimitDeclaration | RawRefSentinel;
145
+ readonly additionalRateLimits?: readonly PayloadRateLimitOption[] | RawRefSentinel;
145
146
  readonly unsafeSkipTransitionGuard?: boolean;
146
147
  readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
147
148
  };
@@ -358,6 +358,9 @@ function renderWriteHandler(p) {
358
358
  lines.push(` agent: ${renderValue(p.agent)},`);
359
359
  if (p.rateLimit !== undefined)
360
360
  lines.push(` rateLimit: ${renderValue(p.rateLimit)},`);
361
+ if (p.additionalRateLimits !== undefined) {
362
+ lines.push(` additionalRateLimits: ${renderValue(p.additionalRateLimits)},`);
363
+ }
361
364
  if (p.unsafeSkipTransitionGuard === true)
362
365
  lines.push(" unsafeSkipTransitionGuard: true,");
363
366
  if (p.escapeHatch !== undefined)