@cosmicdrift/kumiko-framework 0.304.0 → 0.306.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 (92) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/extra-route-rejection.test.ts +38 -0
  3. package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
  4. package/src/api/__tests__/server-boot-guards.test.ts +1 -0
  5. package/src/api/__tests__/server-error-logging.test.ts +104 -0
  6. package/src/api/api-constants.ts +13 -0
  7. package/src/api/extra-route.ts +33 -4
  8. package/src/api/index.ts +1 -0
  9. package/src/api/request-context.ts +5 -4
  10. package/src/api/routes.ts +26 -1
  11. package/src/api/server.ts +8 -2
  12. package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
  13. package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
  14. package/src/bun-db/query.ts +42 -18
  15. package/src/changes.json +108 -0
  16. package/src/db/__tests__/pg-error.test.ts +14 -0
  17. package/src/db/__tests__/system-db-view-export.test.ts +107 -0
  18. package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
  19. package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
  20. package/src/db/event-store-executor-write.ts +7 -0
  21. package/src/db/index.ts +1 -1
  22. package/src/db/pg-error.ts +13 -0
  23. package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
  24. package/src/db/tenant-db.ts +140 -16
  25. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
  26. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  27. package/src/engine/__tests__/boot-validator.test.ts +1 -1
  28. package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
  29. package/src/engine/boot-validator/access-declarations.ts +5 -66
  30. package/src/engine/extension-names.ts +55 -25
  31. package/src/engine/extensions/storage-provider.ts +14 -41
  32. package/src/engine/extensions/tenant-data.ts +4 -0
  33. package/src/engine/extensions/tenant-resource.ts +40 -0
  34. package/src/engine/extensions/user-data.ts +8 -7
  35. package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
  36. package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
  37. package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
  38. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
  39. package/src/engine/feature-ast/entity-field-types.ts +41 -0
  40. package/src/engine/feature-ast/extractors/handlers.ts +217 -84
  41. package/src/engine/feature-ast/extractors/hooks.ts +72 -15
  42. package/src/engine/feature-ast/extractors/round2.ts +21 -0
  43. package/src/engine/feature-ast/extractors/shared.ts +9 -0
  44. package/src/engine/feature-ast/index.ts +11 -1
  45. package/src/engine/feature-ast/patch.ts +338 -5
  46. package/src/engine/feature-ast/patcher.ts +2 -2
  47. package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
  48. package/src/engine/feature-ast/patterns.ts +22 -15
  49. package/src/engine/feature-ast/render.ts +1 -0
  50. package/src/engine/feature-ui-extensions.ts +8 -7
  51. package/src/engine/index.ts +23 -5
  52. package/src/engine/personal-data-fields.ts +66 -0
  53. package/src/engine/registry-validate.ts +15 -0
  54. package/src/engine/registry.ts +2 -0
  55. package/src/engine/types/extension-options-map.ts +1 -0
  56. package/src/engine/types/index.ts +8 -0
  57. package/src/env/__tests__/dry-run.test.ts +43 -3
  58. package/src/env/dry-run.ts +28 -15
  59. package/src/errors/__tests__/write-failures.test.ts +47 -4
  60. package/src/errors/i18n/de.yaml +12 -0
  61. package/src/errors/i18n/en.yaml +12 -0
  62. package/src/errors/reasons.ts +4 -0
  63. package/src/errors/write-error-info.ts +12 -3
  64. package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -0
  65. package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
  66. package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
  67. package/src/jobs/__tests__/jobs.integration.test.ts +38 -3
  68. package/src/jobs/job-runner.ts +170 -19
  69. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
  70. package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
  71. package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
  72. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
  73. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +425 -0
  74. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
  75. package/src/pipeline/active-membership.ts +5 -1
  76. package/src/pipeline/dispatch-batch.ts +59 -13
  77. package/src/pipeline/dispatch-query.ts +16 -5
  78. package/src/pipeline/dispatch-shared.ts +12 -5
  79. package/src/pipeline/dispatch-stream.ts +7 -2
  80. package/src/pipeline/dispatch-write.ts +22 -5
  81. package/src/pipeline/dispatcher.ts +9 -2
  82. package/src/pipeline/idempotency.ts +16 -0
  83. package/src/pipeline/member-reader.ts +3 -1
  84. package/src/pipeline/system-identity-switch.ts +22 -4
  85. package/src/pipeline/write-origin.ts +107 -0
  86. package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
  87. package/src/rate-limit/middleware.ts +3 -0
  88. package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
  89. package/src/stack/test-stack.ts +5 -0
  90. package/src/testing/closed-connection-error.ts +62 -0
  91. package/src/testing/index.ts +1 -0
  92. package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
