@luckystack/devkit 0.6.6 → 0.7.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/CHANGELOG.md +90 -0
- package/CLAUDE.md +129 -125
- package/LICENSE +21 -21
- package/README.md +44 -44
- package/dist/{chunk-EU2RFSLO.js → chunk-BZTCJ7JY.js} +1 -1
- package/dist/chunk-BZTCJ7JY.js.map +1 -0
- package/dist/cli/validateDeploy.js +1 -1
- package/dist/cli/validateDeploy.js.map +1 -1
- package/dist/index.js +257 -35
- package/dist/index.js.map +1 -1
- package/dist/supervisor.js +96 -45
- package/dist/supervisor.js.map +1 -1
- package/dist/templates/api.template.ts +54 -54
- package/dist/templates/page_dashboard.template.tsx +35 -35
- package/dist/templates/page_plain.template.tsx +32 -32
- package/dist/templates/sync_client.template.ts +40 -40
- package/dist/templates/sync_client_paired.template.ts +38 -38
- package/dist/templates/sync_client_standalone.template.ts +36 -36
- package/dist/templates/sync_server.template.ts +64 -64
- package/docs/cli.md +245 -245
- package/docs/hot-reload.md +365 -365
- package/docs/loader-pipeline.md +324 -324
- package/docs/runtime-type-resolver.md +258 -258
- package/docs/supervisor.md +292 -292
- package/docs/ts-program-cache.md +200 -199
- package/docs/type-map-generation.md +410 -392
- package/package.json +10 -4
- package/dist/chunk-EU2RFSLO.js.map +0 -1
package/docs/ts-program-cache.md
CHANGED
|
@@ -1,199 +1,200 @@
|
|
|
1
|
-
# TypeScript Program Cache (`typeMap/tsProgram.ts`)
|
|
2
|
-
|
|
3
|
-
> Dev-only. Internal to `@luckystack/devkit`. The cached `ts.Program` is used by the type-map emitter, the public extractors (`getInputTypeFromFile`, `getSyncClientDataType`), and the runtime type resolver. Production servers never load this module — the type-map emitter runs at build time / dev-time, and the generated `apiTypes.generated.ts` is what production reads.
|
|
4
|
-
|
|
5
|
-
The cache exists for one reason: building a `ts.Program` from `tsconfig.server.json` is the single most expensive step in type-map generation. A medium-sized LuckyStack project (50+ APIs, 20+ syncs, full Prisma schema) pays multi-second program-build cost. Hot reload must be sub-second, and a full type-map regeneration must reuse the same program across every extractor call within one pass.
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## Module surface
|
|
10
|
-
|
|
11
|
-
```typescript
|
|
12
|
-
let cachedProgram: ts.Program | null = null;
|
|
13
|
-
|
|
14
|
-
export const getServerProgram = (): ts.Program;
|
|
15
|
-
export const invalidateProgramCache = (): void;
|
|
16
|
-
|
|
17
|
-
export interface UnresolvedTypeSymbol {
|
|
18
|
-
name: string;
|
|
19
|
-
sourceFile?: string;
|
|
20
|
-
importPath?: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface ExpandedTypeResult {
|
|
24
|
-
text: string;
|
|
25
|
-
unresolvedSymbols: UnresolvedTypeSymbol[];
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export const expandTypeDetailed = (
|
|
29
|
-
type: ts.Type,
|
|
30
|
-
checker: ts.TypeChecker,
|
|
31
|
-
depth?: number,
|
|
32
|
-
state?: ExpandState,
|
|
33
|
-
): ExpandedTypeResult;
|
|
34
|
-
|
|
35
|
-
export const expandType = (
|
|
36
|
-
type: ts.Type,
|
|
37
|
-
checker: ts.TypeChecker,
|
|
38
|
-
depth?: number,
|
|
39
|
-
): string;
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
`cachedProgram` is module-level state. It starts as `null` and is only assigned by `getServerProgram()`. Resetting it to `null` (via `invalidateProgramCache()`) is the only invalidation path; the module never replaces a live program in place.
|
|
43
|
-
|
|
44
|
-
---
|
|
45
|
-
|
|
46
|
-
## `getServerProgram()` flow
|
|
47
|
-
|
|
48
|
-
```typescript
|
|
49
|
-
export const getServerProgram = (): ts.Program => {
|
|
50
|
-
if (cachedProgram) return cachedProgram;
|
|
51
|
-
|
|
52
|
-
const tsconfigPath = ts.findConfigFile(ROOT_DIR, ts.sys.fileExists, 'tsconfig.server.json');
|
|
53
|
-
if (!tsconfigPath) throw new Error('[TypeProgram] tsconfig.server.json not found');
|
|
54
|
-
|
|
55
|
-
const { config } = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
|
|
56
|
-
const { options, fileNames } = ts.parseJsonConfigFileContent(
|
|
57
|
-
config,
|
|
58
|
-
ts.sys,
|
|
59
|
-
path.dirname(tsconfigPath),
|
|
60
|
-
);
|
|
61
|
-
|
|
62
|
-
cachedProgram = ts.createProgram(fileNames, options);
|
|
63
|
-
return cachedProgram;
|
|
64
|
-
};
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
Steps:
|
|
68
|
-
|
|
69
|
-
1. Return the cached program if present. This is the hot path.
|
|
70
|
-
2. Locate `tsconfig.server.json` relative to `ROOT_DIR` (resolved from `@luckystack/core`). The constant is the project's working directory at process start — registered once in `@luckystack/core/src/projectRoot.ts`.
|
|
71
|
-
3. Throw a typed error if no tsconfig is found. There is no fallback; the type emitter has no way to resolve cross-file symbols without a tsconfig.
|
|
72
|
-
4. Parse the JSON tsconfig (`ts.readConfigFile` + `ts.parseJsonConfigFileContent`) to expand `extends`, `include`, `exclude`, `paths`, etc. into the concrete `options` + `fileNames` arrays the compiler accepts.
|
|
73
|
-
5. Build the program with `ts.createProgram(fileNames, options)`. This is the multi-second cost.
|
|
74
|
-
6. Memoize and return.
|
|
75
|
-
|
|
76
|
-
Re-entrancy: this function is synchronous and single-threaded. Two concurrent extractor calls within the same generation pass will each hit step 1 after the first call returns.
|
|
77
|
-
|
|
78
|
-
---
|
|
79
|
-
|
|
80
|
-
## `invalidateProgramCache()` rules
|
|
81
|
-
|
|
82
|
-
```typescript
|
|
83
|
-
export const invalidateProgramCache = (): void => {
|
|
84
|
-
cachedProgram = null;
|
|
85
|
-
};
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
Setting `cachedProgram = null` forces the next `getServerProgram()` call to rebuild from disk.
|
|
89
|
-
|
|
90
|
-
Invalidation points in the codebase:
|
|
91
|
-
|
|
92
|
-
- `loader.ts` `upsertApiFromFile(filePath)` — calls `invalidateProgramCache()` before re-importing the changed file. The next type extraction must see the new file contents.
|
|
93
|
-
- `loader.ts` `removeApiFromFile(filePath)` — same; references to the deleted file must be re-resolved.
|
|
94
|
-
- `loader.ts` `upsertSyncFromFile(filePath)` and `removeSyncFromFile(filePath)` — same.
|
|
95
|
-
- `typeMapGenerator.ts` `generateTypeMapFile(...)` — calls `invalidateProgramCache()` at the start of every full regeneration so a build script picks up files that changed since the previous in-process run.
|
|
96
|
-
|
|
97
|
-
Deliberate non-invalidation points:
|
|
98
|
-
|
|
99
|
-
- `loader.ts` `initializeApis()` and `initializeSyncs()` — **do not** invalidate on the boot path. `cachedProgram` is `null` at module load, so the first `getServerProgram()` call builds it from scratch anyway. With `initializeApis` and `initializeSyncs` running in parallel via `Promise.all`, invalidating in both forced a redundant double-build (measured ~3-4 s wasted on a 54-API project). The annotated reason lives in `loader.ts` directly above the call.
|
|
100
|
-
|
|
101
|
-
Invalidation must always be paired with `clearRuntimeTypeResolverCache()` — the runtime resolver caches expanded text keyed by the `(filePath, typeText)` pair, and stale entries can outlive a program rebuild.
|
|
102
|
-
|
|
103
|
-
---
|
|
104
|
-
|
|
105
|
-
## `expandTypeDetailed(...)` — recursive expander
|
|
106
|
-
|
|
107
|
-
`expandTypeDetailed(type, checker, depth, state)` walks a `ts.Type` and produces a fully self-contained inline type string. The result has no named references, no import paths, and can be dropped into `apiTypes.generated.ts` verbatim.
|
|
108
|
-
|
|
109
|
-
Key behaviors:
|
|
110
|
-
|
|
111
|
-
- **Cycle protection.** Uses `state.stackTypeIds: Set<number>` keyed by TypeScript's internal type IDs. When the walker re-enters a type already on the stack, it short-circuits with `checker.typeToString(type)` plus `collectTypeSymbolFallback(type)` (so the unresolved-symbol bookkeeping still fires).
|
|
112
|
-
- **Depth cap.** `DEPTH_LIMIT = 12`. Above this, the walker stops expanding and returns `checker.typeToString(type)`. Twelve levels is enough for every realistic Prisma model + nested DTO combination encountered in practice; beyond that the cost is exponential and the emitted text becomes unreadable.
|
|
113
|
-
- **JSON passthrough.** `JSON_TYPE_NAMES` — `Json`, `JsonValue`, `JsonObject`, `JsonArray`, `InputJsonValue`, `InputJsonObject`, `InputJsonArray` — short-circuit to the literal text `JsonValue`. Prisma's recursive `Json` types blow the depth limit if expanded structurally, and the structural expansion is meaningless anyway (the runtime is "any JSON-shaped value").
|
|
114
|
-
- **Opaque containers.** `SKIP_EXPANSION`
|
|
115
|
-
- **Arrays.** `Array<T>` and `ReadonlyArray<T>` are rendered as `T[]`. Union/intersection element types are parenthesized
|
|
116
|
-
- **Tuples.** `[A, B, C]` literal form, recursing into each element.
|
|
117
|
-
- **Unions and intersections.** Each constituent is expanded; results are joined with ` | ` or ` & `. Unresolved-symbol lists are merged via `mergeUnresolvedSymbols`.
|
|
118
|
-
- **Object types.** `checker.getPropertiesOfType(type)` + `checker.getIndexInfosOfType(type)`. For each property:
|
|
119
|
-
- If the property's declaration is a `PropertyAssignment` / `ShorthandPropertyAssignment` whose initializer is a literal, the literal text is preserved (`'hello'`, `42`, `true`, `null`). This is what lets the emitter render `httpMethod: 'POST'` as a literal type rather than the generic `string`.
|
|
120
|
-
- Otherwise the property type is recursively expanded.
|
|
121
|
-
- Optional flag (`prop.flags & ts.SymbolFlags.Optional`) is rendered as `?:`.
|
|
122
|
-
- **Index signatures.** Both the key type and the value type are recursively expanded; rendered as `[key: KeyType]: ValueType`.
|
|
123
|
-
- **Primitives.** `string`, `number`, `boolean`, `true`, `false`, `null`, `undefined`, `any`, `unknown`, `never`, `void` are returned via `checker.typeToString(type)` unchanged.
|
|
124
|
-
- **String / number literals.** Single-quoted (with escape handling) for strings, numeric text for numbers. Negative number literals are reconstructed from prefix-unary expressions.
|
|
125
|
-
- **Indentation.** The walker tracks `depth` and uses `' '.repeat(depth + 1)` for property indent and `' '.repeat(depth)` for the closing brace. This keeps the generated `apiTypes.generated.ts` readable.
|
|
126
|
-
|
|
127
|
-
`expandType(type, checker, depth)` is the convenience wrapper that returns only the `text` field — used when the caller doesn't care about unresolved symbols (the runtime resolver path).
|
|
128
|
-
|
|
129
|
-
---
|
|
130
|
-
|
|
131
|
-
## `UnresolvedTypeSymbol` and import collection
|
|
132
|
-
|
|
133
|
-
```typescript
|
|
134
|
-
export interface UnresolvedTypeSymbol {
|
|
135
|
-
name: string;
|
|
136
|
-
sourceFile?: string;
|
|
137
|
-
importPath?: string;
|
|
138
|
-
}
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
When the walker reaches the depth limit, hits a cycle, or encounters a type whose properties cannot be expanded (typically an opaque type from a `node_modules` package), it falls back to `checker.typeToString(type)` and records the type's symbol via `collectTypeSymbolFallback`.
|
|
142
|
-
|
|
143
|
-
`collectTypeSymbolFallback` resolves the source file via `symbol.declarations?.[0]?.getSourceFile().fileName`. If the source file is outside `node_modules`, it computes a relative import path via `normalizeImportPath(sourceFile)` — relative to `src/_sockets/` (the directory where `apiTypes.generated.ts` lives). Otherwise the symbol is recorded with only its `name`, so the emitter can decide whether to surface it as an unresolved alias or skip it.
|
|
144
|
-
|
|
145
|
-
The type-map generator collects every unresolved symbol from every extractor call. If any unresolved symbol has no `importPath` (i.e., it cannot be reached by an import statement from the generated file), `generateTypeMapFile` throws an aggregated error listing every offending name. This is a strict gate — there is no `--allow-unresolved` flag.
|
|
146
|
-
|
|
147
|
-
---
|
|
148
|
-
|
|
149
|
-
## `expandState` — single recursion context
|
|
150
|
-
|
|
151
|
-
```typescript
|
|
152
|
-
interface ExpandState {
|
|
153
|
-
stackTypeIds: Set<number>;
|
|
154
|
-
}
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
Callers do not need to pass `state` explicitly; the walker creates one on the first call. The state is local to one top-level expansion — it does not leak across calls. The `try { ... } finally { stackTypeIds.delete(typeId); }` block in the walker guarantees the stack is unwound even on exception.
|
|
158
|
-
|
|
159
|
-
---
|
|
160
|
-
|
|
161
|
-
## Consumer surface
|
|
162
|
-
|
|
163
|
-
Only the following modules import `tsProgram.ts`:
|
|
164
|
-
|
|
165
|
-
- `typeMap/extractors.ts` — calls `getServerProgram()` once per extractor invocation.
|
|
166
|
-
- `typeMapGenerator.ts` — calls `invalidateProgramCache()` at the start of every regeneration.
|
|
167
|
-
- `runtimeTypeResolver.ts` — calls `getServerProgram()` and `expandType` on the lazy expansion path.
|
|
168
|
-
- `loader.ts` — calls `invalidateProgramCache()` from hot-reload upsert/remove handlers.
|
|
169
|
-
|
|
170
|
-
Code outside `@luckystack/devkit` MUST NOT call `getServerProgram()` directly. Use the public extractors `getInputTypeFromFile(filePath)` and `getSyncClientDataType(filePath)` from `@luckystack/devkit`. They wrap program access, expansion, and error handling, and they are the only stable cross-package surface.
|
|
171
|
-
|
|
172
|
-
---
|
|
173
|
-
|
|
174
|
-
## Interaction with the runtime type resolver
|
|
175
|
-
|
|
176
|
-
`runtimeTypeResolver.ts` and `tsProgram.ts` share the cached program. When the resolver expands an identifier via `resolveIdentifier(identifier, filePath)`, it calls `getServerProgram()` to get the same `ts.Program` the type-map emitter uses. This avoids paying the program-build cost twice during dev startup.
|
|
177
|
-
|
|
178
|
-
The two modules also share an invalidation contract: any code path that calls `invalidateProgramCache()` MUST also call `clearRuntimeTypeResolverCache()`. The hot-reload upsert/remove handlers in `loader.ts` do both. If the resolver's cache outlives a program rebuild, the resolver returns stale expanded text and the runtime validator will reject valid payloads.
|
|
179
|
-
|
|
180
|
-
---
|
|
181
|
-
|
|
182
|
-
## Failure modes
|
|
183
|
-
|
|
184
|
-
- **Missing `tsconfig.server.json`.** `getServerProgram()` throws synchronously. Boot fails loudly; there is no fallback.
|
|
185
|
-
- **TypeScript version drift.** `typescript` is a required peer dependency at `>=5.7.3 <7.0.0`. The `checker.typeToString` output was verified byte-identical between TS 5.7.3 and 6.0.3, so both are supported (TS 7 excluded until re-verified); a consumer with a `typescript` version outside this range may produce different inlined output. Treat this as a hard peer.
|
|
186
|
-
- **Depth limit hit.** `expandTypeDetailed` returns `checker.typeToString(type)` and records the type's symbol as unresolved. The emitter logs the offending route (`[TypeMapGenerator] Unresolved API type (page/name/version): SymbolName`) and aborts the whole generation if any unresolved symbol cannot be imported.
|
|
187
|
-
- **Cyclic type.** Same as depth-limit hit — short-circuit to the type's string form and record the symbol.
|
|
188
|
-
- **Symbol with no source file.** The fallback returns `{ name }` without `sourceFile` or `importPath`. The emitter treats this as a hard unresolved alias and aborts generation.
|
|
189
|
-
- **Stale cache after a file change.** If `invalidateProgramCache()` is not called between a file change and the next extraction, the extractor will see the previous file contents. The hot-reload loader handles this; consumers calling extractors directly must invalidate themselves.
|
|
190
|
-
|
|
191
|
-
---
|
|
192
|
-
|
|
193
|
-
## Constants
|
|
194
|
-
|
|
195
|
-
- `DEPTH_LIMIT = 12` — recursion cap for `expandTypeDetailed`.
|
|
196
|
-
- `JSON_TYPE_NAMES` — set of seven Prisma-related JSON aliases that short-circuit to `JsonValue`.
|
|
197
|
-
- `SKIP_EXPANSION` — set of opaque generic / built-in container names
|
|
198
|
-
|
|
199
|
-
|
|
1
|
+
# TypeScript Program Cache (`typeMap/tsProgram.ts`)
|
|
2
|
+
|
|
3
|
+
> Dev-only. Internal to `@luckystack/devkit`. The cached `ts.Program` is used by the type-map emitter, the public extractors (`getInputTypeFromFile`, `getSyncClientDataType`), and the runtime type resolver. Production servers never load this module — the type-map emitter runs at build time / dev-time, and the generated `apiTypes.generated.ts` is what production reads.
|
|
4
|
+
|
|
5
|
+
The cache exists for one reason: building a `ts.Program` from `tsconfig.server.json` is the single most expensive step in type-map generation. A medium-sized LuckyStack project (50+ APIs, 20+ syncs, full Prisma schema) pays multi-second program-build cost. Hot reload must be sub-second, and a full type-map regeneration must reuse the same program across every extractor call within one pass.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Module surface
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
let cachedProgram: ts.Program | null = null;
|
|
13
|
+
|
|
14
|
+
export const getServerProgram = (): ts.Program;
|
|
15
|
+
export const invalidateProgramCache = (): void;
|
|
16
|
+
|
|
17
|
+
export interface UnresolvedTypeSymbol {
|
|
18
|
+
name: string;
|
|
19
|
+
sourceFile?: string;
|
|
20
|
+
importPath?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ExpandedTypeResult {
|
|
24
|
+
text: string;
|
|
25
|
+
unresolvedSymbols: UnresolvedTypeSymbol[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const expandTypeDetailed = (
|
|
29
|
+
type: ts.Type,
|
|
30
|
+
checker: ts.TypeChecker,
|
|
31
|
+
depth?: number,
|
|
32
|
+
state?: ExpandState,
|
|
33
|
+
): ExpandedTypeResult;
|
|
34
|
+
|
|
35
|
+
export const expandType = (
|
|
36
|
+
type: ts.Type,
|
|
37
|
+
checker: ts.TypeChecker,
|
|
38
|
+
depth?: number,
|
|
39
|
+
): string;
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`cachedProgram` is module-level state. It starts as `null` and is only assigned by `getServerProgram()`. Resetting it to `null` (via `invalidateProgramCache()`) is the only invalidation path; the module never replaces a live program in place.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## `getServerProgram()` flow
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
export const getServerProgram = (): ts.Program => {
|
|
50
|
+
if (cachedProgram) return cachedProgram;
|
|
51
|
+
|
|
52
|
+
const tsconfigPath = ts.findConfigFile(ROOT_DIR, ts.sys.fileExists, 'tsconfig.server.json');
|
|
53
|
+
if (!tsconfigPath) throw new Error('[TypeProgram] tsconfig.server.json not found');
|
|
54
|
+
|
|
55
|
+
const { config } = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
|
|
56
|
+
const { options, fileNames } = ts.parseJsonConfigFileContent(
|
|
57
|
+
config,
|
|
58
|
+
ts.sys,
|
|
59
|
+
path.dirname(tsconfigPath),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
cachedProgram = ts.createProgram(fileNames, options);
|
|
63
|
+
return cachedProgram;
|
|
64
|
+
};
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Steps:
|
|
68
|
+
|
|
69
|
+
1. Return the cached program if present. This is the hot path.
|
|
70
|
+
2. Locate `tsconfig.server.json` relative to `ROOT_DIR` (resolved from `@luckystack/core`). The constant is the project's working directory at process start — registered once in `@luckystack/core/src/projectRoot.ts`.
|
|
71
|
+
3. Throw a typed error if no tsconfig is found. There is no fallback; the type emitter has no way to resolve cross-file symbols without a tsconfig.
|
|
72
|
+
4. Parse the JSON tsconfig (`ts.readConfigFile` + `ts.parseJsonConfigFileContent`) to expand `extends`, `include`, `exclude`, `paths`, etc. into the concrete `options` + `fileNames` arrays the compiler accepts.
|
|
73
|
+
5. Build the program with `ts.createProgram(fileNames, options)`. This is the multi-second cost.
|
|
74
|
+
6. Memoize and return.
|
|
75
|
+
|
|
76
|
+
Re-entrancy: this function is synchronous and single-threaded. Two concurrent extractor calls within the same generation pass will each hit step 1 after the first call returns.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## `invalidateProgramCache()` rules
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
export const invalidateProgramCache = (): void => {
|
|
84
|
+
cachedProgram = null;
|
|
85
|
+
};
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Setting `cachedProgram = null` forces the next `getServerProgram()` call to rebuild from disk.
|
|
89
|
+
|
|
90
|
+
Invalidation points in the codebase:
|
|
91
|
+
|
|
92
|
+
- `loader.ts` `upsertApiFromFile(filePath)` — calls `invalidateProgramCache()` before re-importing the changed file. The next type extraction must see the new file contents.
|
|
93
|
+
- `loader.ts` `removeApiFromFile(filePath)` — same; references to the deleted file must be re-resolved.
|
|
94
|
+
- `loader.ts` `upsertSyncFromFile(filePath)` and `removeSyncFromFile(filePath)` — same.
|
|
95
|
+
- `typeMapGenerator.ts` `generateTypeMapFile(...)` — calls `invalidateProgramCache()` at the start of every full regeneration so a build script picks up files that changed since the previous in-process run.
|
|
96
|
+
|
|
97
|
+
Deliberate non-invalidation points:
|
|
98
|
+
|
|
99
|
+
- `loader.ts` `initializeApis()` and `initializeSyncs()` — **do not** invalidate on the boot path. `cachedProgram` is `null` at module load, so the first `getServerProgram()` call builds it from scratch anyway. With `initializeApis` and `initializeSyncs` running in parallel via `Promise.all`, invalidating in both forced a redundant double-build (measured ~3-4 s wasted on a 54-API project). The annotated reason lives in `loader.ts` directly above the call.
|
|
100
|
+
|
|
101
|
+
Invalidation must always be paired with `clearRuntimeTypeResolverCache()` — the runtime resolver caches expanded text keyed by the `(filePath, typeText)` pair, and stale entries can outlive a program rebuild.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## `expandTypeDetailed(...)` — recursive expander
|
|
106
|
+
|
|
107
|
+
`expandTypeDetailed(type, checker, depth, state)` walks a `ts.Type` and produces a fully self-contained inline type string. The result has no named references, no import paths, and can be dropped into `apiTypes.generated.ts` verbatim.
|
|
108
|
+
|
|
109
|
+
Key behaviors:
|
|
110
|
+
|
|
111
|
+
- **Cycle protection.** Uses `state.stackTypeIds: Set<number>` keyed by TypeScript's internal type IDs. When the walker re-enters a type already on the stack, it short-circuits with `checker.typeToString(type)` plus `collectTypeSymbolFallback(type)` (so the unresolved-symbol bookkeeping still fires).
|
|
112
|
+
- **Depth cap.** `DEPTH_LIMIT = 12`. Above this, the walker stops expanding and returns `checker.typeToString(type)`. Twelve levels is enough for every realistic Prisma model + nested DTO combination encountered in practice; beyond that the cost is exponential and the emitted text becomes unreadable.
|
|
113
|
+
- **JSON passthrough.** `JSON_TYPE_NAMES` — `Json`, `JsonValue`, `JsonObject`, `JsonArray`, `InputJsonValue`, `InputJsonObject`, `InputJsonArray` — short-circuit to the literal text `JsonValue`. Prisma's recursive `Json` types blow the depth limit if expanded structurally, and the structural expansion is meaningless anyway (the runtime is "any JSON-shaped value").
|
|
114
|
+
- **Opaque containers.** On unprojected paths, `SKIP_EXPANSION` containers are returned via `checker.typeToString`. On wire-output paths, `Date` becomes `string`; binary containers (`Buffer`, `ArrayBuffer`, typed arrays, Blob/File) abort generation because HTTP JSON and Socket.io binary delivery disagree.
|
|
115
|
+
- **Arrays.** `Array<T>` and `ReadonlyArray<T>` are rendered as `T[]`. Union/intersection element types are parenthesized; `undefined`/symbol/function constituents become `null`, matching JSON array-slot semantics.
|
|
116
|
+
- **Tuples.** `[A, B, C]` literal form, recursing into each element.
|
|
117
|
+
- **Unions and intersections.** Each constituent is expanded; results are joined with ` | ` or ` & `. Unresolved-symbol lists are merged via `mergeUnresolvedSymbols`.
|
|
118
|
+
- **Object types.** `checker.getPropertiesOfType(type)` + `checker.getIndexInfosOfType(type)`. For each property:
|
|
119
|
+
- If the property's declaration is a `PropertyAssignment` / `ShorthandPropertyAssignment` whose initializer is a literal, the literal text is preserved (`'hello'`, `42`, `true`, `null`). This is what lets the emitter render `httpMethod: 'POST'` as a literal type rather than the generic `string`.
|
|
120
|
+
- Otherwise the property type is recursively expanded.
|
|
121
|
+
- Optional flag (`prop.flags & ts.SymbolFlags.Optional`) is rendered as `?:`. On wire paths, a union containing `undefined`/symbol/function also becomes optional after the omitted constituent is removed; a property containing only omitted constituents is dropped.
|
|
122
|
+
- **Index signatures.** Both the key type and the value type are recursively expanded; rendered as `[key: KeyType]: ValueType`.
|
|
123
|
+
- **Primitives.** `string`, `number`, `boolean`, `true`, `false`, `null`, `undefined`, `any`, `unknown`, `never`, `void` are returned via `checker.typeToString(type)` unchanged.
|
|
124
|
+
- **String / number literals.** Single-quoted (with escape handling) for strings, numeric text for numbers. Negative number literals are reconstructed from prefix-unary expressions.
|
|
125
|
+
- **Indentation.** The walker tracks `depth` and uses `' '.repeat(depth + 1)` for property indent and `' '.repeat(depth)` for the closing brace. This keeps the generated `apiTypes.generated.ts` readable.
|
|
126
|
+
|
|
127
|
+
`expandType(type, checker, depth)` is the convenience wrapper that returns only the `text` field — used when the caller doesn't care about unresolved symbols (the runtime resolver path).
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## `UnresolvedTypeSymbol` and import collection
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
export interface UnresolvedTypeSymbol {
|
|
135
|
+
name: string;
|
|
136
|
+
sourceFile?: string;
|
|
137
|
+
importPath?: string;
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
When the walker reaches the depth limit, hits a cycle, or encounters a type whose properties cannot be expanded (typically an opaque type from a `node_modules` package), it falls back to `checker.typeToString(type)` and records the type's symbol via `collectTypeSymbolFallback`.
|
|
142
|
+
|
|
143
|
+
`collectTypeSymbolFallback` resolves the source file via `symbol.declarations?.[0]?.getSourceFile().fileName`. If the source file is outside `node_modules`, it computes a relative import path via `normalizeImportPath(sourceFile)` — relative to `src/_sockets/` (the directory where `apiTypes.generated.ts` lives). Otherwise the symbol is recorded with only its `name`, so the emitter can decide whether to surface it as an unresolved alias or skip it.
|
|
144
|
+
|
|
145
|
+
The type-map generator collects every unresolved symbol from every extractor call. If any unresolved symbol has no `importPath` (i.e., it cannot be reached by an import statement from the generated file), `generateTypeMapFile` throws an aggregated error listing every offending name. This is a strict gate — there is no `--allow-unresolved` flag.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## `expandState` — single recursion context
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
interface ExpandState {
|
|
153
|
+
stackTypeIds: Set<number>;
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Callers do not need to pass `state` explicitly; the walker creates one on the first call. The state is local to one top-level expansion — it does not leak across calls. The `try { ... } finally { stackTypeIds.delete(typeId); }` block in the walker guarantees the stack is unwound even on exception.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Consumer surface
|
|
162
|
+
|
|
163
|
+
Only the following modules import `tsProgram.ts`:
|
|
164
|
+
|
|
165
|
+
- `typeMap/extractors.ts` — calls `getServerProgram()` once per extractor invocation.
|
|
166
|
+
- `typeMapGenerator.ts` — calls `invalidateProgramCache()` at the start of every regeneration.
|
|
167
|
+
- `runtimeTypeResolver.ts` — calls `getServerProgram()` and `expandType` on the lazy expansion path.
|
|
168
|
+
- `loader.ts` — calls `invalidateProgramCache()` from hot-reload upsert/remove handlers.
|
|
169
|
+
|
|
170
|
+
Code outside `@luckystack/devkit` MUST NOT call `getServerProgram()` directly. Use the public extractors `getInputTypeFromFile(filePath)` and `getSyncClientDataType(filePath)` from `@luckystack/devkit`. They wrap program access, expansion, and error handling, and they are the only stable cross-package surface.
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Interaction with the runtime type resolver
|
|
175
|
+
|
|
176
|
+
`runtimeTypeResolver.ts` and `tsProgram.ts` share the cached program. When the resolver expands an identifier via `resolveIdentifier(identifier, filePath)`, it calls `getServerProgram()` to get the same `ts.Program` the type-map emitter uses. This avoids paying the program-build cost twice during dev startup.
|
|
177
|
+
|
|
178
|
+
The two modules also share an invalidation contract: any code path that calls `invalidateProgramCache()` MUST also call `clearRuntimeTypeResolverCache()`. The hot-reload upsert/remove handlers in `loader.ts` do both. If the resolver's cache outlives a program rebuild, the resolver returns stale expanded text and the runtime validator will reject valid payloads.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Failure modes
|
|
183
|
+
|
|
184
|
+
- **Missing `tsconfig.server.json`.** `getServerProgram()` throws synchronously. Boot fails loudly; there is no fallback.
|
|
185
|
+
- **TypeScript version drift.** `typescript` is a required peer dependency at `>=5.7.3 <7.0.0`. The `checker.typeToString` output was verified byte-identical between TS 5.7.3 and 6.0.3, so both are supported (TS 7 excluded until re-verified); a consumer with a `typescript` version outside this range may produce different inlined output. Treat this as a hard peer.
|
|
186
|
+
- **Depth limit hit.** `expandTypeDetailed` returns `checker.typeToString(type)` and records the type's symbol as unresolved. The emitter logs the offending route (`[TypeMapGenerator] Unresolved API type (page/name/version): SymbolName`) and aborts the whole generation if any unresolved symbol cannot be imported.
|
|
187
|
+
- **Cyclic type.** Same as depth-limit hit — short-circuit to the type's string form and record the symbol.
|
|
188
|
+
- **Symbol with no source file.** The fallback returns `{ name }` without `sourceFile` or `importPath`. The emitter treats this as a hard unresolved alias and aborts generation.
|
|
189
|
+
- **Stale cache after a file change.** If `invalidateProgramCache()` is not called between a file change and the next extraction, the extractor will see the previous file contents. The hot-reload loader handles this; consumers calling extractors directly must invalidate themselves.
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## Constants
|
|
194
|
+
|
|
195
|
+
- `DEPTH_LIMIT = 12` — recursion cap for `expandTypeDetailed`.
|
|
196
|
+
- `JSON_TYPE_NAMES` — set of seven Prisma-related JSON aliases that short-circuit to `JsonValue`.
|
|
197
|
+
- `SKIP_EXPANSION` — set of opaque generic / built-in container names used by the unprojected path.
|
|
198
|
+
- `TRANSPORT_DEPENDENT_BINARY_TYPES` — binary values rejected from shared HTTP/Socket.io output maps.
|
|
199
|
+
|
|
200
|
+
These constants are not exported. They are tuning knobs internal to the expander; changing them requires regenerating the type map for every consumer project to verify no previously-expanding type now bottoms out as an unresolved alias.
|