@malloy-publisher/server 0.0.198-dev3 → 0.0.198-dev4

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,721 @@
1
+ /**
2
+ * Compile worker entry point.
3
+ *
4
+ * Runs inside a worker_threads `Worker`. Owns no DuckDB / native
5
+ * connection state — every schema lookup is proxied back to the
6
+ * main thread via {@link SchemaForTablesRequest} / {@link
7
+ * SchemaForSqlRequest}. The point of this file is to take the
8
+ * dominant CPU cost of a Malloy compile (parser, type checker, IR
9
+ * builder, sourceInfo extraction) off the main event loop so the
10
+ * Kubernetes liveness probe on `/health/liveness` never gets parked
11
+ * behind a multi-second compile.
12
+ *
13
+ * Contract:
14
+ * - Receives {@link CompileJobRequest} messages from the parent
15
+ * port. Dispatches one compile per message.
16
+ * - Proxies schema and URL-reader operations back to the parent
17
+ * via correlated RPC requests; awaits matching responses.
18
+ * - Sends back exactly one {@link CompileJobResult} or {@link
19
+ * CompileJobError} per job.
20
+ * - Honours a graceful {@link ShutdownRequest} so the pool can
21
+ * drain on SIGTERM.
22
+ *
23
+ * This file is bundled separately by build.ts and shipped as
24
+ * `dist/compile_worker.mjs`.
25
+ */
26
+ import {
27
+ contextOverlay,
28
+ MalloyConfig,
29
+ MalloyError,
30
+ Runtime,
31
+ isSourceDef,
32
+ modelDefToModelInfo,
33
+ type Annotation,
34
+ type Connection,
35
+ type FetchSchemaOptions,
36
+ type LookupConnection,
37
+ type ModelDef,
38
+ type ModelMaterializer,
39
+ type NamedModelObject,
40
+ type NamedQueryDef,
41
+ type SQLSourceDef,
42
+ type SQLSourceRequest,
43
+ type StructDef,
44
+ type TableSourceDef,
45
+ type TurtleDef,
46
+ } from "@malloydata/malloy";
47
+ import * as Malloy from "@malloydata/malloy-interfaces";
48
+ import * as fs from "fs";
49
+ import { parentPort, threadId } from "node:worker_threads";
50
+ import { fileURLToPath } from "url";
51
+ import { parseFilters, type FilterDefinition } from "../service/filter";
52
+ import type {
53
+ CompileJobError,
54
+ CompileJobRequest,
55
+ CompileJobResult,
56
+ ConnectionMetadata,
57
+ ConnectionMetadataRequest,
58
+ ConnectionMetadataResponse,
59
+ MainToWorkerMessage,
60
+ ReadUrlRequest,
61
+ ReadUrlResponse,
62
+ SchemaForSqlRequest,
63
+ SchemaForSqlResponse,
64
+ SchemaForTablesRequest,
65
+ SchemaForTablesResponse,
66
+ SerializedError,
67
+ } from "./protocol";
68
+
69
+ if (!parentPort) {
70
+ throw new Error(
71
+ "compile_worker.ts must be loaded inside a worker_threads Worker",
72
+ );
73
+ }
74
+
75
+ const port = parentPort;
76
+
77
+ // ──────────────────────────────────────────────────────────────────────
78
+ // RPC plumbing for worker → main calls
79
+ // ──────────────────────────────────────────────────────────────────────
80
+
81
+ let nextRpcId = 0;
82
+ const pendingRpc = new Map<
83
+ string,
84
+ { resolve: (value: unknown) => void; reject: (err: Error) => void }
85
+ >();
86
+
87
+ function newRpcId(): string {
88
+ nextRpcId += 1;
89
+ return `w${threadId}-rpc-${nextRpcId}`;
90
+ }
91
+
92
+ function callMain<T>(send: (requestId: string) => void): Promise<T> {
93
+ const requestId = newRpcId();
94
+ return new Promise<T>((resolve, reject) => {
95
+ pendingRpc.set(requestId, {
96
+ resolve: (value) => resolve(value as T),
97
+ reject,
98
+ });
99
+ send(requestId);
100
+ });
101
+ }
102
+
103
+ function dispatchMainResponse(message: MainToWorkerMessage): void {
104
+ if (
105
+ message.type === "schema-for-tables-response" ||
106
+ message.type === "schema-for-sql-response" ||
107
+ message.type === "read-url-response" ||
108
+ message.type === "connection-metadata-response"
109
+ ) {
110
+ const pending = pendingRpc.get(message.requestId);
111
+ if (!pending) return;
112
+ pendingRpc.delete(message.requestId);
113
+ pending.resolve(message);
114
+ return;
115
+ }
116
+ if (message.type === "rpc-error") {
117
+ const pending = pendingRpc.get(message.requestId);
118
+ if (!pending) return;
119
+ pendingRpc.delete(message.requestId);
120
+ pending.reject(deserializeError(message.error));
121
+ return;
122
+ }
123
+ }
124
+
125
+ // ──────────────────────────────────────────────────────────────────────
126
+ // Stub InfoConnection that proxies schema fetches to the main thread
127
+ // ──────────────────────────────────────────────────────────────────────
128
+
129
+ /**
130
+ * Minimal `Connection` implementation that satisfies Malloy's compile
131
+ * pipeline. Only the methods called during compile are implemented
132
+ * meaningfully; the rest throw, since the worker never executes SQL.
133
+ *
134
+ * Holds the `jobId` so the main thread can route the schema RPC to
135
+ * the right environment-side `MalloyConfig`.
136
+ */
137
+ class ProxyConnection {
138
+ public readonly name: string;
139
+ public readonly dialectName: string;
140
+ private readonly digest: string;
141
+ private readonly jobId: string;
142
+
143
+ constructor(metadata: ConnectionMetadata, jobId: string) {
144
+ this.name = metadata.name;
145
+ this.dialectName = metadata.dialectName;
146
+ this.digest = metadata.digest;
147
+ this.jobId = jobId;
148
+ }
149
+
150
+ getDigest(): string {
151
+ return this.digest;
152
+ }
153
+
154
+ async fetchSchemaForTables(
155
+ tables: Record<string, string>,
156
+ options: FetchSchemaOptions,
157
+ ): Promise<{
158
+ schemas: Record<string, TableSourceDef>;
159
+ errors: Record<string, string>;
160
+ }> {
161
+ const response = await callMain<SchemaForTablesResponse>((requestId) => {
162
+ const req: SchemaForTablesRequest = {
163
+ type: "schema-for-tables",
164
+ requestId,
165
+ jobId: this.jobId,
166
+ connectionName: this.name,
167
+ tables,
168
+ options: serializeFetchOptions(options),
169
+ };
170
+ port.postMessage(req);
171
+ });
172
+ return { schemas: response.schemas, errors: response.errors };
173
+ }
174
+
175
+ async fetchSchemaForSQLStruct(
176
+ sentence: SQLSourceRequest,
177
+ options: FetchSchemaOptions,
178
+ ): Promise<
179
+ | { structDef: SQLSourceDef; error?: undefined }
180
+ | { error: string; structDef?: undefined }
181
+ > {
182
+ const response = await callMain<SchemaForSqlResponse>((requestId) => {
183
+ const req: SchemaForSqlRequest = {
184
+ type: "schema-for-sql",
185
+ requestId,
186
+ jobId: this.jobId,
187
+ connectionName: this.name,
188
+ sentence: sentence as unknown,
189
+ options: serializeFetchOptions(options),
190
+ };
191
+ port.postMessage(req);
192
+ });
193
+ if (response.error !== undefined) {
194
+ return { error: response.error };
195
+ }
196
+ if (response.structDef === undefined) {
197
+ return { error: "Empty SQL schema response from main thread" };
198
+ }
199
+ return { structDef: response.structDef };
200
+ }
201
+
202
+ // Compile path never calls these. We intentionally throw rather
203
+ // than silently no-op so a misrouted query in a worker surfaces
204
+ // as a loud error rather than a wrong-answer bug.
205
+ async runSQL(): Promise<never> {
206
+ throw new Error(
207
+ `ProxyConnection(${this.name}): runSQL is not available in compile workers`,
208
+ );
209
+ }
210
+ isPool(): false {
211
+ return false;
212
+ }
213
+ canPersist(): false {
214
+ return false;
215
+ }
216
+ canStream(): false {
217
+ return false;
218
+ }
219
+ async close(): Promise<void> {
220
+ /* no-op */
221
+ }
222
+ async idle(): Promise<void> {
223
+ /* no-op */
224
+ }
225
+ async estimateQueryCost(): Promise<never> {
226
+ throw new Error(
227
+ `ProxyConnection(${this.name}): estimateQueryCost not available in compile workers`,
228
+ );
229
+ }
230
+ async fetchMetadata(): Promise<Record<string, unknown>> {
231
+ return {};
232
+ }
233
+ async fetchTableMetadata(): Promise<Record<string, unknown>> {
234
+ return {};
235
+ }
236
+ }
237
+
238
+ function serializeFetchOptions(options: FetchSchemaOptions): {
239
+ refreshTimestamp?: number;
240
+ modelAnnotation?: Annotation;
241
+ } {
242
+ const out: { refreshTimestamp?: number; modelAnnotation?: Annotation } = {};
243
+ if (options.refreshTimestamp !== undefined) {
244
+ out.refreshTimestamp = options.refreshTimestamp;
245
+ }
246
+ if (options.modelAnnotation !== undefined) {
247
+ out.modelAnnotation = options.modelAnnotation;
248
+ }
249
+ return out;
250
+ }
251
+
252
+ // ──────────────────────────────────────────────────────────────────────
253
+ // URLReader: try fs first, fall back to main-thread RPC for non-file URLs
254
+ // ──────────────────────────────────────────────────────────────────────
255
+
256
+ function makeWorkerUrlReader(jobId: string) {
257
+ return {
258
+ readURL: async (url: URL): Promise<string> => {
259
+ if (url.protocol === "file:") {
260
+ const filePath = fileURLToPath(url);
261
+ return fs.promises.readFile(filePath, "utf8");
262
+ }
263
+ // Non-file URL — delegate to main so semantics stay
264
+ // identical to the in-process URL_READER (e.g. allow
265
+ // future https:// imports).
266
+ const response = await callMain<ReadUrlResponse>((requestId) => {
267
+ const req: ReadUrlRequest = {
268
+ type: "read-url",
269
+ requestId,
270
+ jobId,
271
+ url: url.toString(),
272
+ };
273
+ port.postMessage(req);
274
+ });
275
+ return response.contents;
276
+ },
277
+ };
278
+ }
279
+
280
+ // ──────────────────────────────────────────────────────────────────────
281
+ // MalloyConfig assembly inside the worker
282
+ // ──────────────────────────────────────────────────────────────────────
283
+
284
+ function buildWorkerMalloyConfig(job: CompileJobRequest): MalloyConfig {
285
+ // Connections are resolved lazily on first lookup via a metadata
286
+ // RPC back to the main thread — see ConnectionMetadataRequest.
287
+ // We never enumerate the connection list upfront; the package
288
+ // layer doesn't always have one (e.g. environment-wrapped
289
+ // connections appear only when Malloy compiles a `table('...')`
290
+ // reference that names them).
291
+ //
292
+ // Concurrent lookups for the same name are deduped via
293
+ // `inflight` — Malloy's compile pipeline can fan-out multiple
294
+ // schema fetches that all hit `lookupConnection(name)` before
295
+ // any of them resolve, and we don't want to N-multiply the RPC.
296
+ const proxies = new Map<string, ProxyConnection>();
297
+ const inflight = new Map<string, Promise<ProxyConnection>>();
298
+ const config = new MalloyConfig(
299
+ { connections: {} },
300
+ {
301
+ config: contextOverlay({ rootDirectory: job.packagePath }),
302
+ },
303
+ );
304
+ config.wrapConnections(
305
+ (_base: LookupConnection<Connection>): LookupConnection<Connection> => ({
306
+ lookupConnection: async (name?: string): Promise<Connection> => {
307
+ const effectiveName = name ?? job.defaultConnectionName ?? "duckdb";
308
+ const cached = proxies.get(effectiveName);
309
+ if (cached) return cached as unknown as Connection;
310
+ let pending = inflight.get(effectiveName);
311
+ if (!pending) {
312
+ pending = (async () => {
313
+ const response = await callMain<ConnectionMetadataResponse>(
314
+ (requestId) => {
315
+ const req: ConnectionMetadataRequest = {
316
+ type: "connection-metadata",
317
+ requestId,
318
+ jobId: job.requestId,
319
+ connectionName: effectiveName,
320
+ };
321
+ port.postMessage(req);
322
+ },
323
+ );
324
+ const proxy = new ProxyConnection(
325
+ response.metadata,
326
+ job.requestId,
327
+ );
328
+ proxies.set(effectiveName, proxy);
329
+ inflight.delete(effectiveName);
330
+ return proxy;
331
+ })();
332
+ inflight.set(effectiveName, pending);
333
+ }
334
+ const proxy = await pending;
335
+ return proxy as unknown as Connection;
336
+ },
337
+ }),
338
+ );
339
+ return config;
340
+ }
341
+
342
+ // ──────────────────────────────────────────────────────────────────────
343
+ // The actual compile — mirrors Model.create's in-process flow but
344
+ // only the parts that produce data shippable across postMessage.
345
+ // ──────────────────────────────────────────────────────────────────────
346
+
347
+ async function compile(job: CompileJobRequest): Promise<CompileJobResult> {
348
+ const compileStart = performance.now();
349
+
350
+ const malloyConfig = buildWorkerMalloyConfig(job);
351
+ const urlReader = makeWorkerUrlReader(job.requestId);
352
+
353
+ const runtime = new Runtime({
354
+ urlReader,
355
+ config: malloyConfig,
356
+ // job.buildManifest is wire-typed as `unknown` because the
357
+ // worker protocol doesn't depend on Malloy types — assert
358
+ // the shape Malloy's Runtime expects here. We only pass the
359
+ // wrapper when the caller actually supplied entries.
360
+ buildManifest:
361
+ job.buildManifest !== undefined && job.buildManifest !== null
362
+ ? {
363
+ entries: job.buildManifest as Record<
364
+ string,
365
+ import("@malloydata/malloy").BuildManifestEntry
366
+ >,
367
+ strict: false,
368
+ }
369
+ : undefined,
370
+ });
371
+
372
+ // Two compile shapes:
373
+ // 1. File-backed: `modelPath` resolves to a file:// URL the runtime
374
+ // reads via urlReader. The importBaseURL is the model's
375
+ // directory.
376
+ // 2. Inline-source: `inlineSource` is a Malloy string the runtime
377
+ // compiles directly. Mostly used by synthesized snippets like
378
+ // `source: temp is duckdb.table('<path>')` from the package
379
+ // database-info probe. We use the caller-provided importBaseURL
380
+ // (or fall back to the package root) so any `import "…"`
381
+ // statements in the snippet resolve correctly.
382
+ const isInline = typeof job.inlineSource === "string";
383
+ if (!isInline && typeof job.modelPath !== "string") {
384
+ throw new Error(
385
+ "CompileJobRequest must supply either inlineSource or modelPath",
386
+ );
387
+ }
388
+ const importBaseURL = isInline
389
+ ? new URL(job.importBaseURL ?? `file://${job.packagePath}/`)
390
+ : new URL(".", new URL(`file://${job.packagePath}/${job.modelPath}`));
391
+
392
+ const mm: ModelMaterializer = isInline
393
+ ? runtime.loadModel(job.inlineSource as string, { importBaseURL })
394
+ : runtime.loadModel(
395
+ new URL(`file://${job.packagePath}/${job.modelPath}`),
396
+ { importBaseURL },
397
+ );
398
+
399
+ const compiledModel = await mm.getModel();
400
+ const modelDef = compiledModel._modelDef as ModelDef;
401
+
402
+ // Givens — converted to API shape here so the main thread can
403
+ // stash them on the Model without needing Malloy's MalloyGiven
404
+ // type (which has non-serializable methods).
405
+ const malloyGivens = Array.from(compiledModel.givens.values());
406
+ const givens: ApiGivenWire[] | undefined =
407
+ malloyGivens.length > 0
408
+ ? malloyGivens.map((g) => malloyGivenToWire(g))
409
+ : undefined;
410
+
411
+ // Imported sourceInfos — mirrors Model.create line 199–242. We
412
+ // collect them here so the main thread doesn't have to recompile
413
+ // imports just to fill in the response.
414
+ const sourceInfos: Malloy.SourceInfo[] = [];
415
+ const importedSourceNames = new Set<string>();
416
+ const imports = modelDef.imports ?? [];
417
+ for (const importLocation of imports) {
418
+ try {
419
+ const modelString = await urlReader.readURL(
420
+ new URL(importLocation.importURL),
421
+ );
422
+ const importedModelDef = (
423
+ await runtime.loadModel(modelString, { importBaseURL }).getModel()
424
+ )._modelDef;
425
+ const importedInfo = modelDefToModelInfo(importedModelDef);
426
+ const importedSources = importedInfo.entries.filter(
427
+ (entry) => entry.kind === "source",
428
+ ) as Malloy.SourceInfo[];
429
+ for (const source of importedSources) {
430
+ if (!importedSourceNames.has(source.name)) {
431
+ sourceInfos.push(source);
432
+ importedSourceNames.add(source.name);
433
+ }
434
+ }
435
+ } catch {
436
+ // Best-effort, matches the in-process Model.create behaviour
437
+ // of warning-and-skipping when an import can't be loaded.
438
+ }
439
+ }
440
+ const localInfo = modelDefToModelInfo(modelDef);
441
+ const localSources = localInfo.entries.filter(
442
+ (entry) => entry.kind === "source",
443
+ ) as Malloy.SourceInfo[];
444
+ for (const source of localSources) {
445
+ if (!importedSourceNames.has(source.name)) {
446
+ sourceInfos.push(source);
447
+ }
448
+ }
449
+
450
+ // Inline-source compiles have no on-disk modelPath. extractSources /
451
+ // extractQueries use modelPath only to filter annotations by the URL
452
+ // they came from; the inline path has no such annotations to filter,
453
+ // so `""` (matches everything via `includes`) is the correct neutral.
454
+ const modelPathForAnnotations = job.modelPath ?? "";
455
+ const { sources, filterMap } = extractSources(
456
+ modelPathForAnnotations,
457
+ modelDef,
458
+ );
459
+ const queries = extractQueries(modelPathForAnnotations, modelDef);
460
+ const filterMapEntries: Array<[string, FilterDefinition[]]> = Array.from(
461
+ filterMap.entries(),
462
+ );
463
+
464
+ return {
465
+ type: "compile-result",
466
+ requestId: job.requestId,
467
+ modelDef,
468
+ sourceInfos,
469
+ sources,
470
+ queries,
471
+ filterMap: filterMapEntries,
472
+ givens,
473
+ // dataStyles: the in-process HackyDataStylesAccumulator is fed
474
+ // by the URLReader. We don't reuse it here — main thread will
475
+ // accumulate its own when it builds the lazy materializer.
476
+ dataStyles: {},
477
+ compileDurationMs: performance.now() - compileStart,
478
+ };
479
+ }
480
+
481
+ /**
482
+ * Wire-friendly mirror of the publisher's `ApiGiven`. Inlined here so
483
+ * the worker doesn't import the OpenAPI-generated `components` map
484
+ * (which would drag the whole api.ts surface into the worker bundle).
485
+ * Kept structurally identical to `ApiGiven` so the main thread can
486
+ * type-assert it without conversion.
487
+ */
488
+ interface ApiGivenWire {
489
+ name: string;
490
+ type: string;
491
+ annotations?: string[];
492
+ }
493
+
494
+ interface MalloyGivenLike {
495
+ name: string;
496
+ type: { type: string; filterType?: string };
497
+ getTaglines(regex: RegExp): string[];
498
+ }
499
+
500
+ function malloyGivenToWire(given: MalloyGivenLike): ApiGivenWire {
501
+ const t = given.type;
502
+ const renderedType =
503
+ t.type === "filter expression" ? `filter<${t.filterType}>` : t.type;
504
+ return {
505
+ name: given.name,
506
+ type: renderedType,
507
+ annotations: given.getTaglines(/^#\(/),
508
+ };
509
+ }
510
+
511
+ // ──────────────────────────────────────────────────────────────────────
512
+ // extractSources / extractQueries — direct copies of the static
513
+ // helpers in Model.ts. Inlined here to keep the worker independent
514
+ // of the main-thread module graph (smaller bundle, fewer imports of
515
+ // things that pull in DuckDB or AWS SDK by transitive include).
516
+ // ──────────────────────────────────────────────────────────────────────
517
+
518
+ interface ApiSource {
519
+ name: string;
520
+ annotations?: string[];
521
+ views?: { name: string; annotations?: string[] }[];
522
+ filters?: unknown[];
523
+ }
524
+ interface ApiQuery {
525
+ name: string;
526
+ sourceName?: string;
527
+ annotations?: string[];
528
+ }
529
+
530
+ function extractSources(
531
+ modelPath: string,
532
+ modelDef: ModelDef,
533
+ ): { sources: ApiSource[]; filterMap: Map<string, FilterDefinition[]> } {
534
+ const filterMap = new Map<string, FilterDefinition[]>();
535
+ const sources: ApiSource[] = Object.values(modelDef.contents)
536
+ .filter((obj) => isSourceDef(obj))
537
+ .map((sourceObj) => {
538
+ const sourceName =
539
+ (sourceObj as StructDef).as || (sourceObj as StructDef).name;
540
+ const annotations = (sourceObj as StructDef).annotation?.blockNotes
541
+ ?.filter((note) => note.at.url.includes(modelPath))
542
+ .map((note) => note.text);
543
+
544
+ const collected: string[][] = [];
545
+ let cur: Annotation | undefined = (sourceObj as StructDef).annotation;
546
+ while (cur) {
547
+ if (cur.blockNotes) {
548
+ collected.push(cur.blockNotes.map((note) => note.text));
549
+ }
550
+ cur = cur.inherits;
551
+ }
552
+ const allAnnotations = collected.reverse().flat();
553
+ let filters: unknown[] | undefined;
554
+ if (allAnnotations.length > 0) {
555
+ try {
556
+ const parsed = parseFilters(allAnnotations);
557
+ if (parsed.length > 0) {
558
+ filterMap.set(sourceName, parsed);
559
+ const fields = (sourceObj as StructDef).fields;
560
+ filters = parsed.map((f) => {
561
+ const field = fields.find(
562
+ (fd) => (fd.as || fd.name) === f.dimension,
563
+ );
564
+ return {
565
+ name: f.name,
566
+ dimension: f.dimension,
567
+ type: f.type,
568
+ implicit: f.implicit,
569
+ required: f.required,
570
+ dimensionType: field?.type as string | undefined,
571
+ };
572
+ });
573
+ }
574
+ } catch {
575
+ // Mirrors the in-process behaviour: filter parse
576
+ // errors are warnings, not fatal compile failures.
577
+ }
578
+ }
579
+
580
+ const views = (sourceObj as StructDef).fields
581
+ .filter((f) => f.type === "turtle")
582
+ .filter((turtle) =>
583
+ (turtle as TurtleDef).pipeline
584
+ .map((stage) => stage.type)
585
+ .every((type) => type === "reduce"),
586
+ )
587
+ .map((turtle) => ({
588
+ name: turtle.as || turtle.name,
589
+ annotations: turtle?.annotation?.blockNotes
590
+ ?.filter((note) => note.at.url.includes(modelPath))
591
+ .map((note) => note.text),
592
+ }));
593
+
594
+ return {
595
+ name: sourceName,
596
+ annotations,
597
+ views,
598
+ filters,
599
+ } as ApiSource;
600
+ });
601
+
602
+ return { sources, filterMap };
603
+ }
604
+
605
+ function extractQueries(modelPath: string, modelDef: ModelDef): ApiQuery[] {
606
+ const isNamedQuery = (obj: NamedModelObject): obj is NamedQueryDef =>
607
+ obj.type === "query";
608
+ return Object.values(modelDef.contents)
609
+ .filter(isNamedQuery)
610
+ .map((q) => ({
611
+ name: q.as || q.name,
612
+ sourceName: typeof q.structRef === "string" ? q.structRef : undefined,
613
+ annotations: q?.annotation?.blockNotes
614
+ ?.filter((note: { at: { url: string } }) =>
615
+ note.at.url.includes(modelPath),
616
+ )
617
+ .map((note: { text: string }) => note.text),
618
+ }));
619
+ }
620
+
621
+ // ──────────────────────────────────────────────────────────────────────
622
+ // Error serialization
623
+ // ──────────────────────────────────────────────────────────────────────
624
+
625
+ function serializeError(error: unknown): SerializedError {
626
+ if (error instanceof MalloyError) {
627
+ // MalloyError is what Malloy throws for compile failures
628
+ // (parse / type / unresolved-reference errors). Flagging
629
+ // `isCompilationError` lets the main thread re-wrap it as a
630
+ // `ModelCompilationError` so callers' instanceof checks for
631
+ // that type still fire after a worker-side compile.
632
+ return {
633
+ name: error.name,
634
+ message: error.message,
635
+ stack: error.stack,
636
+ malloyProblems: error.problems as unknown[],
637
+ isCompilationError: true,
638
+ };
639
+ }
640
+ if (error instanceof Error) {
641
+ return {
642
+ name: error.name,
643
+ message: error.message,
644
+ stack: error.stack,
645
+ };
646
+ }
647
+ return { name: "Error", message: String(error) };
648
+ }
649
+
650
+ function deserializeError(serialized: SerializedError): Error {
651
+ const err = new Error(serialized.message);
652
+ err.name = serialized.name;
653
+ if (serialized.stack) err.stack = serialized.stack;
654
+ return err;
655
+ }
656
+
657
+ // ──────────────────────────────────────────────────────────────────────
658
+ // Message dispatcher
659
+ // ──────────────────────────────────────────────────────────────────────
660
+
661
+ let shuttingDown = false;
662
+ const inFlightJobs = new Set<string>();
663
+
664
+ port.on("message", (message: MainToWorkerMessage) => {
665
+ if (message.type === "shutdown") {
666
+ shuttingDown = true;
667
+ // Don't exit until in-flight jobs finish. Once empty we exit
668
+ // via the explicit process.exit() below; until then we just
669
+ // keep servicing message responses.
670
+ maybeExit();
671
+ return;
672
+ }
673
+ if (message.type === "compile") {
674
+ if (shuttingDown) {
675
+ const errMsg: CompileJobError = {
676
+ type: "compile-error",
677
+ requestId: message.requestId,
678
+ error: {
679
+ name: "ShuttingDown",
680
+ message: "Compile worker is shutting down",
681
+ },
682
+ };
683
+ port.postMessage(errMsg);
684
+ return;
685
+ }
686
+ inFlightJobs.add(message.requestId);
687
+ void runJob(message);
688
+ return;
689
+ }
690
+ dispatchMainResponse(message);
691
+ });
692
+
693
+ async function runJob(job: CompileJobRequest): Promise<void> {
694
+ try {
695
+ const result = await compile(job);
696
+ port.postMessage(result);
697
+ } catch (error) {
698
+ const errMsg: CompileJobError = {
699
+ type: "compile-error",
700
+ requestId: job.requestId,
701
+ error: serializeError(error),
702
+ };
703
+ port.postMessage(errMsg);
704
+ } finally {
705
+ inFlightJobs.delete(job.requestId);
706
+ maybeExit();
707
+ }
708
+ }
709
+
710
+ function maybeExit(): void {
711
+ if (shuttingDown && inFlightJobs.size === 0 && pendingRpc.size === 0) {
712
+ // Give the postMessage queue a tick to flush before exit so the
713
+ // last result actually reaches the parent.
714
+ setImmediate(() => process.exit(0));
715
+ }
716
+ }
717
+
718
+ // Announce readiness — the pool waits for this before dispatching
719
+ // jobs to a newly-spawned worker so we don't race the worker's
720
+ // module-init time.
721
+ port.postMessage({ type: "ready" });