@@ -0,0 +1,41 @@
1
+ // Shared field-type validation for EntityDefinition. Used by both the
2
+ // static extractor (extractEntity, parse-time ParseError) and the runtime
3
+ // PatternChange validator (pattern-change-schema.ts) so the two never drift
4
+ // into reporting different "unknown field type" catalogues (#3137).
5
+
6
+ import { FIELD_TYPE_NAMES } from "@cosmicdrift/kumiko-types/fields";
7
+ import { isPlainObject, isRawRefSentinel } from "./extractors/shared";
8
+
9
+ export type UnknownEntityFieldType = {
10
+ readonly fieldName: string;
11
+ readonly type: string;
12
+ };
13
+
14
+ /**
15
+ * Scans `definition.fields.<name>.type` for values outside the FieldDefinition
16
+ * catalogue. Tolerant of RawRefSentinel at every level (definition itself,
17
+ * `fields`, a single field, or its `type`) — those are unresolvable source
18
+ * references, not validated field shapes, and readDataLiteralNode already
19
+ * chose not to fail the whole extraction over them.
20
+ */
21
+ export function findUnknownEntityFieldTypes(
22
+ definition: unknown,
23
+ ): readonly UnknownEntityFieldType[] {
24
+ if (isRawRefSentinel(definition) || !isPlainObject(definition)) return [];
25
+ const fields = definition["fields"];
26
+ if (isRawRefSentinel(fields) || !isPlainObject(fields)) return [];
27
+ const out: UnknownEntityFieldType[] = [];
28
+ for (const [fieldName, fieldDef] of Object.entries(fields)) {
29
+ if (isRawRefSentinel(fieldDef) || !isPlainObject(fieldDef)) continue;
30
+ const type = fieldDef["type"];
31
+ if (isRawRefSentinel(type) || typeof type !== "string") continue;
32
+ if (!(FIELD_TYPE_NAMES as readonly string[]).includes(type)) {
33
+ out.push({ fieldName, type });
34
+ }
35
+ }
36
+ return out;
37
+ }
38
+
39
+ export function describeUnknownFieldType(entry: UnknownEntityFieldType): string {
40
+ return `unknown field type "${entry.type}"; expected one of: ${FIELD_TYPE_NAMES.join(", ")}`;
41
+ }
@@ -1,3 +1,4 @@
1
+ import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
1
2
  import type { CallExpression, Node, ObjectLiteralExpression, SourceFile } from "ts-morph";
2
3
  import { SyntaxKind } from "ts-morph";
