@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.
- package/package.json +4 -4
- package/src/api/__tests__/extra-route-rejection.test.ts +38 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
- package/src/api/__tests__/server-boot-guards.test.ts +1 -0
- package/src/api/__tests__/server-error-logging.test.ts +104 -0
- package/src/api/api-constants.ts +13 -0
- package/src/api/extra-route.ts +33 -4
- package/src/api/index.ts +1 -0
- package/src/api/request-context.ts +5 -4
- package/src/api/routes.ts +26 -1
- package/src/api/server.ts +8 -2
- package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
- package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
- package/src/bun-db/query.ts +42 -18
- package/src/changes.json +108 -0
- package/src/db/__tests__/pg-error.test.ts +14 -0
- package/src/db/__tests__/system-db-view-export.test.ts +107 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
- package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
- package/src/db/event-store-executor-write.ts +7 -0
- package/src/db/index.ts +1 -1
- package/src/db/pg-error.ts +13 -0
- package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
- package/src/db/tenant-db.ts +140 -16
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
- package/src/engine/__tests__/boot-validator.test.ts +1 -1
- package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
- package/src/engine/boot-validator/access-declarations.ts +5 -66
- package/src/engine/extension-names.ts +55 -25
- package/src/engine/extensions/storage-provider.ts +14 -41
- package/src/engine/extensions/tenant-data.ts +4 -0
- package/src/engine/extensions/tenant-resource.ts +40 -0
- package/src/engine/extensions/user-data.ts +8 -7
- package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
- package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
- package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
- package/src/engine/feature-ast/entity-field-types.ts +41 -0
- package/src/engine/feature-ast/extractors/handlers.ts +217 -84
- package/src/engine/feature-ast/extractors/hooks.ts +72 -15
- package/src/engine/feature-ast/extractors/round2.ts +21 -0
- package/src/engine/feature-ast/extractors/shared.ts +9 -0
- package/src/engine/feature-ast/index.ts +11 -1
- package/src/engine/feature-ast/patch.ts +338 -5
- package/src/engine/feature-ast/patcher.ts +2 -2
- package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
- package/src/engine/feature-ast/patterns.ts +22 -15
- package/src/engine/feature-ast/render.ts +1 -0
- package/src/engine/feature-ui-extensions.ts +8 -7
- package/src/engine/index.ts +23 -5
- package/src/engine/personal-data-fields.ts +66 -0
- package/src/engine/registry-validate.ts +15 -0
- package/src/engine/registry.ts +2 -0
- package/src/engine/types/extension-options-map.ts +1 -0
- package/src/engine/types/index.ts +8 -0
- package/src/env/__tests__/dry-run.test.ts +43 -3
- package/src/env/dry-run.ts +28 -15
- package/src/errors/__tests__/write-failures.test.ts +47 -4
- package/src/errors/i18n/de.yaml +12 -0
- package/src/errors/i18n/en.yaml +12 -0
- package/src/errors/reasons.ts +4 -0
- package/src/errors/write-error-info.ts +12 -3
- package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -0
- package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
- package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +38 -3
- package/src/jobs/job-runner.ts +170 -19
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
- package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
- package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
- package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
- package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +425 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
- package/src/pipeline/active-membership.ts +5 -1
- package/src/pipeline/dispatch-batch.ts +59 -13
- package/src/pipeline/dispatch-query.ts +16 -5
- package/src/pipeline/dispatch-shared.ts +12 -5
- package/src/pipeline/dispatch-stream.ts +7 -2
- package/src/pipeline/dispatch-write.ts +22 -5
- package/src/pipeline/dispatcher.ts +9 -2
- package/src/pipeline/idempotency.ts +16 -0
- package/src/pipeline/member-reader.ts +3 -1
- package/src/pipeline/system-identity-switch.ts +22 -4
- package/src/pipeline/write-origin.ts +107 -0
- package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
- package/src/rate-limit/middleware.ts +3 -0
- package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
- package/src/stack/test-stack.ts +5 -0
- package/src/testing/closed-connection-error.ts +62 -0
- package/src/testing/index.ts +1 -0
- 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
|
-
|
|
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 {
|
|
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?:
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
208
|
-
...(
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
//
|
|
317
|
-
//
|
|
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 {
|
|
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):
|
|
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
|
|
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
|
-
//
|
|
74
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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
|
-
...
|
|
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
|
|
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
|
-
...
|
|
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) {
|