@malloy-publisher/server 0.0.198-dev → 0.0.198-dev1
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/README.docker.md +135 -20
- package/README.md +15 -0
- package/build.ts +42 -1
- package/dist/app/api-doc.yaml +51 -0
- package/dist/app/assets/EnvironmentPage-Dpee_Kn6.js +1 -0
- package/dist/app/assets/HomePage-DLRWTNoL.js +1 -0
- package/dist/app/assets/MainPage-DsVt5QGM.js +2 -0
- package/dist/app/assets/ModelPage-AwAugZ37.js +1 -0
- package/dist/app/assets/PackagePage-XQ-EWGTC.js +1 -0
- package/dist/app/assets/RouteError-3Mv8JQw7.js +1 -0
- package/dist/app/assets/WorkbookPage-DHYYpcYc.js +1 -0
- package/dist/app/assets/{core-w79IMXAG.es-Bd0UlzOL.js → core-DfcpQGVP.es-DQggNOdX.js} +14 -14
- package/dist/app/assets/{index-C513UodQ.js → index-BUp81Qdm.js} +15 -15
- package/dist/app/assets/index-D1pdwrUW.js +1803 -0
- package/dist/app/assets/index-Dv5bF4Ii.js +451 -0
- package/dist/app/assets/{index.umd-BMeMPq_9.js → index.umd-CQH4LZU8.js} +1 -1
- package/dist/app/index.html +2 -3
- package/dist/compile_worker.mjs +628 -0
- package/dist/default-publisher.config.json +23 -0
- package/dist/instrumentation.mjs +36 -38
- package/dist/server.mjs +2060 -913
- package/package.json +11 -12
- package/publisher.config.example.bigquery.json +33 -0
- package/publisher.config.example.duckdb.json +23 -0
- package/publisher.config.json +1 -11
- package/src/compile/compile_pool.spec.ts +227 -0
- package/src/compile/compile_pool.ts +729 -0
- package/src/compile/compile_worker.ts +683 -0
- package/src/compile/protocol.ts +251 -0
- package/src/config.spec.ts +306 -0
- package/src/config.ts +222 -2
- package/src/controller/compile.controller.ts +3 -1
- package/src/controller/connection.controller.ts +1 -1
- package/src/controller/model.controller.ts +8 -1
- package/src/controller/package.controller.ts +70 -29
- package/src/controller/query.controller.ts +3 -0
- package/src/default-publisher.config.json +23 -0
- package/src/errors.spec.ts +42 -0
- package/src/errors.ts +21 -0
- package/src/health.spec.ts +90 -0
- package/src/health.ts +86 -45
- package/src/logger.ts +1 -3
- package/src/mcp/tools/discovery_tools.ts +6 -2
- package/src/mcp/tools/execute_query_tool.ts +12 -0
- package/src/path_safety.spec.ts +158 -0
- package/src/path_safety.ts +140 -0
- package/src/pg_helpers.spec.ts +226 -0
- package/src/pg_helpers.ts +129 -0
- package/src/server-old.ts +3 -23
- package/src/server.ts +49 -0
- package/src/service/connection.spec.ts +6 -4
- package/src/service/connection.ts +8 -3
- package/src/service/connection_config.ts +2 -2
- package/src/service/environment.ts +621 -176
- package/src/service/environment_admission.spec.ts +180 -0
- package/src/service/environment_store.ts +22 -0
- package/src/service/filter_integration.spec.ts +110 -0
- package/src/service/givens_integration.spec.ts +192 -0
- package/src/service/manifest_service.spec.ts +7 -2
- package/src/service/manifest_service.ts +8 -2
- package/src/service/materialization_service.ts +14 -3
- package/src/service/model.spec.ts +105 -0
- package/src/service/model.ts +317 -10
- package/src/service/model_worker_path.spec.ts +125 -0
- package/src/service/package.ts +4 -3
- package/src/service/package_memory_governor.spec.ts +173 -0
- package/src/service/package_memory_governor.ts +233 -0
- package/src/service/package_race.spec.ts +208 -0
- package/src/storage/StorageManager.ts +71 -11
- package/src/storage/duckdb/schema.ts +41 -0
- package/src/utils.ts +11 -0
- package/tests/harness/rest_e2e.ts +2 -2
- package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +280 -0
- package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +259 -0
- package/tests/unit/duckdb/attached_databases.test.ts +5 -5
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +194 -0
- package/tests/unit/storage/StorageManager.test.ts +166 -0
- package/dist/app/assets/EnvironmentPage-1j6QDWAy.js +0 -1
- package/dist/app/assets/HomePage-DMop21VG.js +0 -1
- package/dist/app/assets/MainPage-BbE8ETz1.js +0 -2
- package/dist/app/assets/ModelPage-D2jvfe3t.js +0 -1
- package/dist/app/assets/PackagePage-BbnhGoD3.js +0 -1
- package/dist/app/assets/RouteError-D3LGEZ3i.js +0 -1
- package/dist/app/assets/WorkbookPage-DttVIj4u.js +0 -1
- package/dist/app/assets/index-5K9YjIxF.js +0 -456
- package/dist/app/assets/index-DIgzgp69.js +0 -1742
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol between the main thread (CompileWorkerPool) and the
|
|
3
|
+
* compile worker threads. Messages flow in both directions over the
|
|
4
|
+
* worker_threads MessagePort:
|
|
5
|
+
*
|
|
6
|
+
* main ──▶ worker: CompileJobRequest (start a compile)
|
|
7
|
+
* worker ──▶ main: CompileJobResult (success)
|
|
8
|
+
* worker ──▶ main: CompileJobError (failure)
|
|
9
|
+
*
|
|
10
|
+
* worker ──▶ main: SchemaForTablesRequest (proxy schema fetch)
|
|
11
|
+
* worker ──▶ main: SchemaForSqlRequest (proxy SQL block schema)
|
|
12
|
+
* main ──▶ worker: SchemaForTablesResponse / SchemaForSqlResponse
|
|
13
|
+
*
|
|
14
|
+
* main ──▶ worker: ShutdownRequest (graceful drain & exit)
|
|
15
|
+
*
|
|
16
|
+
* The protocol intentionally uses plain structured-clonable POJOs so
|
|
17
|
+
* `parentPort.postMessage` and `worker.postMessage` can transfer them
|
|
18
|
+
* via V8's structured clone — much cheaper than JSON.stringify for
|
|
19
|
+
* the multi-MB `modelDef` payloads that come back from compile.
|
|
20
|
+
*
|
|
21
|
+
* All requests are correlated by an opaque `requestId` string so the
|
|
22
|
+
* receiver can match responses without relying on FIFO ordering.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type {
|
|
26
|
+
Annotation,
|
|
27
|
+
SQLSourceDef,
|
|
28
|
+
TableSourceDef,
|
|
29
|
+
} from "@malloydata/malloy";
|
|
30
|
+
|
|
31
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
32
|
+
// Direction: main ──▶ worker (compile job)
|
|
33
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Connection metadata the worker needs to construct a stub
|
|
37
|
+
* `InfoConnection`. Resolved lazily — the worker asks the main thread
|
|
38
|
+
* for these on the first `lookupConnection(name)` call (see
|
|
39
|
+
* {@link ConnectionMetadataRequest}). We don't ship the full list
|
|
40
|
+
* upfront because the caller layer doesn't always know it; Malloy
|
|
41
|
+
* sees connection names only as `connection.table('...')`
|
|
42
|
+
* references inside the model.
|
|
43
|
+
*/
|
|
44
|
+
export interface ConnectionMetadata {
|
|
45
|
+
name: string;
|
|
46
|
+
dialectName: string;
|
|
47
|
+
digest: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CompileJobRequest {
|
|
51
|
+
type: "compile";
|
|
52
|
+
requestId: string;
|
|
53
|
+
/** Absolute path to the package directory on disk. */
|
|
54
|
+
packagePath: string;
|
|
55
|
+
/** Path of the model file relative to `packagePath`. */
|
|
56
|
+
modelPath: string;
|
|
57
|
+
/** Name of the default connection (e.g. "duckdb"), or null. */
|
|
58
|
+
defaultConnectionName: string | null;
|
|
59
|
+
/** Optional row-build manifest passed through to the Runtime. */
|
|
60
|
+
buildManifest?: unknown;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
64
|
+
// Direction: worker ──▶ main (compile result)
|
|
65
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Wire shape of a successful compile. Mirrors the fields the
|
|
69
|
+
* server's `Model` constructor needs to fully describe a `.malloy`
|
|
70
|
+
* file without holding a `ModelMaterializer` reference.
|
|
71
|
+
*
|
|
72
|
+
* The materializer itself is intentionally NOT shipped back — it
|
|
73
|
+
* binds to a Runtime that holds live native connection handles and
|
|
74
|
+
* cannot cross a worker_threads boundary. The main thread builds
|
|
75
|
+
* its own materializer lazily on the first query (see
|
|
76
|
+
* `Model.ensureMaterializer`).
|
|
77
|
+
*/
|
|
78
|
+
export interface CompileJobResult {
|
|
79
|
+
type: "compile-result";
|
|
80
|
+
requestId: string;
|
|
81
|
+
/** Whatever `await modelMaterializer.getModel()`._modelDef returned. */
|
|
82
|
+
modelDef: unknown;
|
|
83
|
+
/** Source-info entries (from imports + local sources). */
|
|
84
|
+
sourceInfos: unknown[];
|
|
85
|
+
/** Pre-extracted API source descriptors. */
|
|
86
|
+
sources: unknown[];
|
|
87
|
+
/** Pre-extracted API query descriptors. */
|
|
88
|
+
queries: unknown[];
|
|
89
|
+
/** Parsed `#(filter)` map, keyed by source name. */
|
|
90
|
+
filterMap: Array<[string, unknown[]]>;
|
|
91
|
+
/** Givens declared on the model, already in API shape so the main
|
|
92
|
+
* thread can stash them on the `Model` without further conversion. */
|
|
93
|
+
givens?: unknown[];
|
|
94
|
+
/** Accumulated dataStyles (from HackyDataStylesAccumulator). */
|
|
95
|
+
dataStyles: unknown;
|
|
96
|
+
/** Wall-clock ms inside the worker for the actual compile. */
|
|
97
|
+
compileDurationMs: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface CompileJobError {
|
|
101
|
+
type: "compile-error";
|
|
102
|
+
requestId: string;
|
|
103
|
+
/** Serialized error — the main thread reconstructs an Error. */
|
|
104
|
+
error: SerializedError;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Error wire-shape. We cannot transfer Error instances directly
|
|
109
|
+
* across postMessage cleanly (Bun/Node behaviour diverges on stack
|
|
110
|
+
* propagation), so we ship a structured payload and reconstitute on
|
|
111
|
+
* the main thread.
|
|
112
|
+
*/
|
|
113
|
+
export interface SerializedError {
|
|
114
|
+
name: string;
|
|
115
|
+
message: string;
|
|
116
|
+
stack?: string;
|
|
117
|
+
/** Set when the error originated as a Malloy `MalloyError`. */
|
|
118
|
+
malloyProblems?: unknown[];
|
|
119
|
+
/** Set when the error originated as `ModelCompilationError`. */
|
|
120
|
+
isCompilationError?: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
124
|
+
// Direction: worker ──▶ main (proxy connection metadata)
|
|
125
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
export interface ConnectionMetadataRequest {
|
|
128
|
+
type: "connection-metadata";
|
|
129
|
+
requestId: string;
|
|
130
|
+
jobId: string;
|
|
131
|
+
connectionName: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface ConnectionMetadataResponse {
|
|
135
|
+
type: "connection-metadata-response";
|
|
136
|
+
requestId: string;
|
|
137
|
+
ok: true;
|
|
138
|
+
metadata: ConnectionMetadata;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
142
|
+
// Direction: worker ──▶ main (proxy schema fetches)
|
|
143
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
export interface SchemaForTablesRequest {
|
|
146
|
+
type: "schema-for-tables";
|
|
147
|
+
requestId: string;
|
|
148
|
+
/** Job this RPC belongs to (so main routes to the right config). */
|
|
149
|
+
jobId: string;
|
|
150
|
+
connectionName: string;
|
|
151
|
+
tables: Record<string, string>;
|
|
152
|
+
options: {
|
|
153
|
+
refreshTimestamp?: number;
|
|
154
|
+
modelAnnotation?: Annotation;
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface SchemaForTablesResponse {
|
|
159
|
+
type: "schema-for-tables-response";
|
|
160
|
+
requestId: string;
|
|
161
|
+
ok: true;
|
|
162
|
+
schemas: Record<string, TableSourceDef>;
|
|
163
|
+
errors: Record<string, string>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface SchemaForSqlRequest {
|
|
167
|
+
type: "schema-for-sql";
|
|
168
|
+
requestId: string;
|
|
169
|
+
jobId: string;
|
|
170
|
+
connectionName: string;
|
|
171
|
+
sentence: unknown;
|
|
172
|
+
options: {
|
|
173
|
+
refreshTimestamp?: number;
|
|
174
|
+
modelAnnotation?: Annotation;
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface SchemaForSqlResponse {
|
|
179
|
+
type: "schema-for-sql-response";
|
|
180
|
+
requestId: string;
|
|
181
|
+
ok: true;
|
|
182
|
+
structDef?: SQLSourceDef;
|
|
183
|
+
error?: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface RpcErrorResponse {
|
|
187
|
+
type: "rpc-error";
|
|
188
|
+
requestId: string;
|
|
189
|
+
ok: false;
|
|
190
|
+
error: SerializedError;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
194
|
+
// Direction: worker ──▶ main (file read for imports)
|
|
195
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Workers read most files directly via fs (they run in the same
|
|
199
|
+
* filesystem namespace). This RPC exists for the rare case where the
|
|
200
|
+
* package URL reader has host-specific behaviour (e.g. virtual files,
|
|
201
|
+
* remote URLs) — we delegate back to the main thread's URL reader so
|
|
202
|
+
* compile semantics stay identical to the in-process path.
|
|
203
|
+
*/
|
|
204
|
+
export interface ReadUrlRequest {
|
|
205
|
+
type: "read-url";
|
|
206
|
+
requestId: string;
|
|
207
|
+
jobId: string;
|
|
208
|
+
url: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface ReadUrlResponse {
|
|
212
|
+
type: "read-url-response";
|
|
213
|
+
requestId: string;
|
|
214
|
+
ok: true;
|
|
215
|
+
contents: string;
|
|
216
|
+
invalidationKey?: string | number | null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
220
|
+
// Lifecycle
|
|
221
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
export interface ShutdownRequest {
|
|
224
|
+
type: "shutdown";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface ReadyMessage {
|
|
228
|
+
type: "ready";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
232
|
+
// Union types for routing
|
|
233
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
export type MainToWorkerMessage =
|
|
236
|
+
| CompileJobRequest
|
|
237
|
+
| ConnectionMetadataResponse
|
|
238
|
+
| SchemaForTablesResponse
|
|
239
|
+
| SchemaForSqlResponse
|
|
240
|
+
| ReadUrlResponse
|
|
241
|
+
| RpcErrorResponse
|
|
242
|
+
| ShutdownRequest;
|
|
243
|
+
|
|
244
|
+
export type WorkerToMainMessage =
|
|
245
|
+
| CompileJobResult
|
|
246
|
+
| CompileJobError
|
|
247
|
+
| ConnectionMetadataRequest
|
|
248
|
+
| SchemaForTablesRequest
|
|
249
|
+
| SchemaForSqlRequest
|
|
250
|
+
| ReadUrlRequest
|
|
251
|
+
| ReadyMessage;
|
package/src/config.spec.ts
CHANGED
|
@@ -856,3 +856,309 @@ describe("Config Environment Variable Substitution", () => {
|
|
|
856
856
|
});
|
|
857
857
|
});
|
|
858
858
|
});
|
|
859
|
+
|
|
860
|
+
// TODO: Remove this during projects cleanup
|
|
861
|
+
describe("Config legacy 'projects' key back-compat", () => {
|
|
862
|
+
const testServerRoot = path.join(process.cwd(), "test-temp-legacy-config");
|
|
863
|
+
const configPath = path.join(testServerRoot, PUBLISHER_CONFIG_NAME);
|
|
864
|
+
|
|
865
|
+
beforeEach(() => {
|
|
866
|
+
if (!fs.existsSync(testServerRoot)) {
|
|
867
|
+
fs.mkdirSync(testServerRoot, { recursive: true });
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
afterEach(() => {
|
|
872
|
+
if (fs.existsSync(configPath)) {
|
|
873
|
+
fs.unlinkSync(configPath);
|
|
874
|
+
}
|
|
875
|
+
if (fs.existsSync(testServerRoot)) {
|
|
876
|
+
fs.rmdirSync(testServerRoot, { recursive: true });
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
it("reads from legacy 'projects' key when 'environments' is absent", async () => {
|
|
881
|
+
// Pre-rename on-disk shape: top-level key is `projects`, not
|
|
882
|
+
// `environments`. Without back-compat this silently parses as empty.
|
|
883
|
+
const legacyConfig = {
|
|
884
|
+
frozenConfig: false,
|
|
885
|
+
projects: [
|
|
886
|
+
{
|
|
887
|
+
name: "legacy-env",
|
|
888
|
+
packages: [
|
|
889
|
+
{
|
|
890
|
+
name: "p1",
|
|
891
|
+
location: "./packages/p1",
|
|
892
|
+
},
|
|
893
|
+
],
|
|
894
|
+
},
|
|
895
|
+
],
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
fs.writeFileSync(configPath, JSON.stringify(legacyConfig, null, 2));
|
|
899
|
+
|
|
900
|
+
// Spy on logger.warn so we can assert the deprecation message fired.
|
|
901
|
+
const { logger } = await import("./logger");
|
|
902
|
+
const originalWarn = logger.warn;
|
|
903
|
+
const warnings: string[] = [];
|
|
904
|
+
logger.warn = ((msg: unknown, ..._rest: unknown[]) => {
|
|
905
|
+
warnings.push(typeof msg === "string" ? msg : String(msg));
|
|
906
|
+
return logger;
|
|
907
|
+
}) as typeof logger.warn;
|
|
908
|
+
|
|
909
|
+
try {
|
|
910
|
+
const result = getPublisherConfig(testServerRoot);
|
|
911
|
+
|
|
912
|
+
expect(result.environments.length).toBe(1);
|
|
913
|
+
expect(result.environments[0].name).toBe("legacy-env");
|
|
914
|
+
expect(result.environments[0].packages[0].name).toBe("p1");
|
|
915
|
+
|
|
916
|
+
expect(
|
|
917
|
+
warnings.some((w) => w.includes('uses deprecated "projects" key')),
|
|
918
|
+
).toBe(true);
|
|
919
|
+
} finally {
|
|
920
|
+
logger.warn = originalWarn;
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
it("prefers the new 'environments' key when both are present", async () => {
|
|
925
|
+
const config = {
|
|
926
|
+
frozenConfig: false,
|
|
927
|
+
environments: [
|
|
928
|
+
{
|
|
929
|
+
name: "new-env",
|
|
930
|
+
packages: [{ name: "p1", location: "./packages/p1" }],
|
|
931
|
+
},
|
|
932
|
+
],
|
|
933
|
+
projects: [
|
|
934
|
+
{
|
|
935
|
+
name: "should-be-ignored",
|
|
936
|
+
packages: [{ name: "p2", location: "./packages/p2" }],
|
|
937
|
+
},
|
|
938
|
+
],
|
|
939
|
+
};
|
|
940
|
+
|
|
941
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
942
|
+
|
|
943
|
+
const { logger } = await import("./logger");
|
|
944
|
+
const originalWarn = logger.warn;
|
|
945
|
+
const warnings: string[] = [];
|
|
946
|
+
logger.warn = ((msg: unknown, ..._rest: unknown[]) => {
|
|
947
|
+
warnings.push(typeof msg === "string" ? msg : String(msg));
|
|
948
|
+
return logger;
|
|
949
|
+
}) as typeof logger.warn;
|
|
950
|
+
|
|
951
|
+
try {
|
|
952
|
+
const result = getPublisherConfig(testServerRoot);
|
|
953
|
+
|
|
954
|
+
expect(result.environments.length).toBe(1);
|
|
955
|
+
expect(result.environments[0].name).toBe("new-env");
|
|
956
|
+
|
|
957
|
+
// No deprecation warning should fire when `environments` is present.
|
|
958
|
+
expect(
|
|
959
|
+
warnings.some((w) => w.includes('uses deprecated "projects" key')),
|
|
960
|
+
).toBe(false);
|
|
961
|
+
} finally {
|
|
962
|
+
logger.warn = originalWarn;
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
describe("Committed example configs", () => {
|
|
968
|
+
const serverDir = path.resolve(__dirname, "..");
|
|
969
|
+
|
|
970
|
+
it.each([
|
|
971
|
+
["publisher.config.json", false],
|
|
972
|
+
["publisher.config.example.duckdb.json", false],
|
|
973
|
+
["publisher.config.example.bigquery.json", true],
|
|
974
|
+
])(
|
|
975
|
+
"%s parses as a valid PublisherConfig",
|
|
976
|
+
(filename, expectsBigQueryConnection) => {
|
|
977
|
+
const filePath = path.join(serverDir, filename);
|
|
978
|
+
expect(fs.existsSync(filePath)).toBe(true);
|
|
979
|
+
|
|
980
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
981
|
+
const parsed = JSON.parse(raw) as PublisherConfig;
|
|
982
|
+
|
|
983
|
+
expect(parsed).toHaveProperty("environments");
|
|
984
|
+
expect(Array.isArray(parsed.environments)).toBe(true);
|
|
985
|
+
expect(parsed.environments.length).toBeGreaterThan(0);
|
|
986
|
+
|
|
987
|
+
const env = parsed.environments[0];
|
|
988
|
+
expect(env.name).toBeTruthy();
|
|
989
|
+
expect(Array.isArray(env.packages)).toBe(true);
|
|
990
|
+
expect(env.packages.length).toBeGreaterThan(0);
|
|
991
|
+
|
|
992
|
+
if (expectsBigQueryConnection) {
|
|
993
|
+
expect(
|
|
994
|
+
(env.connections ?? []).some((c) => c.type === "bigquery"),
|
|
995
|
+
).toBe(true);
|
|
996
|
+
expect(
|
|
997
|
+
env.packages.some((p) => p.name === "bigquery-hackernews"),
|
|
998
|
+
).toBe(true);
|
|
999
|
+
} else {
|
|
1000
|
+
expect(
|
|
1001
|
+
env.packages.some((p) => p.name === "bigquery-hackernews"),
|
|
1002
|
+
).toBe(false);
|
|
1003
|
+
}
|
|
1004
|
+
},
|
|
1005
|
+
);
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
describe("Config path resolution (--config and bundled default)", () => {
|
|
1009
|
+
const emptyServerRoot = path.join(process.cwd(), "test-empty-server-root");
|
|
1010
|
+
const customConfigPath = path.join(
|
|
1011
|
+
process.cwd(),
|
|
1012
|
+
"test-custom-publisher.config.json",
|
|
1013
|
+
);
|
|
1014
|
+
|
|
1015
|
+
beforeEach(() => {
|
|
1016
|
+
if (!fs.existsSync(emptyServerRoot)) {
|
|
1017
|
+
fs.mkdirSync(emptyServerRoot, { recursive: true });
|
|
1018
|
+
}
|
|
1019
|
+
delete process.env.PUBLISHER_CONFIG_PATH;
|
|
1020
|
+
delete process.env.PUBLISHER_USE_BUNDLED_DEFAULT;
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
afterEach(() => {
|
|
1024
|
+
delete process.env.PUBLISHER_CONFIG_PATH;
|
|
1025
|
+
delete process.env.PUBLISHER_USE_BUNDLED_DEFAULT;
|
|
1026
|
+
if (fs.existsSync(customConfigPath)) {
|
|
1027
|
+
fs.unlinkSync(customConfigPath);
|
|
1028
|
+
}
|
|
1029
|
+
if (fs.existsSync(emptyServerRoot)) {
|
|
1030
|
+
fs.rmSync(emptyServerRoot, { recursive: true, force: true });
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
it("falls back to the bundled default config when serverRoot has no publisher.config.json and bundled-default opt-in is set", () => {
|
|
1035
|
+
// The bundled default is opt-in (server.ts sets the env var when
|
|
1036
|
+
// the user passed neither --server_root nor --config) so embedded
|
|
1037
|
+
// callers don't get a surprise filesystem read.
|
|
1038
|
+
process.env.PUBLISHER_USE_BUNDLED_DEFAULT = "true";
|
|
1039
|
+
const result = getPublisherConfig(emptyServerRoot);
|
|
1040
|
+
|
|
1041
|
+
expect(result.environments.length).toBeGreaterThan(0);
|
|
1042
|
+
const env = result.environments[0];
|
|
1043
|
+
expect(env.name).toBe("malloy-samples");
|
|
1044
|
+
expect(env.packages.some((p) => p.name === "ecommerce")).toBe(true);
|
|
1045
|
+
expect(env.packages.some((p) => p.name === "bigquery-hackernews")).toBe(
|
|
1046
|
+
false,
|
|
1047
|
+
);
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
it("does NOT fall back to the bundled default when opt-in flag is unset (programmatic construction)", () => {
|
|
1051
|
+
// No PUBLISHER_USE_BUNDLED_DEFAULT, no PUBLISHER_CONFIG_PATH, no
|
|
1052
|
+
// file in emptyServerRoot — result should be the original empty
|
|
1053
|
+
// shape, preserving prior behavior for embeds and tests.
|
|
1054
|
+
const result = getPublisherConfig(emptyServerRoot);
|
|
1055
|
+
expect(result.environments).toEqual([]);
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
it("honors PUBLISHER_CONFIG_PATH (set by --config) over the server root", () => {
|
|
1059
|
+
const customConfig = {
|
|
1060
|
+
frozenConfig: false,
|
|
1061
|
+
environments: [
|
|
1062
|
+
{ name: "custom-env", packages: [{ name: "foo", location: "/x" }] },
|
|
1063
|
+
],
|
|
1064
|
+
};
|
|
1065
|
+
fs.writeFileSync(customConfigPath, JSON.stringify(customConfig));
|
|
1066
|
+
process.env.PUBLISHER_CONFIG_PATH = customConfigPath;
|
|
1067
|
+
|
|
1068
|
+
const result = getPublisherConfig(emptyServerRoot);
|
|
1069
|
+
expect(result.environments[0].name).toBe("custom-env");
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
it("returns empty environments (not the bundled default) when --config points at a missing file", () => {
|
|
1073
|
+
process.env.PUBLISHER_CONFIG_PATH = path.join(
|
|
1074
|
+
process.cwd(),
|
|
1075
|
+
"does-not-exist.json",
|
|
1076
|
+
);
|
|
1077
|
+
// Even with bundled-default opt-in, --config with a missing target
|
|
1078
|
+
// is a user error and we don't paper over it.
|
|
1079
|
+
process.env.PUBLISHER_USE_BUNDLED_DEFAULT = "true";
|
|
1080
|
+
const result = getPublisherConfig(emptyServerRoot);
|
|
1081
|
+
expect(result.environments).toEqual([]);
|
|
1082
|
+
});
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
describe("getMemoryGovernorConfig", () => {
|
|
1086
|
+
const GOVERNOR_ENV_VARS = [
|
|
1087
|
+
"PUBLISHER_MAX_MEMORY_BYTES",
|
|
1088
|
+
"PUBLISHER_MEMORY_HIGH_WATER_FRACTION",
|
|
1089
|
+
"PUBLISHER_MEMORY_LOW_WATER_FRACTION",
|
|
1090
|
+
"PUBLISHER_MEMORY_CHECK_INTERVAL_MS",
|
|
1091
|
+
"PUBLISHER_MEMORY_BACKPRESSURE",
|
|
1092
|
+
];
|
|
1093
|
+
|
|
1094
|
+
beforeEach(() => {
|
|
1095
|
+
for (const v of GOVERNOR_ENV_VARS) delete process.env[v];
|
|
1096
|
+
});
|
|
1097
|
+
afterEach(() => {
|
|
1098
|
+
for (const v of GOVERNOR_ENV_VARS) delete process.env[v];
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
it("returns null when PUBLISHER_MAX_MEMORY_BYTES is unset", async () => {
|
|
1102
|
+
const { getMemoryGovernorConfig } = await import("./config");
|
|
1103
|
+
expect(getMemoryGovernorConfig()).toBeNull();
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
it("parses defaults when only PUBLISHER_MAX_MEMORY_BYTES is set", async () => {
|
|
1107
|
+
process.env.PUBLISHER_MAX_MEMORY_BYTES = String(2 * 1024 * 1024 * 1024);
|
|
1108
|
+
const { getMemoryGovernorConfig } = await import("./config");
|
|
1109
|
+
const cfg = getMemoryGovernorConfig();
|
|
1110
|
+
expect(cfg).not.toBeNull();
|
|
1111
|
+
expect(cfg!.maxMemoryBytes).toBe(2 * 1024 * 1024 * 1024);
|
|
1112
|
+
expect(cfg!.backpressureEnabled).toBe(true);
|
|
1113
|
+
expect(cfg!.highWaterFraction).toBeGreaterThan(cfg!.lowWaterFraction);
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
it("honours fraction and interval overrides", async () => {
|
|
1117
|
+
process.env.PUBLISHER_MAX_MEMORY_BYTES = "1000000000";
|
|
1118
|
+
process.env.PUBLISHER_MEMORY_HIGH_WATER_FRACTION = "0.85";
|
|
1119
|
+
process.env.PUBLISHER_MEMORY_LOW_WATER_FRACTION = "0.7";
|
|
1120
|
+
process.env.PUBLISHER_MEMORY_CHECK_INTERVAL_MS = "10000";
|
|
1121
|
+
process.env.PUBLISHER_MEMORY_BACKPRESSURE = "false";
|
|
1122
|
+
const { getMemoryGovernorConfig } = await import("./config");
|
|
1123
|
+
const cfg = getMemoryGovernorConfig();
|
|
1124
|
+
expect(cfg).not.toBeNull();
|
|
1125
|
+
expect(cfg!.highWaterFraction).toBe(0.85);
|
|
1126
|
+
expect(cfg!.lowWaterFraction).toBe(0.7);
|
|
1127
|
+
expect(cfg!.checkIntervalMs).toBe(10000);
|
|
1128
|
+
expect(cfg!.backpressureEnabled).toBe(false);
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
it("treats PUBLISHER_MAX_MEMORY_BYTES=0 as disabled (returns null)", async () => {
|
|
1132
|
+
process.env.PUBLISHER_MAX_MEMORY_BYTES = "0";
|
|
1133
|
+
const { getMemoryGovernorConfig } = await import("./config");
|
|
1134
|
+
expect(getMemoryGovernorConfig()).toBeNull();
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
it("rejects a negative PUBLISHER_MAX_MEMORY_BYTES", async () => {
|
|
1138
|
+
process.env.PUBLISHER_MAX_MEMORY_BYTES = "-1";
|
|
1139
|
+
const { getMemoryGovernorConfig } = await import("./config");
|
|
1140
|
+
expect(() => getMemoryGovernorConfig()).toThrow();
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
it("rejects low >= high", async () => {
|
|
1144
|
+
process.env.PUBLISHER_MAX_MEMORY_BYTES = "1000000000";
|
|
1145
|
+
process.env.PUBLISHER_MEMORY_HIGH_WATER_FRACTION = "0.7";
|
|
1146
|
+
process.env.PUBLISHER_MEMORY_LOW_WATER_FRACTION = "0.8";
|
|
1147
|
+
const { getMemoryGovernorConfig } = await import("./config");
|
|
1148
|
+
expect(() => getMemoryGovernorConfig()).toThrow();
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
it("rejects an out-of-range fraction", async () => {
|
|
1152
|
+
process.env.PUBLISHER_MAX_MEMORY_BYTES = "1000000000";
|
|
1153
|
+
process.env.PUBLISHER_MEMORY_HIGH_WATER_FRACTION = "1.5";
|
|
1154
|
+
const { getMemoryGovernorConfig } = await import("./config");
|
|
1155
|
+
expect(() => getMemoryGovernorConfig()).toThrow();
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
it("rejects a check interval below the safety floor", async () => {
|
|
1159
|
+
process.env.PUBLISHER_MAX_MEMORY_BYTES = "1000000000";
|
|
1160
|
+
process.env.PUBLISHER_MEMORY_CHECK_INTERVAL_MS = "10";
|
|
1161
|
+
const { getMemoryGovernorConfig } = await import("./config");
|
|
1162
|
+
expect(() => getMemoryGovernorConfig()).toThrow();
|
|
1163
|
+
});
|
|
1164
|
+
});
|