@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
@@ -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)
@@ -3,7 +3,7 @@ import type { ZodType } from "zod";
3
3
  import { type QueryHandlerDefinition, type StreamHandlerDefinition, type WriteHandlerDefinition } from "./define-handler.js";
4
4
  import type { RegisterEntityCrudOptions } from "./entity-handlers.js";
5
5
  import type { FeatureBuilderState } from "./feature-builder-state.js";
6
- import type { AccessRule, AgentHandlerHints, EntityDefinition, EntityRef, EscapeHatchDeclaration, HandlerRef, NameOrRef, QueryHandlerFn, RateLimitDeclaration, RelationDefinition, StreamHandlerFn, WriteHandlerFn } from "./types/index.js";
6
+ import type { AccessRule, AgentHandlerHints, EntityDefinition, EntityRef, EscapeHatchDeclaration, HandlerRef, NameOrRef, PayloadRateLimitOption, QueryHandlerFn, RateLimitDeclaration, RelationDefinition, StreamHandlerFn, WriteHandlerFn } from "./types/index.js";
7
7
  export declare function buildEntityHandlerMethods<TName extends string>(state: FeatureBuilderState, name: TName): {
8
8
  entity(nameOrDefinition: string | ({
9
9
  readonly name: string;
@@ -17,6 +17,7 @@ export declare function buildEntityHandlerMethods<TName extends string>(state: F
17
17
  writeHandler<TName_1 extends string, TSchema extends ZodType>(nameOrDef: string | WriteHandlerDefinition<TName_1, TSchema>, schema?: TSchema, handler?: WriteHandlerFn<z.infer<TSchema>>, options?: {
18
18
  access: AccessRule;
19
19
  rateLimit?: RateLimitDeclaration;
20
+ additionalRateLimits?: readonly PayloadRateLimitOption[];
20
21
  description?: string;
21
22
  agent?: AgentHandlerHints;
22
23
  escapeHatch?: EscapeHatchDeclaration;
@@ -105,6 +105,7 @@ export function buildEntityHandlerMethods(state, name) {
105
105
  ...agentSlots(def),
106
106
  ...(def.unsafeSkipTransitionGuard && { unsafeSkipTransitionGuard: true }),
107
107
  ...(def.rateLimit && { rateLimit: def.rateLimit }),
108
+ ...(def.additionalRateLimits && { additionalRateLimits: def.additionalRateLimits }),
108
109
  ...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
109
110
  ...(def.tenantlessAnonymous && { tenantlessAnonymous: true }),
110
111
  // Forward the pipeline-build closure so boot-validators and
@@ -129,6 +130,9 @@ export function buildEntityHandlerMethods(state, name) {
129
130
  access: inline.options.access,
130
131
  ...agentSlots(inline.options),
131
132
  ...(inline.options.rateLimit && { rateLimit: inline.options.rateLimit }),
133
+ ...(inline.options.additionalRateLimits && {
134
+ additionalRateLimits: inline.options.additionalRateLimits,
135
+ }),
132
136
  ...(inline.options.escapeHatch && { escapeHatch: inline.options.escapeHatch }),
133
137
  };
134
138
  tryMapEntity(state, name, nameOrDef);
@@ -421,6 +421,15 @@ export function buildUiExtensionsMethods(state, name) {
421
421
  throw new Error(`[Feature ${name}] httpRoute "${definition.method} ${definition.path}" must declare ` +
422
422
  `anonymous: true | false — true mounts it public, false behind the session auth chain.`);
423
423
  }
424
+ const routeLimit = definition.rateLimit;
425
+ if (routeLimit !== undefined &&
426
+ (!Number.isInteger(routeLimit.limit) ||
427
+ routeLimit.limit < 1 ||
428
+ !Number.isInteger(routeLimit.windowSeconds) ||
429
+ routeLimit.windowSeconds < 1)) {
430
+ throw new Error(`[Feature ${name}] httpRoute "${definition.method} ${definition.path}" rateLimit needs ` +
431
+ `positive integer limit and windowSeconds.`);
432
+ }
424
433
  const key = `${definition.method} ${definition.path}`;
425
434
  if (state.httpRoutes[key]) {
426
435
  throw new Error(`[Feature ${name}] HTTP-Route "${key}" already registered. ` +
@@ -74,7 +74,7 @@ export { WORKFLOW_SYSTEM_EVENT_DEFS } from "./steps/workflow-system-events.js";
74
74
  export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
75
75
  export { type EffectiveFeaturesResolver, findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin, type TrialGate, } from "./tier-resolver-extension.js";
76
76
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
77
- export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DateFieldDef, DecimalFieldDef, DeleteContext, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EntityDefinition, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, NotifyFactory, NotifyFn, NotifyOptions, NotifyPriority, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitPer, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyDefinition, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TenantId, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.js";
77
+ export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DateFieldDef, DecimalFieldDef, DeleteContext, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EntityDefinition, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, NotifyDelivery, NotifyDeliveryStatus, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitPer, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyDefinition, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TenantId, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.js";
78
78
  export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, HookPhases, isAgentVisibleScreen, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
79
79
  export type { AwaitedEventType, PipelineBuildCtx, PipelineCtx, PipelineDef, StepBuilder, StepDef, StepFailureStrategy, StepInstance, StepKind, StepNamespace, StepResolver, } from "./types/step.js";
80
80
  export { runValidation } from "./validation.js";
@@ -10,6 +10,7 @@
10
10
  // matching strip (anonymous/all only) to globalRoles, where SystemAdmin
11
11
  // legitimately lives.
12
12
  import { access } from "./config-helpers.js";
13
+ import { SYSTEM_ROLE } from "./system-user.js";
13
14
  export const FORBIDDEN_MEMBERSHIP_ROLES = new Set([
14
15
  ...access.privileged, // system, SystemAdmin
15
16
  ...access.all, // all
@@ -26,10 +27,13 @@ export function findForbiddenMembershipRole(roles) {
26
27
  export function stripForbiddenMembershipRoles(roles) {
27
28
  return roles.filter((role) => !isForbiddenMembershipRole(role));
28
29
  }
29
- // "anonymous"/"all" are never legitimate global roles; system/SystemAdmin stay.
30
+ // "anonymous"/"all" are never legitimate global roles; SystemAdmin stays.
31
+ // "system" is the in-process identity (system-identity-switch treats any user
32
+ // carrying it as System) and must never end up in a session.
30
33
  const NON_MINTABLE_GLOBAL_ROLES = new Set([
31
34
  ...access.all,
32
35
  ...access.anonymous,
36
+ SYSTEM_ROLE,
33
37
  ]);
34
38
  function stripNonMintableGlobalRoles(roles) {
35
39
  return roles.filter((role) => !NON_MINTABLE_GLOBAL_ROLES.has(role));
@@ -78,6 +78,11 @@ export const writeHandlerSchema = {
78
78
  label: { en: "Rate limit", de: "Rate-Limit" },
79
79
  input: "json-readonly",
80
80
  },
81
+ {
82
+ path: "additionalRateLimits",
83
+ label: { en: "Per-payload rate limits", de: "Payload-Rate-Limits" },
84
+ input: "json-readonly",
85
+ },
81
86
  {
82
87
  path: "unsafeSkipTransitionGuard",
83
88
  label: { en: "Skip transition guard", de: "Übergangs-Guard überspringen" },
@@ -43,7 +43,6 @@ export function buildImplicitProjection(featureName, entityName, entity, qualify
43
43
  // r.extendEntityProjection: merge extension applies into the rebuild
44
44
  // replay. Collisions with lifecycle applies (or another extension) are
45
45
  // authoring bugs — fail at boot, not by silently overwriting a handler.
46
- const extraSources = [];
47
46
  for (const ext of extensions) {
48
47
  for (const [eventType, fn] of Object.entries(ext.apply)) {
49
48
  if (apply[eventType]) {
@@ -52,20 +51,54 @@ export function buildImplicitProjection(featureName, entityName, entity, qualify
52
51
  }
53
52
  apply[eventType] = fn;
54
53
  }
55
- for (const s of ext.sources ?? []) {
56
- if (s !== entityName && !extraSources.includes(s))
57
- extraSources.push(s);
58
- }
59
54
  }
55
+ const { extraSources, extraSourceRowIds } = collectExtensionSources(name, entityName, extensions);
60
56
  return {
61
57
  name,
62
58
  source: entityName,
63
59
  ...(extraSources.length > 0 && { extraSources }),
60
+ ...(Object.keys(extraSourceRowIds).length > 0 && { extraSourceRowIds }),
64
61
  table: drizzleTable,
65
62
  apply,
66
63
  isImplicit: true,
67
64
  };
68
65
  }
66
+ function assertRowIdOfSources(projectionName, entityName, ext) {
67
+ // skip: extension maps no rowIdOf, nothing to validate
68
+ if (ext.rowIdOf === undefined)
69
+ return;
70
+ const sources = ext.sources ?? [];
71
+ if (sources.length === 0) {
72
+ throw new Error(`Implicit projection "${projectionName}": extendEntityProjection declares rowIdOf without ` +
73
+ `\`sources\` — rowIdOf maps events of the declared sources to row ids.`);
74
+ }
75
+ if (sources.includes(entityName)) {
76
+ throw new Error(`Implicit projection "${projectionName}": extendEntityProjection rowIdOf cannot apply to the ` +
77
+ `entity's own stream "${entityName}" — host-stream rows are keyed by the aggregate id.`);
78
+ }
79
+ }
80
+ function collectExtensionSources(projectionName, entityName, extensions) {
81
+ const extraSources = [];
82
+ const extraSourceRowIds = {};
83
+ const rowIdDeclaredBy = new Map();
84
+ for (const ext of extensions) {
85
+ for (const s of ext.sources ?? []) {
86
+ if (s !== entityName && !extraSources.includes(s))
87
+ extraSources.push(s);
88
+ }
89
+ assertRowIdOfSources(projectionName, entityName, ext);
90
+ for (const s of ext.sources ?? []) {
91
+ if (rowIdDeclaredBy.has(s) && rowIdDeclaredBy.get(s) !== ext.rowIdOf) {
92
+ throw new Error(`Implicit projection "${projectionName}": source "${s}" is declared by several extensions with ` +
93
+ `different or missing rowIdOf — the row id for its events is ambiguous.`);
94
+ }
95
+ rowIdDeclaredBy.set(s, ext.rowIdOf);
96
+ if (ext.rowIdOf !== undefined)
97
+ extraSourceRowIds[s] = ext.rowIdOf;
98
+ }
99
+ }
100
+ return { extraSources, extraSourceRowIds };
101
+ }
69
102
  // Validates a r.entity backing table is a superset of the entity's field-
70
103
  // derived columns, then hands it back as the projection table. The cast is a
71
104
  // system-boundary reconstitution: the table is stored as `unknown` on
@@ -655,6 +655,8 @@ export function computeHasRateLimitedHandler(state) {
655
655
  for (const h of state.writeHandlerMap.values()) {
656
656
  if (h.rateLimit !== undefined && !isRateLimitDisabled(h.rateLimit))
657
657
  return true;
658
+ if (h.additionalRateLimits !== undefined)
659
+ return true;
658
660
  }
659
661
  for (const h of state.queryHandlerMap.values()) {
660
662
  if (h.rateLimit !== undefined && !isRateLimitDisabled(h.rateLimit))
@@ -1,9 +1,11 @@
1
1
  import type { ConfigKeyDefinition, JobDefinition, JobHandlerFn } from "./types/config.js";
2
+ import type { EntityDefinition } from "./types/index.js";
2
3
  export declare const SOFT_DELETE_CLEANUP_JOB = "soft-delete:job:cleanup";
3
4
  export declare const SOFT_DELETE_CLEANUP_SYSTEM_JOB = "soft-delete:job:cleanup-system";
4
5
  export declare const SOFT_DELETE_GRACE_DAYS_KEY = "soft-delete:config:grace-days";
5
6
  export declare const DEFAULT_GRACE_DAYS = 30;
6
7
  export declare const softDeleteGraceDaysConfig: ConfigKeyDefinition;
8
+ export declare function isSystemScopedSoftDeleteTable(table: unknown, entity: Pick<EntityDefinition, "tenancy">): boolean;
7
9
  export declare const softDeleteCleanupJob: JobHandlerFn;
8
10
  export declare const softDeleteCleanupSystemJob: JobHandlerFn;
9
11
  export declare function buildSoftDeleteCleanupJob(): JobDefinition;
@@ -10,7 +10,7 @@
10
10
  // resurrect the row as isDeleted=true. That's acceptable: cleanup bounds LIVE
11
11
  // table growth; irreversible event-log purging is data-retention's job
12
12
  // (pruneEvents), a separate, consumer-lag-guarded path.
13
- import { deleteMany } from "../db/query.js";
13
+ import { asEntityTableMeta, deleteMany } from "../db/query.js";
14
14
  import { SYSTEM_USER_ID } from "./system-user.js";
15
15
  // qualifyEntityName convention (feature:type:kebab-name) with a reserved
16
16
  // "soft-delete" owner — no real feature owns these; the framework synthesizes
@@ -31,6 +31,18 @@ export const softDeleteGraceDaysConfig = {
31
31
  // existing silent-below-bound / audited-crossing behavior.
32
32
  bounds: { min: 1 },
33
33
  };
34
+ function hasTenantIdColumn(table) {
35
+ return table["tenantId"] !== undefined;
36
+ }
37
+ // The entity declaration decides, not the column: a `tenancy: "global"` table
38
+ // (e.g. user) still carries a tenantId column (SYSTEM_TENANT_ID), yet no
39
+ // tenant-mode delete can ever match its rows.
40
+ export function isSystemScopedSoftDeleteTable(table, entity) {
41
+ return entity.tenancy === "global" || !hasTenantIdColumn(table);
42
+ }
43
+ function isGlobalTenancyTable(table) {
44
+ return asEntityTableMeta(table)?.tenancy === "global";
45
+ }
34
46
  export const softDeleteCleanupJob = async (_payload, ctx) => {
35
47
  const { db, registry } = ctx;
36
48
  if (!db || !registry) {
@@ -57,21 +69,18 @@ export const softDeleteCleanupJob = async (_payload, ctx) => {
57
69
  const entity = registry.getEntity(proj.source);
58
70
  if (!entity?.softDelete)
59
71
  continue;
60
- // @cast-boundary column-presence probe — identical access the executor's
61
- // list() does on table["tenantId"] to decide tenant-scoping.
62
- const hasTenantColumn = proj.table["tenantId"] !== undefined;
63
- // System-global entities (no tenantId column, e.g. `user`) are NOT swept
72
+ // System-global entities (tenancy "global" or no tenantId column, e.g. `user`) are NOT swept
64
73
  // here: this handler runs once PER TENANT, so a tenant-scoped grace value
65
74
  // would purge every OTHER tenant's still-within-grace rows too (effective
66
75
  // grace = min() across all tenants). softDeleteCleanupSystemJob handles
67
76
  // these separately, once, with a fixed grace period.
68
- if (!hasTenantColumn)
77
+ if (isSystemScopedSoftDeleteTable(proj.table, entity))
69
78
  continue;
70
79
  const where = { isDeleted: true, deletedAt: { lt: cutoff }, tenantId };
71
80
  await deleteMany(db, proj.table, where);
72
81
  }
73
82
  };
74
- // System-global soft-deleted entities (no tenantId column) can't take a
83
+ // System-global soft-deleted entities (tenancy "global" or no tenantId column) can't take a
75
84
  // per-tenant grace period — there's no "this tenant's view" of a row that
76
85
  // isn't scoped to any tenant. Runs once (not perTenant) with a fixed grace
77
86
  // period; a future system-scope config key could make this configurable
@@ -88,9 +97,15 @@ export const softDeleteCleanupSystemJob = async (_payload, ctx) => {
88
97
  const entity = registry.getEntity(proj.source);
89
98
  if (!entity?.softDelete)
90
99
  continue;
91
- if (proj.table["tenantId"] !== undefined)
100
+ if (!isSystemScopedSoftDeleteTable(proj.table, entity))
92
101
  continue;
93
- await deleteMany(db, proj.table, { isDeleted: true, deletedAt: { lt: cutoff } });
102
+ const where = { isDeleted: true, deletedAt: { lt: cutoff } };
103
+ if (isGlobalTenancyTable(proj.table)) {
104
+ await db.global(proj.table).deleteMany(where);
105
+ }
106
+ else {
107
+ await deleteMany(db, proj.table, where);
108
+ }
94
109
  }
95
110
  };
96
111
  export function buildSoftDeleteCleanupJob() {
@@ -110,5 +125,9 @@ export function buildSoftDeleteCleanupSystemJob() {
110
125
  trigger: { cron: "15 3 * * *" },
111
126
  concurrency: "skip",
112
127
  runIn: "worker",
128
+ escapeHatch: {
129
+ reason: "hard-deletes expired soft-deleted rows of global entities (e.g. user) once, system-wide",
130
+ grants: ["globalWrites"],
131
+ },
113
132
  };
114
133
  }
@@ -1,3 +1,4 @@
1
+ import type { CacheSyncBus } from "@cosmicdrift/kumiko-types/cache-sync-types";
1
2
  import type { DbConnection } from "../db/connection.js";
2
3
  import type { RegistrarExtensionRegistration } from "./types/config.js";
3
4
  import type { FeatureDefinition, Registry } from "./types/feature.js";
@@ -44,6 +45,7 @@ export type TierResolverPlugin = {
44
45
  readonly build: (deps: {
45
46
  readonly db: DbConnection;
46
47
  readonly registry: Registry;
48
+ readonly cacheSync?: CacheSyncBus;
47
49
  }) => Promise<EffectiveFeaturesResolver>;
48
50
  };
49
51
  /**