@luckystack/devkit 0.4.1 → 0.5.1

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.
@@ -1,258 +1,258 @@
1
- # Runtime Type Resolver (`runtimeTypeResolver.ts`)
2
-
3
- > Dev-only. Lives in `@luckystack/devkit` and is consumed exclusively by `@luckystack/core/src/runtimeTypeValidation.ts` via a lazy `await import('@luckystack/devkit')` that fires only when `NODE_ENV !== 'production'`. Production servers never load this module — runtime input validation in prod uses the pre-generated Zod schemas in `apiInputSchemas.generated.ts`, not the deep resolver.
4
-
5
- The resolver bridges two worlds:
6
-
7
- 1. **What the loader stored.** When the dev loader (`loader.ts`) imports an API or sync file, it calls `getInputTypeFromFile(filePath)` (a public extractor) and stashes the result on `devApis[routeKey].inputType` along with `inputTypeFilePath`. The stored text is what the extractor produced at import time — usually a fully-expanded inline form, occasionally a named alias that the extractor could not expand standalone.
8
- 2. **What the runtime validator needs.** Each incoming request is type-checked against the stored `inputType`. If the stored text still contains identifiers (e.g. `UserInput`, `Partial<RegisterPayload>`, `Pick<Prisma.User, 'id' | 'name'>`), the validator cannot match payload keys to type members until those identifiers are recursively expanded into a structural shape.
9
-
10
- The resolver takes the stored text + the original file path, follows imports across the project via the cached `ts.Program`, applies a handful of TypeScript utility types (`Partial`, `Required`, `Pick`, `Omit`, `Record`, `Array`), and returns a self-contained inline type string. The result is cached so a hot request path does not redo work.
11
-
12
- ---
13
-
14
- ## Module surface
15
-
16
- ```typescript
17
- type ResolveResult =
18
- | { status: 'success'; typeText: string }
19
- | { status: 'error'; message: string };
20
-
21
- export const resolveRuntimeTypeText = ({
22
- typeText,
23
- filePath,
24
- }: {
25
- typeText: string;
26
- filePath?: string;
27
- }): ResolveResult;
28
-
29
- export const clearRuntimeTypeResolverCache = (): void;
30
- export const isUnresolvedTypeMarker = (value: string): boolean;
31
- export const getUnresolvedTypeMessage = (value: string): string;
32
- ```
33
-
34
- Internal-only:
35
-
36
- - `resolveExpression(typeText, filePath, depth, state)` — recursive worker.
37
- - `resolveIdentifier(identifier, filePath)` — TypeChecker-backed identifier lookup, follows local declarations + import statements.
38
- - `applyUtilityType({ utilityName, utilityArgs, filePath, depth, state })` — `Partial`, `Required`, `Pick`, `Omit`, `Record`.
39
- - `parseObjectFields(typeText)` / `serializeObjectFields({ fields, indexSignatures })` — round-trip an object literal type through a typed AST.
40
- - `splitTopLevel(value, '|' | '&' | ',')` — depth-aware splitter (respects `(`, `{`, `[`, `<`).
41
- - `parseLiteralUnionKeys(value)` — extract `'a' | 'b'` into `['a', 'b']` for `Pick` / `Omit` / `Record` key lists.
42
- - Constants: `MAX_DEPTH = 20`, `PRIMITIVE_TYPES`, `SKIP_EXPANSION`, `unresolvedPrefix = '__RUNTIME_UNRESOLVED__::'`.
43
- - Types: `ObjectField`, `ObjectIndexSignature`, `ResolveState`.
44
-
45
- ---
46
-
47
- ## `resolveRuntimeTypeText(...)` flow
48
-
49
- ```typescript
50
- export const resolveRuntimeTypeText = ({ typeText, filePath }) => {
51
- const cleanType = typeText.trim();
52
- if (!cleanType || !filePath) {
53
- return { status: 'success', typeText: cleanType };
54
- }
55
-
56
- const cacheKey = `${filePath}::${cleanType}`;
57
- const cached = resolvedTypeCache.get(cacheKey);
58
- if (cached) {
59
- return cached;
60
- }
61
-
62
- const resolved = resolveExpression(cleanType, filePath, 0, { stack: new Set() });
63
- const result: ResolveResult = isUnresolvedTypeMarker(resolved)
64
- ? { status: 'error', message: getUnresolvedTypeMessage(resolved) }
65
- : { status: 'success', typeText: resolved };
66
-
67
- resolvedTypeCache.set(cacheKey, result);
68
- return result;
69
- };
70
- ```
71
-
72
- 1. **No-op short-circuit.** Empty input or missing `filePath` — return as-is. The validator passes an empty string for routes with `{ [key: string]: never }` "no input" syntax that the extractor reduced to nothing; callers expect a success in that case.
73
- 2. **Cache hit.** `resolvedTypeCache: Map<string, ResolveResult>` keyed by `${filePath}::${typeText}`. The path is part of the key because `import { Foo } from './a'` and `import { Foo } from './b'` resolve to different `Foo`s.
74
- 3. **Recursive resolve.** `resolveExpression(...)` walks the type expression text and returns either a fully expanded form or a string prefixed with `__RUNTIME_UNRESOLVED__::<reason>`.
75
- 4. **Result shape.** Unresolved-marker prefix is mapped to `{ status: 'error', message }`; clean text is mapped to `{ status: 'success', typeText }`. Both branches are cached.
76
-
77
- ---
78
-
79
- ## `resolveExpression(...)` — the recursive worker
80
-
81
- The worker tracks two pieces of state:
82
-
83
- - `state.stack: Set<string>` — visited `(filePath, typeText)` pairs in the current resolution. Re-entering one returns `__RUNTIME_UNRESOLVED__::cyclic type reference <type>`.
84
- - `depth: number` — bumped on every recursion. When it exceeds `MAX_DEPTH = 20`, the worker returns `__RUNTIME_UNRESOLVED__::resolution depth exceeded for <type>`.
85
-
86
- The decision tree (in order):
87
-
88
- 1. **Already unresolved.** If `type.startsWith('__RUNTIME_UNRESOLVED__::')`, return it as-is.
89
- 2. **Parenthesized group.** `(T)` — strip outer parens, recurse, re-wrap on success.
90
- 3. **Top-level union.** `splitTopLevel(type, '|')` returns more than one part — recurse on each, propagate the first unresolved marker found, otherwise join with ` | `.
91
- 4. **Top-level intersection.** Same pattern with `&`.
92
- 5. **Array suffix.** `T[]` — recurse on `T`, wrap result with `[]`.
93
- 6. **Object literal.** Starts with `{` and ends with `}` — parse via `parseObjectFields`, recurse on every field type and every index signature key/value type, serialize back via `serializeObjectFields`.
94
- 7. **Generic application.** Matches `^([A-Za-z_][A-Za-z0-9_]*)<(.+)>$` — split args at top-level commas, then:
95
- - `Array<T>` (single arg) — same as `T[]`.
96
- - `Partial`, `Required`, `Pick`, `Omit`, `Record` — delegate to `applyUtilityType`.
97
- - Anything else — recurse on every type argument and re-render as `Name<arg1, arg2>`. The generic name is preserved verbatim; the validator can decide what to do with it.
98
- 8. **Bare identifier.** Matches `^[A-Za-z_][A-Za-z0-9_]*$` — delegate to `resolveIdentifier`.
99
- 9. **Everything else.** Returned as-is. This covers literal types (`'foo'`, `42`, `true`), already-primitive forms, and anything the regex set above does not classify.
100
-
101
- `splitTopLevel(value, splitter)` is the depth-aware splitter used by steps 3, 4, 7. It tracks `(`, `{`, `[`, `<` depth counters and only splits when all four are zero. This is what lets the worker safely split `Pick<User, 'a' | 'b'>` on commas without trimming the inner union.
102
-
103
- ---
104
-
105
- ## `resolveIdentifier(identifier, filePath)`
106
-
107
- ```typescript
108
- const resolveIdentifier = (identifier: string, filePath: string): string => {
109
- if (PRIMITIVE_TYPES.has(identifier.toLowerCase())) return identifier.toLowerCase();
110
- if (SKIP_EXPANSION.has(identifier)) return identifier;
111
-
112
- const program = getServerProgram();
113
- const sourceFile = program.getSourceFile(filePath);
114
- if (!sourceFile) return toUnresolved(`unresolved type ${identifier}`);
115
-
116
- const checker = program.getTypeChecker();
117
-
118
- for (const stmt of sourceFile.statements) {
119
- // 1. Local interface / type alias / enum
120
- if (
121
- (ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) || ts.isEnumDeclaration(stmt))
122
- && stmt.name.text === identifier
123
- ) {
124
- const symbol = checker.getSymbolAtLocation(stmt.name);
125
- if (symbol) {
126
- return expandType(checker.getDeclaredTypeOfSymbol(symbol), checker);
127
- }
128
- }
129
-
130
- // 2. Import declarations — follow aliases
131
- if (ts.isImportDeclaration(stmt) && stmt.importClause) {
132
- // namedBindings: { Foo, Bar } — match Foo or Bar
133
- // defaultName: Foo from '...'
134
- // For matches: getSymbolAtLocation -> getAliasedSymbol -> getDeclaredTypeOfSymbol -> expandType
135
- }
136
- }
137
-
138
- return toUnresolved(`unresolved type ${identifier}`);
139
- };
140
- ```
141
-
142
- Two short-circuit lists:
143
-
144
- - `PRIMITIVE_TYPES` — `string`, `number`, `boolean`, `true`, `false`, `null`, `undefined`, `any`, `unknown`, `void`, `never`. Returned lowercased; covers the case where the extractor emitted `String` instead of `string`.
145
- - `SKIP_EXPANSION` — `Date`, `Promise`, `Array`, `Record`, `Partial`, `Required`, `Pick`, `Omit`, `Function`, `Map`, `Set`, `Buffer`, `Uint8Array`, `Object`, `WeakMap`, `WeakSet`. Structurally opaque; the validator treats them as "any value of this name".
146
-
147
- For everything else, the worker walks the file's top-level statements and looks for:
148
-
149
- 1. A local `interface`, `type`, or `enum` declaration matching the identifier. If found, resolve the symbol, fetch its declared type, and recursively expand via `expandType` from `tsProgram.ts`.
150
- 2. An import declaration whose named or default binding matches the identifier. If found, resolve the symbol, hop through `getAliasedSymbol` to reach the original declaration, then expand.
151
-
152
- If neither path produces a result, return the unresolved marker. The `try { ... } catch { ... }` wrapper around the body ensures any TypeChecker exception (rare, but possible on partially-typed sources) degrades to `__RUNTIME_UNRESOLVED__::unresolved type <identifier>` rather than crashing the request.
153
-
154
- `getServerProgram()` is shared with the type-map emitter. The resolver does not call `invalidateProgramCache()` — that's the loader's job on hot-reload. Resolver-side, `clearRuntimeTypeResolverCache()` is the matching reset.
155
-
156
- ---
157
-
158
- ## `applyUtilityType(...)`
159
-
160
- Handles five utility types. Each requires its target type to resolve to an object literal first; if the target resolves to a non-object form, the utility returns an unresolved marker.
161
-
162
- - **`Partial<T>`** — resolve `T` to an object, mark every field optional via `{ ...field, optional: true }`, preserve index signatures. Pure-additive utility, no field filtering.
163
- - **`Required<T>`** — symmetric; mark every field non-optional.
164
- - **`Pick<T, K>`** — resolve `T` to an object, parse `K` via `parseLiteralUnionKeys` into a string set, drop every field whose key is not in the set. Preserves index signatures.
165
- - **`Omit<T, K>`** — same as `Pick` with inverted membership test.
166
- - **`Record<K, V>`** — resolve `K` and `V` first. If `K` resolves to a literal-string union, materialize each literal as a concrete field with type `V` (renders as a closed object). Otherwise leave as `Record<K, V>` (treated as an opaque container at runtime).
167
-
168
- If any argument to a utility resolves to an unresolved marker, that marker is propagated unchanged.
169
-
170
- If an argument list does not have the expected arity (e.g. `Partial<A, B>`), the utility returns `__RUNTIME_UNRESOLVED__::unresolved utility <Name><...>`.
171
-
172
- ---
173
-
174
- ## Object literal round-trip
175
-
176
- `parseObjectFields(typeText)` and `serializeObjectFields({ fields, indexSignatures })` are the small AST-like helpers that let the worker rewrite an object body.
177
-
178
- `parseObjectFields(typeText)`:
179
-
180
- - Strips outer braces.
181
- - Walks the inner text character by character, tracking nesting depth for `{`, `[`, `(`, `<`.
182
- - Splits on `;` at depth 0.
183
- - For each segment, runs two regexes:
184
- - Field regex: `^("']?[A-Za-z_][A-Za-z0-9_]*["']?)(\?)?\s*:\s*([\s\S]+)$` — captures key, optional flag, and type. Strips surrounding quotes from the key.
185
- - Index signature regex: `^\[\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([^\]]+)\]\s*:\s*([\s\S]+)$` — captures the key name (e.g. `key`), the key type (e.g. `string`), and the value type.
186
-
187
- `serializeObjectFields`:
188
-
189
- - Renders each field as `${name}${optional ? '?' : ''}: ${type}` and each index signature as `[${keyName}: ${keyType}]: ${type}`.
190
- - Joins with `; ` and wraps with `{ ... }`. Returns `{ }` for an empty body (matches the extractor's "no input" form).
191
-
192
- ---
193
-
194
- ## Unresolved-marker protocol
195
-
196
- ```typescript
197
- const unresolvedPrefix = '__RUNTIME_UNRESOLVED__::';
198
-
199
- const toUnresolved = (message: string): string => `${unresolvedPrefix}${message}`;
200
- export const isUnresolvedTypeMarker = (value: string): boolean => value.startsWith(unresolvedPrefix);
201
- export const getUnresolvedTypeMessage = (value: string): string => value.slice(unresolvedPrefix.length).trim();
202
- ```
203
-
204
- The worker uses marker strings — not exceptions — to propagate failure. The reason:
205
-
206
- - One unresolved leaf inside a deep object should not abort the whole resolve; the validator wants to know which symbol failed.
207
- - Throw-based propagation forces every call site to wrap in try/catch; marker-based propagation lets the worker fall through identical code paths for success and failure (the worker only needs to check the result of each recursion).
208
-
209
- Callers (`@luckystack/core/src/runtimeTypeValidation.ts`) inspect `result.status`. On `'error'`, they log the `message` and degrade to a soft-pass for the request rather than refusing it — the deep resolver is informational, not a hard gate.
210
-
211
- ---
212
-
213
- ## Cache lifecycle
214
-
215
- ```typescript
216
- const resolvedTypeCache = new Map<string, ResolveResult>();
217
-
218
- export const clearRuntimeTypeResolverCache = () => {
219
- resolvedTypeCache.clear();
220
- };
221
- ```
222
-
223
- The cache is keyed by `${filePath}::${typeText}`. It survives across requests but must be cleared whenever the underlying source files change.
224
-
225
- Invalidation points (all in `loader.ts`):
226
-
227
- - `initializeApis()` and `initializeSyncs()` — `clearRuntimeTypeResolverCache()` is called before re-scanning the source tree.
228
- - `upsertApiFromFile(filePath)`, `removeApiFromFile(filePath)`, `upsertSyncFromFile(filePath)`, `removeSyncFromFile(filePath)` — every per-file mutation clears the whole cache (not just the entry for the changed file). Selective invalidation would require tracking transitive imports; whole-cache flush is the simpler, safer choice and the resolver is not on the hot path of a steady-state running app.
229
-
230
- The cache must always be flushed alongside `invalidateProgramCache()` from `tsProgram.ts`. The two are paired in every invalidation site.
231
-
232
- ---
233
-
234
- ## Consumer contract
235
-
236
- - **Only `@luckystack/core` may import this module.** The lazy import lives in `@luckystack/core/src/runtimeTypeValidation.ts` and fires only when `process.env.NODE_ENV !== 'production'`. Consumers must not introduce any additional callers.
237
- - **Do not call from production code paths.** The resolver depends on `getServerProgram()`, which needs `tsconfig.server.json` and the consumer's source tree on disk. Production tarballs have neither.
238
- - **Treat results as ephemeral.** A cached success may turn into an error after the next hot reload (or vice versa). Always re-call `resolveRuntimeTypeText({ typeText, filePath })` rather than memoizing on the consumer side.
239
- - **Markers are strings, not exceptions.** Callers must check `status` before assuming success.
240
-
241
- ---
242
-
243
- ## Failure modes
244
-
245
- - **Cycle detected.** Returns `{ status: 'error', message: 'cyclic type reference <type>' }`. Happens for self-referential aliases that the type-map emitter could not flatten (e.g. recursive tree types whose Prisma client form was elided).
246
- - **Depth exceeded.** Returns `{ status: 'error', message: 'resolution depth exceeded for <type>' }`. `MAX_DEPTH = 20` is well above the depth limit in `tsProgram.ts` (`DEPTH_LIMIT = 12`), so this only fires when a type chains many import hops in addition to deep nesting.
247
- - **Identifier not found.** Returns `{ status: 'error', message: 'unresolved type <name>' }`. The file in question may not exist in the cached `ts.Program` (recently deleted, or outside the `tsconfig.server.json` `include` glob).
248
- - **Utility-arity mismatch.** Returns `{ status: 'error', message: 'unresolved utility <Name><...>' }`. The validator falls back to a soft-pass.
249
- - **Program rebuild during a resolve.** A concurrent hot-reload event could call `invalidateProgramCache()` mid-resolution. The next `getServerProgram()` call inside `resolveIdentifier` rebuilds; results from before and after the rebuild may differ. This is a tolerated race — the next steady-state request will see consistent state.
250
-
251
- ---
252
-
253
- ## Constants
254
-
255
- - `MAX_DEPTH = 20` — recursion cap for `resolveExpression`. Distinct from `DEPTH_LIMIT = 12` in `tsProgram.ts`; the resolver allows deeper recursion because each step crosses at most one file boundary.
256
- - `PRIMITIVE_TYPES` — 11 entries, lowercased on return.
257
- - `SKIP_EXPANSION` — 17 entries; passes through opaque containers and utility names that are themselves the subject of recursion elsewhere.
258
- - `unresolvedPrefix = '__RUNTIME_UNRESOLVED__::'` — sentinel string used by `toUnresolved` / `isUnresolvedTypeMarker` / `getUnresolvedTypeMessage`.
1
+ # Runtime Type Resolver (`runtimeTypeResolver.ts`)
2
+
3
+ > Dev-only. Lives in `@luckystack/devkit` and is consumed exclusively by `@luckystack/core/src/runtimeTypeValidation.ts` via a lazy `await import('@luckystack/devkit')` that fires only when `NODE_ENV !== 'production'`. Production servers never load this module — runtime input validation in prod uses the pre-generated Zod schemas in `apiInputSchemas.generated.ts`, not the deep resolver.
4
+
5
+ The resolver bridges two worlds:
6
+
7
+ 1. **What the loader stored.** When the dev loader (`loader.ts`) imports an API or sync file, it calls `getInputTypeFromFile(filePath)` (a public extractor) and stashes the result on `devApis[routeKey].inputType` along with `inputTypeFilePath`. The stored text is what the extractor produced at import time — usually a fully-expanded inline form, occasionally a named alias that the extractor could not expand standalone.
8
+ 2. **What the runtime validator needs.** Each incoming request is type-checked against the stored `inputType`. If the stored text still contains identifiers (e.g. `UserInput`, `Partial<RegisterPayload>`, `Pick<Prisma.User, 'id' | 'name'>`), the validator cannot match payload keys to type members until those identifiers are recursively expanded into a structural shape.
9
+
10
+ The resolver takes the stored text + the original file path, follows imports across the project via the cached `ts.Program`, applies a handful of TypeScript utility types (`Partial`, `Required`, `Pick`, `Omit`, `Record`, `Array`), and returns a self-contained inline type string. The result is cached so a hot request path does not redo work.
11
+
12
+ ---
13
+
14
+ ## Module surface
15
+
16
+ ```typescript
17
+ type ResolveResult =
18
+ | { status: 'success'; typeText: string }
19
+ | { status: 'error'; message: string };
20
+
21
+ export const resolveRuntimeTypeText = ({
22
+ typeText,
23
+ filePath,
24
+ }: {
25
+ typeText: string;
26
+ filePath?: string;
27
+ }): ResolveResult;
28
+
29
+ export const clearRuntimeTypeResolverCache = (): void;
30
+ export const isUnresolvedTypeMarker = (value: string): boolean;
31
+ export const getUnresolvedTypeMessage = (value: string): string;
32
+ ```
33
+
34
+ Internal-only:
35
+
36
+ - `resolveExpression(typeText, filePath, depth, state)` — recursive worker.
37
+ - `resolveIdentifier(identifier, filePath)` — TypeChecker-backed identifier lookup, follows local declarations + import statements.
38
+ - `applyUtilityType({ utilityName, utilityArgs, filePath, depth, state })` — `Partial`, `Required`, `Pick`, `Omit`, `Record`.
39
+ - `parseObjectFields(typeText)` / `serializeObjectFields({ fields, indexSignatures })` — round-trip an object literal type through a typed AST.
40
+ - `splitTopLevel(value, '|' | '&' | ',')` — depth-aware splitter (respects `(`, `{`, `[`, `<`).
41
+ - `parseLiteralUnionKeys(value)` — extract `'a' | 'b'` into `['a', 'b']` for `Pick` / `Omit` / `Record` key lists.
42
+ - Constants: `MAX_DEPTH = 20`, `PRIMITIVE_TYPES`, `SKIP_EXPANSION`, `unresolvedPrefix = '__RUNTIME_UNRESOLVED__::'`.
43
+ - Types: `ObjectField`, `ObjectIndexSignature`, `ResolveState`.
44
+
45
+ ---
46
+
47
+ ## `resolveRuntimeTypeText(...)` flow
48
+
49
+ ```typescript
50
+ export const resolveRuntimeTypeText = ({ typeText, filePath }) => {
51
+ const cleanType = typeText.trim();
52
+ if (!cleanType || !filePath) {
53
+ return { status: 'success', typeText: cleanType };
54
+ }
55
+
56
+ const cacheKey = `${filePath}::${cleanType}`;
57
+ const cached = resolvedTypeCache.get(cacheKey);
58
+ if (cached) {
59
+ return cached;
60
+ }
61
+
62
+ const resolved = resolveExpression(cleanType, filePath, 0, { stack: new Set() });
63
+ const result: ResolveResult = isUnresolvedTypeMarker(resolved)
64
+ ? { status: 'error', message: getUnresolvedTypeMessage(resolved) }
65
+ : { status: 'success', typeText: resolved };
66
+
67
+ resolvedTypeCache.set(cacheKey, result);
68
+ return result;
69
+ };
70
+ ```
71
+
72
+ 1. **No-op short-circuit.** Empty input or missing `filePath` — return as-is. The validator passes an empty string for routes with `{ [key: string]: never }` "no input" syntax that the extractor reduced to nothing; callers expect a success in that case.
73
+ 2. **Cache hit.** `resolvedTypeCache: Map<string, ResolveResult>` keyed by `${filePath}::${typeText}`. The path is part of the key because `import { Foo } from './a'` and `import { Foo } from './b'` resolve to different `Foo`s.
74
+ 3. **Recursive resolve.** `resolveExpression(...)` walks the type expression text and returns either a fully expanded form or a string prefixed with `__RUNTIME_UNRESOLVED__::<reason>`.
75
+ 4. **Result shape.** Unresolved-marker prefix is mapped to `{ status: 'error', message }`; clean text is mapped to `{ status: 'success', typeText }`. Both branches are cached.
76
+
77
+ ---
78
+
79
+ ## `resolveExpression(...)` — the recursive worker
80
+
81
+ The worker tracks two pieces of state:
82
+
83
+ - `state.stack: Set<string>` — visited `(filePath, typeText)` pairs in the current resolution. Re-entering one returns `__RUNTIME_UNRESOLVED__::cyclic type reference <type>`.
84
+ - `depth: number` — bumped on every recursion. When it exceeds `MAX_DEPTH = 20`, the worker returns `__RUNTIME_UNRESOLVED__::resolution depth exceeded for <type>`.
85
+
86
+ The decision tree (in order):
87
+
88
+ 1. **Already unresolved.** If `type.startsWith('__RUNTIME_UNRESOLVED__::')`, return it as-is.
89
+ 2. **Parenthesized group.** `(T)` — strip outer parens, recurse, re-wrap on success.
90
+ 3. **Top-level union.** `splitTopLevel(type, '|')` returns more than one part — recurse on each, propagate the first unresolved marker found, otherwise join with ` | `.
91
+ 4. **Top-level intersection.** Same pattern with `&`.
92
+ 5. **Array suffix.** `T[]` — recurse on `T`, wrap result with `[]`.
93
+ 6. **Object literal.** Starts with `{` and ends with `}` — parse via `parseObjectFields`, recurse on every field type and every index signature key/value type, serialize back via `serializeObjectFields`.
94
+ 7. **Generic application.** Matches `^([A-Za-z_][A-Za-z0-9_]*)<(.+)>$` — split args at top-level commas, then:
95
+ - `Array<T>` (single arg) — same as `T[]`.
96
+ - `Partial`, `Required`, `Pick`, `Omit`, `Record` — delegate to `applyUtilityType`.
97
+ - Anything else — recurse on every type argument and re-render as `Name<arg1, arg2>`. The generic name is preserved verbatim; the validator can decide what to do with it.
98
+ 8. **Bare identifier.** Matches `^[A-Za-z_][A-Za-z0-9_]*$` — delegate to `resolveIdentifier`.
99
+ 9. **Everything else.** Returned as-is. This covers literal types (`'foo'`, `42`, `true`), already-primitive forms, and anything the regex set above does not classify.
100
+
101
+ `splitTopLevel(value, splitter)` is the depth-aware splitter used by steps 3, 4, 7. It tracks `(`, `{`, `[`, `<` depth counters and only splits when all four are zero. This is what lets the worker safely split `Pick<User, 'a' | 'b'>` on commas without trimming the inner union.
102
+
103
+ ---
104
+
105
+ ## `resolveIdentifier(identifier, filePath)`
106
+
107
+ ```typescript
108
+ const resolveIdentifier = (identifier: string, filePath: string): string => {
109
+ if (PRIMITIVE_TYPES.has(identifier.toLowerCase())) return identifier.toLowerCase();
110
+ if (SKIP_EXPANSION.has(identifier)) return identifier;
111
+
112
+ const program = getServerProgram();
113
+ const sourceFile = program.getSourceFile(filePath);
114
+ if (!sourceFile) return toUnresolved(`unresolved type ${identifier}`);
115
+
116
+ const checker = program.getTypeChecker();
117
+
118
+ for (const stmt of sourceFile.statements) {
119
+ // 1. Local interface / type alias / enum
120
+ if (
121
+ (ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) || ts.isEnumDeclaration(stmt))
122
+ && stmt.name.text === identifier
123
+ ) {
124
+ const symbol = checker.getSymbolAtLocation(stmt.name);
125
+ if (symbol) {
126
+ return expandType(checker.getDeclaredTypeOfSymbol(symbol), checker);
127
+ }
128
+ }
129
+
130
+ // 2. Import declarations — follow aliases
131
+ if (ts.isImportDeclaration(stmt) && stmt.importClause) {
132
+ // namedBindings: { Foo, Bar } — match Foo or Bar
133
+ // defaultName: Foo from '...'
134
+ // For matches: getSymbolAtLocation -> getAliasedSymbol -> getDeclaredTypeOfSymbol -> expandType
135
+ }
136
+ }
137
+
138
+ return toUnresolved(`unresolved type ${identifier}`);
139
+ };
140
+ ```
141
+
142
+ Two short-circuit lists:
143
+
144
+ - `PRIMITIVE_TYPES` — `string`, `number`, `boolean`, `true`, `false`, `null`, `undefined`, `any`, `unknown`, `void`, `never`. Returned lowercased; covers the case where the extractor emitted `String` instead of `string`.
145
+ - `SKIP_EXPANSION` — `Date`, `Promise`, `Array`, `Record`, `Partial`, `Required`, `Pick`, `Omit`, `Function`, `Map`, `Set`, `Buffer`, `Uint8Array`, `Object`, `WeakMap`, `WeakSet`. Structurally opaque; the validator treats them as "any value of this name".
146
+
147
+ For everything else, the worker walks the file's top-level statements and looks for:
148
+
149
+ 1. A local `interface`, `type`, or `enum` declaration matching the identifier. If found, resolve the symbol, fetch its declared type, and recursively expand via `expandType` from `tsProgram.ts`.
150
+ 2. An import declaration whose named or default binding matches the identifier. If found, resolve the symbol, hop through `getAliasedSymbol` to reach the original declaration, then expand.
151
+
152
+ If neither path produces a result, return the unresolved marker. The `try { ... } catch { ... }` wrapper around the body ensures any TypeChecker exception (rare, but possible on partially-typed sources) degrades to `__RUNTIME_UNRESOLVED__::unresolved type <identifier>` rather than crashing the request.
153
+
154
+ `getServerProgram()` is shared with the type-map emitter. The resolver does not call `invalidateProgramCache()` — that's the loader's job on hot-reload. Resolver-side, `clearRuntimeTypeResolverCache()` is the matching reset.
155
+
156
+ ---
157
+
158
+ ## `applyUtilityType(...)`
159
+
160
+ Handles five utility types. Each requires its target type to resolve to an object literal first; if the target resolves to a non-object form, the utility returns an unresolved marker.
161
+
162
+ - **`Partial<T>`** — resolve `T` to an object, mark every field optional via `{ ...field, optional: true }`, preserve index signatures. Pure-additive utility, no field filtering.
163
+ - **`Required<T>`** — symmetric; mark every field non-optional.
164
+ - **`Pick<T, K>`** — resolve `T` to an object, parse `K` via `parseLiteralUnionKeys` into a string set, drop every field whose key is not in the set. Preserves index signatures.
165
+ - **`Omit<T, K>`** — same as `Pick` with inverted membership test.
166
+ - **`Record<K, V>`** — resolve `K` and `V` first. If `K` resolves to a literal-string union, materialize each literal as a concrete field with type `V` (renders as a closed object). Otherwise leave as `Record<K, V>` (treated as an opaque container at runtime).
167
+
168
+ If any argument to a utility resolves to an unresolved marker, that marker is propagated unchanged.
169
+
170
+ If an argument list does not have the expected arity (e.g. `Partial<A, B>`), the utility returns `__RUNTIME_UNRESOLVED__::unresolved utility <Name><...>`.
171
+
172
+ ---
173
+
174
+ ## Object literal round-trip
175
+
176
+ `parseObjectFields(typeText)` and `serializeObjectFields({ fields, indexSignatures })` are the small AST-like helpers that let the worker rewrite an object body.
177
+
178
+ `parseObjectFields(typeText)`:
179
+
180
+ - Strips outer braces.
181
+ - Walks the inner text character by character, tracking nesting depth for `{`, `[`, `(`, `<`.
182
+ - Splits on `;` at depth 0.
183
+ - For each segment, runs two regexes:
184
+ - Field regex: `^("']?[A-Za-z_][A-Za-z0-9_]*["']?)(\?)?\s*:\s*([\s\S]+)$` — captures key, optional flag, and type. Strips surrounding quotes from the key.
185
+ - Index signature regex: `^\[\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([^\]]+)\]\s*:\s*([\s\S]+)$` — captures the key name (e.g. `key`), the key type (e.g. `string`), and the value type.
186
+
187
+ `serializeObjectFields`:
188
+
189
+ - Renders each field as `${name}${optional ? '?' : ''}: ${type}` and each index signature as `[${keyName}: ${keyType}]: ${type}`.
190
+ - Joins with `; ` and wraps with `{ ... }`. Returns `{ }` for an empty body (matches the extractor's "no input" form).
191
+
192
+ ---
193
+
194
+ ## Unresolved-marker protocol
195
+
196
+ ```typescript
197
+ const unresolvedPrefix = '__RUNTIME_UNRESOLVED__::';
198
+
199
+ const toUnresolved = (message: string): string => `${unresolvedPrefix}${message}`;
200
+ export const isUnresolvedTypeMarker = (value: string): boolean => value.startsWith(unresolvedPrefix);
201
+ export const getUnresolvedTypeMessage = (value: string): string => value.slice(unresolvedPrefix.length).trim();
202
+ ```
203
+
204
+ The worker uses marker strings — not exceptions — to propagate failure. The reason:
205
+
206
+ - One unresolved leaf inside a deep object should not abort the whole resolve; the validator wants to know which symbol failed.
207
+ - Throw-based propagation forces every call site to wrap in try/catch; marker-based propagation lets the worker fall through identical code paths for success and failure (the worker only needs to check the result of each recursion).
208
+
209
+ Callers (`@luckystack/core/src/runtimeTypeValidation.ts`) inspect `result.status`. On `'error'`, they log the `message` and degrade to a soft-pass for the request rather than refusing it — the deep resolver is informational, not a hard gate.
210
+
211
+ ---
212
+
213
+ ## Cache lifecycle
214
+
215
+ ```typescript
216
+ const resolvedTypeCache = new Map<string, ResolveResult>();
217
+
218
+ export const clearRuntimeTypeResolverCache = () => {
219
+ resolvedTypeCache.clear();
220
+ };
221
+ ```
222
+
223
+ The cache is keyed by `${filePath}::${typeText}`. It survives across requests but must be cleared whenever the underlying source files change.
224
+
225
+ Invalidation points (all in `loader.ts`):
226
+
227
+ - `initializeApis()` and `initializeSyncs()` — `clearRuntimeTypeResolverCache()` is called before re-scanning the source tree.
228
+ - `upsertApiFromFile(filePath)`, `removeApiFromFile(filePath)`, `upsertSyncFromFile(filePath)`, `removeSyncFromFile(filePath)` — every per-file mutation clears the whole cache (not just the entry for the changed file). Selective invalidation would require tracking transitive imports; whole-cache flush is the simpler, safer choice and the resolver is not on the hot path of a steady-state running app.
229
+
230
+ The cache must always be flushed alongside `invalidateProgramCache()` from `tsProgram.ts`. The two are paired in every invalidation site.
231
+
232
+ ---
233
+
234
+ ## Consumer contract
235
+
236
+ - **Only `@luckystack/core` may import this module.** The lazy import lives in `@luckystack/core/src/runtimeTypeValidation.ts` and fires only when `process.env.NODE_ENV !== 'production'`. Consumers must not introduce any additional callers.
237
+ - **Do not call from production code paths.** The resolver depends on `getServerProgram()`, which needs `tsconfig.server.json` and the consumer's source tree on disk. Production tarballs have neither.
238
+ - **Treat results as ephemeral.** A cached success may turn into an error after the next hot reload (or vice versa). Always re-call `resolveRuntimeTypeText({ typeText, filePath })` rather than memoizing on the consumer side.
239
+ - **Markers are strings, not exceptions.** Callers must check `status` before assuming success.
240
+
241
+ ---
242
+
243
+ ## Failure modes
244
+
245
+ - **Cycle detected.** Returns `{ status: 'error', message: 'cyclic type reference <type>' }`. Happens for self-referential aliases that the type-map emitter could not flatten (e.g. recursive tree types whose Prisma client form was elided).
246
+ - **Depth exceeded.** Returns `{ status: 'error', message: 'resolution depth exceeded for <type>' }`. `MAX_DEPTH = 20` is well above the depth limit in `tsProgram.ts` (`DEPTH_LIMIT = 12`), so this only fires when a type chains many import hops in addition to deep nesting.
247
+ - **Identifier not found.** Returns `{ status: 'error', message: 'unresolved type <name>' }`. The file in question may not exist in the cached `ts.Program` (recently deleted, or outside the `tsconfig.server.json` `include` glob).
248
+ - **Utility-arity mismatch.** Returns `{ status: 'error', message: 'unresolved utility <Name><...>' }`. The validator falls back to a soft-pass.
249
+ - **Program rebuild during a resolve.** A concurrent hot-reload event could call `invalidateProgramCache()` mid-resolution. The next `getServerProgram()` call inside `resolveIdentifier` rebuilds; results from before and after the rebuild may differ. This is a tolerated race — the next steady-state request will see consistent state.
250
+
251
+ ---
252
+
253
+ ## Constants
254
+
255
+ - `MAX_DEPTH = 20` — recursion cap for `resolveExpression`. Distinct from `DEPTH_LIMIT = 12` in `tsProgram.ts`; the resolver allows deeper recursion because each step crosses at most one file boundary.
256
+ - `PRIMITIVE_TYPES` — 11 entries, lowercased on return.
257
+ - `SKIP_EXPANSION` — 17 entries; passes through opaque containers and utility names that are themselves the subject of recursion elsewhere.
258
+ - `unresolvedPrefix = '__RUNTIME_UNRESOLVED__::'` — sentinel string used by `toUnresolved` / `isUnresolvedTypeMarker` / `getUnresolvedTypeMessage`.