@luckystack/devkit 0.1.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.
@@ -0,0 +1,292 @@
1
+ # Supervisor (`supervisor.ts`)
2
+
3
+ > Dev-only. The supervisor is the parent process that watches files which cannot be hot-reloaded in-process (config, bootstrap, sockets setup, auth wiring) and restarts the LuckyStack server child whenever one of them changes. In production it skips the watcher entirely and just keeps the child crash-restarted.
4
+
5
+ The supervisor is a single Node script (`packages/devkit/src/supervisor.ts`) compiled into the devkit `dist/` and invoked by consumer projects via their own `npm run dev` script (typically `tsx node_modules/@luckystack/devkit/dist/supervisor.js` or equivalent).
6
+
7
+ It owns one child process at a time. The child runs `tsx` against `server/server.ts` and inherits stdio so its logs surface in the parent's terminal as-is.
8
+
9
+ ---
10
+
11
+ ## Process model
12
+
13
+ ```typescript
14
+ const tsxCliPath = path.resolve(process.cwd(), 'node_modules', 'tsx', 'dist', 'cli.mjs');
15
+ const childArgs = [tsxCliPath, 'server/server.ts'];
16
+
17
+ const startChild = () => {
18
+ if (isShuttingDown) return;
19
+ childBootStartedAt = performance.now();
20
+ childProcess = spawn(process.execPath, childArgs, {
21
+ stdio: 'inherit',
22
+ env: {
23
+ ...process.env,
24
+ LUCKYSTACK_CORE_SUPERVISED: 'true',
25
+ },
26
+ });
27
+ // ... attach 'exit' handler ...
28
+ };
29
+ ```
30
+
31
+ Key points:
32
+
33
+ - `process.execPath` is the running Node binary, so the child uses the same Node version as the supervisor.
34
+ - `tsx` is invoked as a Node script (`node node_modules/tsx/dist/cli.mjs`) rather than via a shell — fewer cross-platform surprises (no PATH dependency, no `.cmd` shim on Windows).
35
+ - `stdio: 'inherit'` means child logs go directly to the parent's TTY. Backpressure is handled by Node, the supervisor doesn't touch the streams.
36
+ - `LUCKYSTACK_CORE_SUPERVISED=true` is set in the child env so framework code can detect that it is running under the supervisor (useful for adjusting log prefixes or skipping its own crash-restart logic).
37
+
38
+ The supervisor stores the active child handle on a module-level variable:
39
+
40
+ ```typescript
41
+ let childProcess: ChildProcess | null = null;
42
+ let pendingRestart = false;
43
+ let restartTimer: NodeJS.Timeout | null = null;
44
+ let crashRestartTimer: NodeJS.Timeout | null = null;
45
+ let childBootStartedAt = 0;
46
+ let isShuttingDown = false;
47
+ ```
48
+
49
+ This is intentionally global to the file — the supervisor is a single-purpose entry script, not a library.
50
+
51
+ ---
52
+
53
+ ## `CORE_WATCH_GLOBS`
54
+
55
+ ```typescript
56
+ const CORE_WATCH_GLOBS = [
57
+ 'config.ts',
58
+ '.env',
59
+ '.env.local',
60
+ 'server/server.ts',
61
+ 'server/bootstrap/**/*.ts',
62
+ 'server/auth/**/*.ts',
63
+ 'server/functions/db.ts',
64
+ 'server/functions/redis.ts',
65
+ 'server/functions/sentry.ts',
66
+ ];
67
+ ```
68
+
69
+ These are files that cannot be hot-reloaded inside the same Node process — they own framework-wide singletons (Prisma client, Redis client, Sentry SDK, Socket.io server, OAuth providers). Changing any of them requires a fresh boot.
70
+
71
+ Everything not in this list — API/sync files, page components, shared helpers, locale `.json`, `_components/*` — is hot-reloaded by `setupWatchers()` running inside the child (see `hot-reload.md`).
72
+
73
+ Adding new globs here should be deliberate. The rule of thumb: if mutation can be reflected by reassigning a module-level binding inside `@luckystack/core` (the `registerXxx` extension points), it can hot-reload; if it needs to re-run a top-level `new SomeClient(...)`, it needs a supervisor restart.
74
+
75
+ ---
76
+
77
+ ## Restart debouncing
78
+
79
+ ```typescript
80
+ const RESTART_DEBOUNCE_MS = 150;
81
+ const CRASH_RESTART_DELAY_MS = 300;
82
+
83
+ const scheduleRestart = ({ event, changedPath }: { event: string; changedPath: string }) => {
84
+ if (restartTimer) clearTimeout(restartTimer);
85
+
86
+ restartTimer = setTimeout(() => {
87
+ restartTimer = null;
88
+ pendingRestart = true;
89
+
90
+ console.log(`[Supervisor] Core change detected (${event}): ${changedPath}. Restarting server`, 'yellow');
91
+
92
+ if (!childProcess) {
93
+ pendingRestart = false;
94
+ startChild();
95
+ return;
96
+ }
97
+
98
+ childProcess.kill('SIGTERM');
99
+ }, RESTART_DEBOUNCE_MS);
100
+ };
101
+ ```
102
+
103
+ Two constants:
104
+
105
+ - **`RESTART_DEBOUNCE_MS = 150`** — coalesces bursts of save events (e.g. a project-wide rename) into a single restart.
106
+ - **`CRASH_RESTART_DELAY_MS = 300`** — delay before re-spawning after a non-zero exit, so a syntax error doesn't restart-loop at full speed.
107
+
108
+ The flow on a save:
109
+
110
+ 1. Watcher fires `all` -> `scheduleRestart`.
111
+ 2. Existing timer cleared; new timer queued at 150 ms.
112
+ 3. Timer expires: `pendingRestart = true`, then either start a fresh child (no current) or `kill('SIGTERM')` the running one.
113
+ 4. The child's `exit` handler sees `pendingRestart === true` and respawns.
114
+
115
+ `pendingRestart` is the bridge between the kill and the respawn. It's needed because the child exit handler runs asynchronously; the supervisor must remember that this particular exit was intentional.
116
+
117
+ ---
118
+
119
+ ## State machine
120
+
121
+ ```
122
+ +----------------+
123
+ | no child |
124
+ +-------+--------+
125
+ | startChild()
126
+ v
127
+ +----------------+
128
+ file change ---> | running |
129
+ +-------+--------+
130
+ |
131
+ +---------------+----------------+
132
+ | | |
133
+ v v v
134
+ SIGTERM exit 0 exit code != 0
135
+ (intentional) (graceful, (crash)
136
+ | user Ctrl+C
137
+ v | |
138
+ respawn after stop (or schedule respawn
139
+ 150ms debounce process.exit after 300ms
140
+ coalesce if isShuttingDown)
141
+ ```
142
+
143
+ Key transitions in the `exit` handler:
144
+
145
+ ```typescript
146
+ childProcess.on('exit', (code, signal) => {
147
+ const uptimeMs = Math.round(performance.now() - childBootStartedAt);
148
+ const shouldRestart = pendingRestart;
149
+ childProcess = null;
150
+
151
+ if (isShuttingDown) {
152
+ process.exit(0);
153
+ }
154
+
155
+ if (shouldRestart) {
156
+ pendingRestart = false;
157
+ console.log(`[Supervisor] Restarting server after ${String(uptimeMs)}ms uptime`, 'yellow');
158
+ startChild();
159
+ return;
160
+ }
161
+
162
+ if (signal === 'SIGTERM' || signal === 'SIGINT') {
163
+ return;
164
+ }
165
+
166
+ if (typeof code === 'number' && code !== 0) {
167
+ console.log(`[Supervisor] Server crashed with code ${String(code)}. Restarting in ${String(CRASH_RESTART_DELAY_MS)}ms`, 'red');
168
+ crashRestartTimer = setTimeout(() => {
169
+ crashRestartTimer = null;
170
+ startChild();
171
+ }, CRASH_RESTART_DELAY_MS);
172
+ }
173
+ });
174
+ ```
175
+
176
+ Cases handled:
177
+
178
+ | Exit signal/code | Behavior |
179
+ |---|---|
180
+ | `isShuttingDown` | `process.exit(0)` — Ctrl+C raced with a crash, do not respawn |
181
+ | `pendingRestart === true` | Respawn immediately, log uptime |
182
+ | Signal `SIGTERM` / `SIGINT` (not from us) | Treat as ordered shutdown, do not respawn |
183
+ | `code === 0` | Clean exit, do not respawn |
184
+ | `code !== 0` | Schedule a `setTimeout(startChild, 300)` respawn |
185
+
186
+ The `isShuttingDown` guard at the top is critical: without it, a crash that raced with Ctrl+C would re-spawn indefinitely because `pendingRestart` is independent of the shutdown flag.
187
+
188
+ ---
189
+
190
+ ## Shutdown handling
191
+
192
+ ```typescript
193
+ const shutdownSupervisor = () => {
194
+ isShuttingDown = true;
195
+
196
+ if (restartTimer) {
197
+ clearTimeout(restartTimer);
198
+ restartTimer = null;
199
+ }
200
+ if (crashRestartTimer) {
201
+ clearTimeout(crashRestartTimer);
202
+ crashRestartTimer = null;
203
+ }
204
+
205
+ pendingRestart = false;
206
+
207
+ if (childProcess) {
208
+ childProcess.kill('SIGTERM');
209
+ setTimeout(() => process.exit(0), 1500).unref();
210
+ } else {
211
+ process.exit(0);
212
+ }
213
+ };
214
+
215
+ const handleSignal = (signalName: string) => {
216
+ if (isShuttingDown) {
217
+ process.exit(1); // second Ctrl+C — hard exit
218
+ }
219
+ isShuttingDown = true;
220
+ console.log(`[Supervisor] Received ${signalName}, shutting down`, 'yellow');
221
+ void watcher.close().then(() => {
222
+ shutdownSupervisor();
223
+ }).catch(() => {
224
+ shutdownSupervisor();
225
+ });
226
+ };
227
+
228
+ process.on('SIGINT', () => handleSignal('SIGINT'));
229
+ process.on('SIGTERM', () => handleSignal('SIGTERM'));
230
+ ```
231
+
232
+ Notes:
233
+
234
+ - **`isShuttingDown` is set synchronously** at the top of `handleSignal` so any in-flight child `exit` handler running BEFORE `watcher.close()` resolves can't schedule a new spawn.
235
+ - **Second Ctrl+C exits with code 1** immediately, bypassing the graceful watcher close. The user is impatient; this is the right move.
236
+ - **`setTimeout(process.exit, 1500).unref()`** is the force-exit grace period. On Windows, child processes sometimes ignore `SIGTERM`; the unrefed timer guarantees the supervisor exits even if the child sits.
237
+ - **Watcher close is awaited** so chokidar releases file handles before exit (matters on Windows where unreleased handles block subsequent processes).
238
+
239
+ ---
240
+
241
+ ## Production mode
242
+
243
+ ```typescript
244
+ if (env.NODE_ENV === 'production') {
245
+ startChild();
246
+ } else {
247
+ const watcher = watch(CORE_WATCH_GLOBS, {
248
+ ignoreInitial: true,
249
+ awaitWriteFinish: {
250
+ stabilityThreshold: 120,
251
+ pollInterval: 20,
252
+ },
253
+ });
254
+
255
+ watcher.on('all', (event, changedPath) => {
256
+ scheduleRestart({ event, changedPath });
257
+ });
258
+
259
+ process.on('SIGINT', () => handleSignal('SIGINT'));
260
+ process.on('SIGTERM', () => handleSignal('SIGTERM'));
261
+
262
+ startChild();
263
+ }
264
+ ```
265
+
266
+ In production:
267
+
268
+ - No file watcher.
269
+ - Only `startChild()` is called.
270
+ - The crash-restart timer still fires on non-zero exit (the `exit` handler always runs), so the supervisor doubles as a minimal process keeper for prod boots — useful when running directly without a system supervisor (PM2, systemd, etc.).
271
+ - Signal handlers are NOT registered in the prod branch above; the child receives signals directly (or via the system supervisor), and crash-restart is the only behavior.
272
+
273
+ `env.NODE_ENV` is read through `@luckystack/core`'s env helper, not directly from `process.env`, so any project-level env preprocessing (e.g. loading `.env.local` overrides) applies before the branch.
274
+
275
+ ---
276
+
277
+ ## Edge cases
278
+
279
+ - **Child exits between two debounce ticks.** The first tick set `pendingRestart = true` and killed the child. The child's exit handler respawns. The second tick fires, but `childProcess` is already a fresh process — it kills the new child too. This is correct: both saves are part of the same coalesced restart from the user's perspective.
280
+ - **Watcher event arrives during shutdown.** `handleSignal` sets `isShuttingDown = true` synchronously; `scheduleRestart` does not check this flag, but its eventual `startChild()` call DOES (`if (isShuttingDown) return;`). The kill+respawn sequence is short-circuited.
281
+ - **SIGTERM ignored by Windows child.** `shutdownSupervisor` schedules a 1500 ms force-exit via `setTimeout(process.exit, 1500).unref()`. The unref means the timer doesn't keep the event loop alive if the child does exit cleanly.
282
+ - **Rapid Ctrl+C double-tap.** First Ctrl+C: `handleSignal` flags shutdown, calls `watcher.close()`. Second Ctrl+C: `handleSignal` sees `isShuttingDown === true` and calls `process.exit(1)` immediately — no waiting for watchers or children.
283
+ - **Respawn loop after a config syntax error.** Child crashes with non-zero exit -> 300 ms crash-restart timer -> child crashes again. The supervisor will loop until the user fixes the config or kills it. There is no exponential backoff currently.
284
+
285
+ ---
286
+
287
+ ## Operational guidance
288
+
289
+ - The supervisor is NOT the place to add type-map regeneration — that lives in `setupWatchers` inside the child (`hot-reload.md`). The supervisor only handles restarts.
290
+ - Add new globs to `CORE_WATCH_GLOBS` only when a file change cannot be picked up by hot reload. Most additions are unnecessary — hot reload handles `_api/`, `_sync/`, components, helpers, locales, and the import dependency graph for shared modules.
291
+ - The supervisor doesn't manage ports. The child binds whatever port the framework / config specifies. If the previous child exits cleanly on SIGTERM, the port is released before the new child starts; if it doesn't, the new child's `EADDRINUSE` is a config bug, not a supervisor bug.
292
+ - Logs from the supervisor are prefixed `[Supervisor]` and colored cyan/yellow/red. They appear alongside child logs because of `stdio: 'inherit'`.
@@ -0,0 +1,136 @@
1
+ # Template customization
2
+
3
+ `@luckystack/devkit` injects starter content into newly-created **empty** files
4
+ under `src/` (a new `_api/*.ts`, `_sync/*.ts`, or `page.tsx`). Two things are
5
+ customizable, both from the consumer's `.luckystack/templates/` folder:
6
+
7
+ 1. **Which** template a file gets — the *selection rules*.
8
+ 2. **What** that template contains — the *content*.
9
+
10
+ Everything here is **dev-only**: devkit reads it from the hot-reload path during
11
+ `npm run server`. Nothing ships to end users.
12
+
13
+ ---
14
+
15
+ ## How a file gets a template
16
+
17
+ `injectTemplate(filePath)` (called by the file watcher on empty-file creation):
18
+
19
+ 1. **Classify** the file structurally into a `fileKind`:
20
+ `'api' | 'sync_server' | 'sync_client' | 'page'` (derived from the route
21
+ conventions — override the markers via `registerRoutingRules`). For pages,
22
+ placement is validated first; an un-routable placement gets a commented
23
+ diagnostic instead of a real template.
24
+ 2. **Select a kind** via `resolveTemplateKind(ctx)` — the registered rules are
25
+ evaluated by descending `priority` (ties: newest registration first), and the
26
+ first matching rule's `kind` wins.
27
+ 3. **Resolve content** for that kind, first hit wins:
28
+ - `.luckystack/templates/<kind>.template.ts(x)` — consumer file
29
+ - a `registerTemplate('<kind>', '...')` string override
30
+ - the bundled default shipped inside devkit (`dist/templates/`)
31
+ 4. **Substitute placeholders** (`{{REL_PATH}}`, `{{PAGE_PATH}}`, `{{SYNC_NAME}}`)
32
+ and write the file.
33
+
34
+ The match context is:
35
+
36
+ ```ts
37
+ interface TemplateMatchContext {
38
+ filePath: string; // absolute path of the new file
39
+ fileKind: 'api' | 'sync_server' | 'sync_client' | 'page';
40
+ hasPairedServer: boolean; // sync_client: does *_server_v<N>.ts exist?
41
+ srcRelativePath: string | null; // path relative to src/, or null
42
+ }
43
+ ```
44
+
45
+ ---
46
+
47
+ ## The consumer overlay: `.luckystack/templates/`
48
+
49
+ `create-luckystack-app` scaffolds this folder. devkit auto-loads
50
+ `.luckystack/templates/templateRules.ts` **once, in dev, before the first
51
+ injection** (a plain dynamic import — no wiring needed). Absent file ⇒ devkit's
52
+ built-in defaults apply.
53
+
54
+ ```
55
+ .luckystack/templates/
56
+ templateRules.ts # the selection logic — edit/remove/add rules
57
+ api.template.ts # editable copies of the built-in template bodies
58
+ sync_server.template.ts
59
+ sync_client_paired.template.ts
60
+ sync_client_standalone.template.ts
61
+ page_plain.template.tsx
62
+ page_dashboard.template.tsx
63
+ README.md
64
+ ```
65
+
66
+ A `*.template.*` file in this folder overrides that kind's content. **Delete** a
67
+ file to fall back to devkit's (upgradeable) bundled default. The shipped copies
68
+ are a snapshot of the defaults at scaffold time — to refresh, copy from
69
+ `node_modules/@luckystack/devkit/dist/templates/`.
70
+
71
+ ---
72
+
73
+ ## API
74
+
75
+ | Export | Purpose |
76
+ |---|---|
77
+ | `registerTemplateRule({ kind, match, priority? })` | Add a selection rule. `priority` default for direct calls is the caller's; defaults use 10 (specific) / 0 (page catch-all). |
78
+ | `registerTemplateKind(kind, { match, content?, priority? })` | Register a brand-new kind (rule + optional inline content) in one call. Default priority 100 so custom kinds beat the built-ins. |
79
+ | `registerTemplate(kind, content)` | Override just the content body of a kind (string). |
80
+ | `resolveTemplateKind(ctx)` | Evaluate the active rules → the chosen kind (or null). |
81
+ | `getTemplateRules()` | The active rules in evaluation order. |
82
+ | `clearTemplateRules()` | Drop ALL rules — including the built-in defaults. The scaffolded `templateRules.ts` calls this first so it is the single source of truth. |
83
+ | `registerDefaultTemplateRules()` | (Re)arm the framework defaults. Armed automatically on module load; idempotent. |
84
+ | `BUILT_IN_TEMPLATE_KINDS` / `BUILT_IN_TEMPLATE_FILENAMES` / `DEFAULT_DASHBOARD_PATH_PATTERN` | The 6 kinds, their bundled filenames, and the page-dashboard heuristic regex. |
85
+
86
+ Built-in kinds: `api`, `sync_server`, `sync_client_paired`,
87
+ `sync_client_standalone`, `page_plain`, `page_dashboard`.
88
+
89
+ ---
90
+
91
+ ## Recipes
92
+
93
+ ### Change when a page gets the dashboard layout
94
+
95
+ Edit the `page_dashboard` rule in `.luckystack/templates/templateRules.ts`:
96
+
97
+ ```ts
98
+ registerTemplateRule({
99
+ kind: 'page_dashboard',
100
+ priority: 10,
101
+ match: (ctx) => ctx.fileKind === 'page' && ctx.filePath.replaceAll('\\', '/').includes('/app/'),
102
+ });
103
+ ```
104
+
105
+ ### Remove a rule entirely
106
+
107
+ The scaffolded `templateRules.ts` starts with `clearTemplateRules()` and then
108
+ re-declares each default. Delete the rule you don't want — that mapping no
109
+ longer applies (a page with no matching rule simply isn't injected).
110
+
111
+ ### Add a brand-new template kind
112
+
113
+ ```ts
114
+ // .luckystack/templates/templateRules.ts
115
+ import { registerTemplateKind } from '@luckystack/devkit';
116
+
117
+ registerTemplateKind('page_marketing', {
118
+ priority: 20, // beats page_dashboard / page_plain
119
+ match: (ctx) => ctx.fileKind === 'page' && ctx.filePath.replaceAll('\\', '/').includes('/marketing/'),
120
+ });
121
+ ```
122
+
123
+ Then create `.luckystack/templates/page_marketing.template.tsx` with the body.
124
+ Custom kinds resolve content from `<kind>.template.tsx` then `<kind>.template.ts`
125
+ in the overlay folder (or a `registerTemplate('page_marketing', '...')` string).
126
+
127
+ ---
128
+
129
+ ## Packaging note (framework maintainers)
130
+
131
+ The bundled templates live at `packages/devkit/src/templates/`. They are read at
132
+ runtime via `fs.readFileSync(dist/templates/...)`, so `tsup.config.ts` copies
133
+ `src/templates → dist/templates` in its `onSuccess` hook and `files: ["dist"]`
134
+ ships them in the tarball. The scaffolded consumer copies live separately under
135
+ `create-luckystack-app/template/_dot_luckystack/templates/` (the `_dot_` prefix
136
+ is rewritten to `.` by the scaffold so npm doesn't drop the dot-folder).
@@ -0,0 +1,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.** `SKIP_EXPANSION` — `Promise`, `Map`, `WeakMap`, `Set`, `WeakSet`, `Error`, `Date`, `RegExp`, `Buffer`, `ArrayBuffer`, `ReadonlyArray` — are returned as `checker.typeToString(type)` without recursion into internals. Their structural shape is irrelevant to API/sync wire types.
115
+ - **Arrays.** `Array<T>` and `ReadonlyArray<T>` are rendered as `T[]`. Union/intersection element types are parenthesized: `(A | B)[]` not `A | B[]`.
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`. A consumer with a different `typescript` version may produce different inlined output (`checker.typeToString` formatting drifts across TS versions). 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 that pass through unexpanded.
198
+
199
+ 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.