3
4
  import type {
@@ -5,12 +6,20 @@ import type {
5
6
  AgentHandlerHints,
6
7
  AgentRisk,
7
8
  EscapeHatchDeclaration,
8
- RateLimitOption,
9
+ QueryHandlerDef,
10
+ RateLimitDeclaration,
11
+ StreamHandlerDef,
12
+ WriteHandlerDef,
9
13
  } from "../../types/handlers";
10
14
  import type { QueryHandlerPattern, StreamHandlerPattern, WriteHandlerPattern } from "../patterns";
11
15
  import type { SourceLocation } from "../source-location";
12
16
  import { sourceLocationFromNode } from "../source-location";
13
- import { readOptionalAccessRule, readOptionalEscapeHatch, readOptionalRateLimit } from "./hooks";
17
+ import {
18
+ readHeaderValueOrRaw,
19
+ readOptionalAccessRule,
20
+ readOptionalEscapeHatch,
21
+ readOptionalRateLimit,
22
+ } from "./hooks";
14
23
  import {
15
24
  type ExtractOutput,
16
25
  fail,
@@ -18,9 +27,12 @@ import {
18
27
  isPlainObject,
19
28
  isRawRefSentinel,
20
29
  ok,
30
+ type RawRefSentinel,
21
31
  readBooleanProperty,
22
32
  readDataLiteralNode,
23
33
  readNameLiteral,
34
+ readObjectPropertyInitializer,
35
+ readPropertyKey,
24
36
  } from "./shared";
25
37
 
26
38
  export type ParsedHandlerCall = {
@@ -28,15 +40,15 @@ export type ParsedHandlerCall = {
28
40
  readonly handlerName?: string;
29
41
  readonly schemaSource?: SourceLocation;
30
42
  readonly handlerBody?: SourceLocation;
31
- readonly access?: AccessRule;
43
+ readonly access?: AccessRule | RawRefSentinel;
32
44
  readonly description?: string;
33
- readonly agent?: AgentHandlerHints;
34
- readonly rateLimit?: RateLimitOption;
45
+ readonly agent?: AgentHandlerHints | RawRefSentinel;
46
+ readonly rateLimit?: RateLimitDeclaration | RawRefSentinel;
35
47
  readonly unsafeSkipTransitionGuard?: boolean;
36
- readonly escapeHatch?: EscapeHatchDeclaration;
48
+ readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
37
49
  };
38
50
 
39
- const AGENT_RISK_VALUES: readonly AgentRisk[] = ["low", "mid", "high"];
51
+ export const AGENT_RISK_VALUES: readonly AgentRisk[] = ["low", "mid", "high"];
40
52
 
41
53
  function isAgentRisk(value: unknown): value is AgentRisk {
42
54
  return typeof value === "string" && (AGENT_RISK_VALUES as readonly string[]).includes(value);
@@ -53,55 +65,85 @@ export function readOptionalAgentHints(value: unknown): AgentHandlerHints | unde
53
65
  };
54
66
  }
55
67
 
56
- /**
57
- * Reads the two AI-agent-manifest slots (`description`, `agent`) off an
58
- * object-form handler-call literal. Factored out of parseHandlerCall to
59
- * keep that function's branching flat — both fields are optional and
60
- * independent of the rest of the header (access/rateLimit/schema/handler).
61
- */
62
- function readDescriptionAndAgent(
63
- obj: ObjectLiteralExpression,
64
- ): Pick<ParsedHandlerCall, "description" | "agent"> {
65
- const descriptionLiteral = obj
66
- .getProperty("description")
67
- ?.asKind(SyntaxKind.PropertyAssignment)
68
- ?.getInitializer()
69
- ?.asKind(SyntaxKind.StringLiteral);
70
- const agentInit = obj
71
- .getProperty("agent")
72
- ?.asKind(SyntaxKind.PropertyAssignment)
73
- ?.getInitializer();
74
- const agent = agentInit ? readOptionalAgentHints(readDataLiteralNode(agentInit)) : undefined;
75
- return {
76
- ...(descriptionLiteral !== undefined && { description: descriptionLiteral.getLiteralValue() }),
77
- ...(agent !== undefined && { agent }),
78
- };
68
+ type KeyClassification = "modeled" | "opaque";
69
+
70
+ // Record<keyof Def, ...> instead of a looser map: adding a field to
71
+ // WriteHandlerDef/QueryHandlerDef/StreamHandlerDef without classifying it
72
+ // here is a compile error, not a silent drop on render.
73
+ const WRITE_HANDLER_KEY_KINDS: Record<keyof WriteHandlerDef, KeyClassification> = {
74
+ name: "modeled",
75
+ schema: "modeled",
76
+ handler: "modeled",
77
+ access: "modeled",
78
+ description: "modeled",
79
+ agent: "modeled",
80
+ unsafeSkipTransitionGuard: "modeled",
81
+ rateLimit: "modeled",
82
+ escapeHatch: "modeled",
83
+ perform: "opaque",
84
+ };
85
+
86
+ const QUERY_HANDLER_KEY_KINDS: Record<keyof QueryHandlerDef, KeyClassification> = {
87
+ name: "modeled",
88
+ schema: "modeled",
89
+ handler: "modeled",
90
+ [ENTITY_CONVENTION_QUERY_BRAND]: "opaque",
91
+ access: "modeled",
92
+ description: "modeled",
93
+ agent: "modeled",
94
+ rateLimit: "modeled",
95
+ outputSchema: "opaque",
96
+ escapeHatch: "modeled",
97
+ };
98
+
99
+ const STREAM_HANDLER_KEY_KINDS: Record<keyof StreamHandlerDef, KeyClassification> = {
100
+ name: "modeled",
101
+ schema: "modeled",
102
+ handler: "modeled",
103
+ access: "modeled",
104
+ rateLimit: "modeled",
105
+ escapeHatch: "modeled",
106
+ };
107
+
108
+ const HANDLER_KEY_KINDS = {
109
+ writeHandler: WRITE_HANDLER_KEY_KINDS,
110
+ queryHandler: QUERY_HANDLER_KEY_KINDS,
111
+ streamHandler: STREAM_HANDLER_KEY_KINDS,
112
+ } as const;
113
+
114
+ function lookupClassification<T extends Record<string, KeyClassification>>(
115
+ map: T,
116
+ key: string,
117
+ ): KeyClassification | undefined {
118
+ return key in map ? map[key as keyof T] : undefined;
79
119
  }
80
120
 
81
- /**
82
- * Reads `access`/`rateLimit`/`description`/`agent` off the positional
83
- * 4th-argument options object (the inline-authoring form). Mirrors
84
- * readDescriptionAndAgent's role for the object-form branch — keeping
85
- * parseHandlerCall's positional branch to a single assignment instead of
86
- * four independent mutable locals.
87
- */
88
- function readOptionsFields(
89
- options: unknown,
90
- ): Pick<ParsedHandlerCall, "access" | "rateLimit" | "description" | "agent" | "escapeHatch"> {
91
- if (!isPlainObject(options)) return {};
92
- const access = readOptionalAccessRule(options["access"]);
93
- const rateLimit = readOptionalRateLimit(options["rateLimit"]);
94
- const description =
95
- typeof options["description"] === "string" ? options["description"] : undefined;
96
- const agent = readOptionalAgentHints(options["agent"]);
97
- const escapeHatch = readOptionalEscapeHatch(options["escapeHatch"]);
98
- return {
99
- ...(access !== undefined && { access }),
100
- ...(description !== undefined && { description }),
101
- ...(agent !== undefined && { agent }),
102
- ...(rateLimit !== undefined && { rateLimit }),
103
- ...(escapeHatch !== undefined && { escapeHatch }),
104
- };
121
+ // True when the call's object body/options carry a shape the extractor
122
+ // cannot losslessly model property-by-property (a spread, a method/accessor
123
+ // shorthand, a computed key, or a key outside the classification map above).
124
+ // Callers fall back to the opaque whole-call pattern instead of dropping
125
+ // whatever they can't read.
126
+ function hasUnmodeledShape(
127
+ obj: ObjectLiteralExpression,
128
+ methodName: "writeHandler" | "queryHandler" | "streamHandler",
129
+ ): boolean {
130
+ const keyKinds = HANDLER_KEY_KINDS[methodName];
131
+ for (const prop of obj.getProperties()) {
132
+ if (prop.getKind() === SyntaxKind.SpreadAssignment) return true;
133
+ const propAssign = prop.asKind(SyntaxKind.PropertyAssignment);
134
+ if (propAssign) {
135
+ if (propAssign.getNameNode().getKind() === SyntaxKind.ComputedPropertyName) return true;
136
+ if (lookupClassification(keyKinds, readPropertyKey(propAssign)) !== "modeled") return true;
137
+ continue;
138
+ }
139
+ const shorthand = prop.asKind(SyntaxKind.ShorthandPropertyAssignment);
140
+ if (shorthand) {
141
+ if (lookupClassification(keyKinds, shorthand.getName()) !== "modeled") return true;
142
+ continue;
143
+ }
144
+ return true;
145
+ }
146
+ return false;
105
147
  }
106
148
 
107
149
  /**
@@ -119,6 +161,92 @@ function resolveObjectLiteralArg(node: Node) {
119
161
  return varDecl?.getInitializer()?.asKind(SyntaxKind.ObjectLiteralExpression);
120
162
  }
121
163
 
164
+ // Deliberately does NOT resolve identifiers (unlike resolveObjectLiteralArg):
165
+ // an options argument authored as a bare reference has no per-property
166
+ // nodes to read, so parseHandlerCall falls back to the opaque whole-call
167
+ // pattern instead of trying to read headers off it.
168
+ function unwrapObjectLiteral(node: Node): ObjectLiteralExpression | undefined {
169
+ const direct = node.asKind(SyntaxKind.ObjectLiteralExpression);
170
+ if (direct) return direct;
171
+ const asExpr = node.asKind(SyntaxKind.AsExpression);
172
+ if (asExpr) return unwrapObjectLiteral(asExpr.getExpression());
173
+ const satisfiesExpr = node.asKind(SyntaxKind.SatisfiesExpression);
174
+ if (satisfiesExpr) return unwrapObjectLiteral(satisfiesExpr.getExpression());
175
+ const paren = node.asKind(SyntaxKind.ParenthesizedExpression);
176
+ if (paren) return unwrapObjectLiteral(paren.getExpression());
177
+ return undefined;
178
+ }
179
+
180
+ function readHeaderField<T>(
181
+ init: Node | undefined,
182
+ recognize: (value: unknown) => T | undefined,
183
+ methodName: "writeHandler" | "queryHandler" | "streamHandler",
184
+ sourceFile: SourceFile,
185
+ unrecognizedReason: string,
186
+ ): ExtractOutput<T | RawRefSentinel | undefined> {
187
+ if (!init) return ok(undefined);
188
+ const result = readHeaderValueOrRaw(init, recognize);
189
+ if (result.kind === "value") return ok<T | RawRefSentinel | undefined>(result.value);
190
+ if (result.kind === "raw") return ok<T | RawRefSentinel | undefined>(result.sentinel);
191
+ return fail(methodName, sourceLocationFromNode(init, sourceFile), unrecognizedReason);
192
+ }
193
+
194
+ type HandlerHeaderFields = Pick<
195
+ ParsedHandlerCall,
196
+ "access" | "rateLimit" | "escapeHatch" | "agent"
197
+ >;
198
+
199
+ // Shared by the object-form call body and the positional options object,
200
+ // which carry the same header shape.
201
+ function readHandlerHeaderFields(
202
+ obj: ObjectLiteralExpression,
203
+ methodName: "writeHandler" | "queryHandler" | "streamHandler",
204
+ sourceFile: SourceFile,
205
+ ): ExtractOutput<HandlerHeaderFields> {
206
+ const accessResult = readHeaderField(
207
+ readObjectPropertyInitializer(obj, "access"),
208
+ readOptionalAccessRule,
209
+ methodName,
210
+ sourceFile,
211
+ "access must be a recognized AccessRule ({ roles: [...] } or { openToAll: { reason } }), or a reference to one",
212
+ );
213
+ if (accessResult.kind === "error") return accessResult;
214
+
215
+ const rateLimitResult = readHeaderField(
216
+ readObjectPropertyInitializer(obj, "rateLimit"),
217
+ readOptionalRateLimit,
218
+ methodName,
219
+ sourceFile,
220
+ "rateLimit must be { per, limit, windowSeconds } or { disabled: true, reason }, or a reference to one",
221
+ );
222
+ if (rateLimitResult.kind === "error") return rateLimitResult;
223
+
224
+ const escapeHatchResult = readHeaderField(
225
+ readObjectPropertyInitializer(obj, "escapeHatch"),
226
+ readOptionalEscapeHatch,
227
+ methodName,
228
+ sourceFile,
229
+ "escapeHatch must be { reason: string }, or a reference to one",
230
+ );
231
+ if (escapeHatchResult.kind === "error") return escapeHatchResult;
232
+
233
+ const agentResult = readHeaderField(
234
+ readObjectPropertyInitializer(obj, "agent"),
235
+ readOptionalAgentHints,
236
+ methodName,
237
+ sourceFile,
238
+ 'agent must be { expose?: boolean, risk?: "low" | "mid" | "high" }, or a reference to one',
239
+ );
240
+ if (agentResult.kind === "error") return agentResult;
241
+
242
+ return ok({
243
+ ...(accessResult.pattern !== undefined && { access: accessResult.pattern }),
244
+ ...(rateLimitResult.pattern !== undefined && { rateLimit: rateLimitResult.pattern }),
245
+ ...(escapeHatchResult.pattern !== undefined && { escapeHatch: escapeHatchResult.pattern }),
246
+ ...(agentResult.pattern !== undefined && { agent: agentResult.pattern }),
247
+ });
248
+ }
249
+
122
250
  export function parseHandlerCall(
123
251
  call: CallExpression,
124
252
  sourceFile: SourceFile,
@@ -136,6 +264,9 @@ export function parseHandlerCall(
136
264
 
137
265
  const obj = args.length === 1 ? resolveObjectLiteralArg(first) : undefined;
138
266
  if (obj) {
267
+ if (hasUnmodeledShape(obj, methodName)) {
268
+ return ok({ source: sourceLocationFromNode(call, sourceFile) });
269
+ }
139
270
  const nameLiteral = obj
140
271
  .getProperty("name")
141
272
  ?.asKind(SyntaxKind.PropertyAssignment)
@@ -178,38 +309,24 @@ export function parseHandlerCall(
178
309
  "handler must be an inline arrow function or function expression",
179
310
  );
180
311
  }
181
- const accessInit = obj
182
- .getProperty("access")
312
+ const headerResult = readHandlerHeaderFields(obj, methodName, sourceFile);
313
+ if (headerResult.kind === "error") return headerResult;
314
+ const descriptionLiteral = obj
315
+ .getProperty("description")
183
316
  ?.asKind(SyntaxKind.PropertyAssignment)
184
- ?.getInitializer();
185
- const access = accessInit ? readOptionalAccessRule(readDataLiteralNode(accessInit)) : undefined;
186
- const rateLimitInit = obj
187
- .getProperty("rateLimit")
188
- ?.asKind(SyntaxKind.PropertyAssignment)
189
- ?.getInitializer();
190
- const rateLimit = rateLimitInit
191
- ? readOptionalRateLimit(readDataLiteralNode(rateLimitInit))
192
- : undefined;
193
- const { description, agent } = readDescriptionAndAgent(obj);
317
+ ?.getInitializer()
318
+ ?.asKind(SyntaxKind.StringLiteral);
194
319
  const skip = readBooleanProperty(obj, "unsafeSkipTransitionGuard");
195
- const escapeHatchInit = obj
196
- .getProperty("escapeHatch")
197
- ?.asKind(SyntaxKind.PropertyAssignment)
198
- ?.getInitializer();
199
- const escapeHatch = escapeHatchInit
200
- ? readOptionalEscapeHatch(readDataLiteralNode(escapeHatchInit))
201
- : undefined;
202
320
  return ok({
203
321
  source: sourceLocationFromNode(call, sourceFile),
204
322
  handlerName: nameLiteral.getLiteralValue(),
205
323
  schemaSource: sourceLocationFromNode(schemaInit, sourceFile),
206
324
  handlerBody: sourceLocationFromNode(fn, sourceFile),
207
- ...(access !== undefined && { access }),
208
- ...(description !== undefined && { description }),
209
- ...(agent !== undefined && { agent }),
210
- ...(rateLimit !== undefined && { rateLimit }),
325
+ ...headerResult.pattern,
326
+ ...(descriptionLiteral !== undefined && {
327
+ description: descriptionLiteral.getLiteralValue(),
328
+ }),
211
329
  ...(skip === true && { unsafeSkipTransitionGuard: true }),
212
- ...(escapeHatch !== undefined && { escapeHatch }),
213
330
  });
214
331
  }
215
332
 
@@ -256,13 +373,30 @@ export function parseHandlerCall(
256
373
  );
257
374
  }
258
375
  const optionsArg = args[3];
259
- const optionsFields = optionsArg ? readOptionsFields(readDataLiteralNode(optionsArg)) : {};
376
+ let headerFields: HandlerHeaderFields = {};
377
+ let description: string | undefined;
378
+ if (optionsArg) {
379
+ const optionsObj = unwrapObjectLiteral(optionsArg);
380
+ if (!optionsObj || hasUnmodeledShape(optionsObj, methodName)) {
381
+ return ok({ source: sourceLocationFromNode(call, sourceFile) });
382
+ }
383
+ const headerResult = readHandlerHeaderFields(optionsObj, methodName, sourceFile);
384
+ if (headerResult.kind === "error") return headerResult;
385
+ headerFields = headerResult.pattern;
386
+ description = optionsObj
387
+ .getProperty("description")
388
+ ?.asKind(SyntaxKind.PropertyAssignment)
389
+ ?.getInitializer()
390
+ ?.asKind(SyntaxKind.StringLiteral)
391
+ ?.getLiteralValue();
392
+ }
260
393
  return ok({
261
394
  source: sourceLocationFromNode(call, sourceFile),
262
395
  handlerName,
263
396
  schemaSource: sourceLocationFromNode(schemaArg, sourceFile),
264
397
  handlerBody: sourceLocationFromNode(fn, sourceFile),
265
- ...optionsFields,
398
+ ...headerFields,
399
+ ...(description !== undefined && { description }),
266
400
  });
267
401
  }
268
402
 
@@ -295,6 +429,7 @@ function readHandlerFields(parsed: Extract<ExtractOutput<ParsedHandlerCall>, { k
295
429
  handlerBody: parsed.pattern.handlerBody,
296
430
  ...(parsed.pattern.access !== undefined && { access: parsed.pattern.access }),
297
431
  ...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
432
+ ...(parsed.pattern.escapeHatch !== undefined && { escapeHatch: parsed.pattern.escapeHatch }),
298
433
  };
299
434
  }
300
435
 
@@ -309,13 +444,11 @@ export function extractQueryHandler(
309
444
  ...readHandlerFields(parsed),
310
445
  ...(parsed.pattern.description !== undefined && { description: parsed.pattern.description }),
311
446
  ...(parsed.pattern.agent !== undefined && { agent: parsed.pattern.agent }),
312
- ...(parsed.pattern.escapeHatch !== undefined && { escapeHatch: parsed.pattern.escapeHatch }),
313
447
  });
314
448
  }
315
449
 
316
- // streamHandler shares parseHandlerCall with writeHandler/queryHandler, but
317
- // StreamHandlerDef carries neither `description` nor `agent` at runtime —
318
- // readHandlerFields intentionally does not forward them here.
450
+ // StreamHandlerDef has no description/agent at runtime, unlike escapeHatch,
451
+ // so readHandlerFields forwards only access/rateLimit/escapeHatch.
319
452
  export function extractStreamHandler(
320
453
  call: CallExpression,
321
454
  sourceFile: SourceFile,
@@ -1,22 +1,50 @@
1
1
  import type { CallExpression, Node, SourceFile } from "ts-morph";
2
2
  import { SyntaxKind } from "ts-morph";
3
3
  import type { LifecycleHookType } from "../../constants";
4
- import type { AccessRule, EscapeHatchDeclaration, RateLimitOption } from "../../types/handlers";
4
+ import type {
5
+ AccessRule,
6
+ EscapeHatchDeclaration,
7
+ RateLimitDeclaration,
8
+ } from "../../types/handlers";
5
9
  import type { HookPhase } from "../../types/hooks";
6
10
  import type { AuthClaimsPattern, HookPattern } from "../patterns";
7
11
  import { sourceLocationFromNode } from "../source-location";
8
12
  import {
13
+ containsRawRefSentinel,
9
14
  type ExtractOutput,
10
15
  fail,
11
16
  findFunctionLiteral,
12
17
  isPlainObject,
13
18
  ok,
19
+ type RawRefSentinel,
14
20
  readDataLiteralNode,
15
21
  readNameLiteral,
16
22
  readNameOrRef,
17
23
  readNameOrRefOrList,
24
+ readObjectPropertyInitializer,
18
25
  } from "./shared";
19
26
 
27
+ export type HeaderReadResult<T> =
28
+ | { readonly kind: "value"; readonly value: T }
29
+ | { readonly kind: "raw"; readonly sentinel: RawRefSentinel }
30
+ | { readonly kind: "unrecognized" };
31
+
32
+ // Raw-sentinel check runs before recognize: recognize narrows literals and
33
+ // would silently drop a nested reference (e.g. roles array plus a
34
+ // non-literal personalData) that the whole-object narrowing can't see.
35
+ export function readHeaderValueOrRaw<T>(
36
+ init: Node,
37
+ recognize: (value: unknown) => T | undefined,
38
+ ): HeaderReadResult<T> {
39
+ const value = readDataLiteralNode(init);
40
+ if (value === undefined || containsRawRefSentinel(value)) {
41
+ return { kind: "raw", sentinel: { __raw: init.getText() } };
42
+ }
43
+ const recognized = recognize(value);
44
+ if (recognized === undefined) return { kind: "unrecognized" };
45
+ return { kind: "value", value: recognized };
46
+ }
47
+
20
48
  export function isHookType(value: string): value is LifecycleHookType | "validation" {
21
49
  return (
22
50
  value === "preSave" ||
@@ -56,12 +84,19 @@ export function readOptionalAccessRule(value: unknown): AccessRule | undefined {
56
84
  return undefined;
57
85
  }
58
86
 
59
- export function readOptionalRateLimit(value: unknown): RateLimitOption | undefined {
87
+ export function readOptionalRateLimit(value: unknown): RateLimitDeclaration | undefined {
60
88
  if (!isPlainObject(value)) return undefined;
89
+ if (value["disabled"] === true) {
90
+ // Strict shape: exactly { disabled: true, reason } — anything extra
91
+ // isn't RateLimitDisabled and falls through to "unrecognized".
92
+ if (typeof value["reason"] !== "string") return undefined;
93
+ if (Object.keys(value).length !== 2) return undefined;
94
+ return { disabled: true, reason: value["reason"] };
95
+ }
61
96
  if (typeof value["per"] !== "string") return undefined;
62
97
  if (typeof value["limit"] !== "number") return undefined;
63
98
  if (typeof value["windowSeconds"] !== "number") return undefined;
64
- return value as unknown as RateLimitOption;
99
+ return value as unknown as RateLimitDeclaration;
65
100
  }
66
101
 
67
102
  export function readOptionalEscapeHatch(value: unknown): EscapeHatchDeclaration | undefined {
@@ -70,17 +105,37 @@ export function readOptionalEscapeHatch(value: unknown): EscapeHatchDeclaration
70
105
  return { reason: value["reason"] };
71
106
  }
72
107
 
73
- // Extracts the `escapeHatch` sub-property node first, not via readDataLiteralNode on the whole object — a sibling property like `handler` (a closure) isn't representable as plain data, which would make the whole-object read return undefined.
74
- export function readOptionalHookEscapeHatch(
108
+ // Reads the `escapeHatch` sub-property node first, not via readDataLiteralNode
109
+ // on the whole object: a sibling property like `handler` (a closure) isn't
110
+ // representable as plain data, which would make the whole-object read
111
+ // return undefined. undefined means the property is absent, not an error.
112
+ function readOptionalHookEscapeHatch(
75
113
  node: Node | undefined,
76
- ): EscapeHatchDeclaration | undefined {
114
+ ): HeaderReadResult<EscapeHatchDeclaration> | undefined {
77
115
  const obj = node?.asKind(SyntaxKind.ObjectLiteralExpression);
78
116
  if (!obj) return undefined;
79
- const init = obj
80
- .getProperty("escapeHatch")
81
- ?.asKind(SyntaxKind.PropertyAssignment)
82
- ?.getInitializer();
83
- return init ? readOptionalEscapeHatch(readDataLiteralNode(init)) : undefined;
117
+ const init = readObjectPropertyInitializer(obj, "escapeHatch");
118
+ if (!init) return undefined;
119
+ return readHeaderValueOrRaw(init, readOptionalEscapeHatch);
120
+ }
121
+
122
+ // Resolves the hook's escapeHatch into either a field to spread into the
123
+ // pattern, or a ParseError for a fully literal but unrecognized shape.
124
+ function readHookEscapeHatch(
125
+ node: Node | undefined,
126
+ call: CallExpression,
127
+ sourceFile: SourceFile,
128
+ ): { readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel } | ReturnType<typeof fail> {
129
+ const result = readOptionalHookEscapeHatch(node);
130
+ if (!result) return {};
131
+ if (result.kind === "unrecognized") {
132
+ return fail(
133
+ "hook",
134
+ sourceLocationFromNode(call, sourceFile),
135
+ "escapeHatch must be { reason: string }, or a reference to one",
136
+ );
137
+ }
138
+ return { escapeHatch: result.kind === "value" ? result.value : result.sentinel };
84
139
  }
85
140
 
86
141
  // r.hook's target: a NameOrRef, a list of them, or an entity-wide
@@ -173,7 +228,8 @@ export function extractHook(
173
228
  );
174
229
  }
175
230
  const phase = readOptionalPhase(obj);
176
- const escapeHatch = readOptionalHookEscapeHatch(obj);
231
+ const escapeHatchOutcome = readHookEscapeHatch(obj, call, sourceFile);
232
+ if ("kind" in escapeHatchOutcome) return escapeHatchOutcome;
177
233
  return ok({
178
234
  kind: "hook",
179
235
  source: sourceLocationFromNode(call, sourceFile),
@@ -181,7 +237,7 @@ export function extractHook(
181
237
  target,
182
238
  fnBody: sourceLocationFromNode(fn, sourceFile),
183
239
  ...(phase !== undefined && { phase }),
184
- ...(escapeHatch !== undefined && { escapeHatch }),
240
+ ...escapeHatchOutcome,
185
241
  });
186
242
  }
187
243
 
@@ -233,7 +289,8 @@ export function extractHook(
233
289
  );
234
290
  }
235
291
  const phase = readOptionalPhase(args[3]);
236
- const escapeHatch = readOptionalHookEscapeHatch(args[3]);
292
+ const escapeHatchOutcome = readHookEscapeHatch(args[3], call, sourceFile);
293
+ if ("kind" in escapeHatchOutcome) return escapeHatchOutcome;
237
294
  return ok({
238
295
  kind: "hook",
239
296
  source: sourceLocationFromNode(call, sourceFile),
@@ -241,7 +298,7 @@ export function extractHook(
241
298
  target,
242
299
  fnBody: sourceLocationFromNode(fn, sourceFile),
243
300
  ...(phase !== undefined && { phase }),
244
- ...(escapeHatch !== undefined && { escapeHatch }),
301
+ ...escapeHatchOutcome,
245
302
  });
246
303
  }
247
304
 
@@ -4,6 +4,7 @@ import type { EntityDefinition } from "../../types/fields";
4
4
  import type { NavDefinition } from "../../types/nav";
5
5
  import type { RelationDefinition } from "../../types/relations";
6
6
  import type { WorkspaceDefinition } from "../../types/workspace";
7
+ import { describeUnknownFieldType, findUnknownEntityFieldTypes } from "../entity-field-types";
7
8
  import type { EntityPattern, NavPattern, RelationPattern, WorkspacePattern } from "../patterns";
8
9
  import { sourceLocationFromNode } from "../source-location";
9
10
  import {
@@ -53,10 +54,21 @@ export function extractEntity(
53
54
  );
54
55
  }
55
56
  const { name: _name, ...defWithoutName } = definition;
57
+ const unknownTypes = findUnknownEntityFieldTypes(defWithoutName);
58
+ const firstUnknown = unknownTypes[0];
59
+ if (firstUnknown) {
60
+ return fail(
61
+ "entity",
62
+ sourceLocationFromNode(call, sourceFile),
63
+ `definition.fields.${firstUnknown.fieldName}.type: ${describeUnknownFieldType(firstUnknown)}`,
64
+ );
65
+ }
56
66
  return ok({
57
67
  kind: "entity",
58
68
  source: sourceLocationFromNode(call, sourceFile),
59
69
  entityName: nameInit.getLiteralValue(),
70
+ // Field-type membership is checked above; the cast is the extractor's
71
+ // existing narrowing contract (readDataLiteralNode returns unknown).
60
72
  definition: defWithoutName as EntityDefinition,
61
73
  });
62
74
  }
@@ -85,6 +97,15 @@ export function extractEntity(
85
97
  "definition could not be read as a plain object (contains functions or identifiers)",
86
98
  );
87
99
  }
100
+ const unknownTypes = findUnknownEntityFieldTypes(definition);
101
+ const firstUnknown = unknownTypes[0];
102
+ if (firstUnknown) {
103
+ return fail(
104
+ "entity",
105
+ sourceLocationFromNode(call, sourceFile),
106
+ `definition.fields.${firstUnknown.fieldName}.type: ${describeUnknownFieldType(firstUnknown)}`,
107
+ );
108
+ }
88
109
  return ok({
89
110
  kind: "entity",
90
111
  source: sourceLocationFromNode(call, sourceFile),
@@ -66,6 +66,15 @@ export function isRawRefSentinel(value: unknown): value is RawRefSentinel {
66
66
  );
67
67
  }
68
68
 
69
+ // Deep check over arrays/plain objects, the only container shapes
70
+ // readDataLiteralNode produces.
71
+ export function containsRawRefSentinel(value: unknown): boolean {
72
+ if (isRawRefSentinel(value)) return true;
73
+ if (Array.isArray(value)) return value.some(containsRawRefSentinel);
74
+ if (isPlainObject(value)) return Object.values(value).some(containsRawRefSentinel);
75
+ return false;
76
+ }
77
+
69
78
  export function readDataLiteralNode(node: Node): unknown {
70
79
  const kind = node.getKind();
71
80
  switch (kind) {