@malloy-publisher/server 0.0.198 → 0.0.199

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.
Files changed (45) hide show
  1. package/build.ts +30 -1
  2. package/dist/app/api-doc.yaml +51 -0
  3. package/dist/app/assets/{EnvironmentPage-C7rtH4mC.js → EnvironmentPage-Dpee_Kn6.js} +1 -1
  4. package/dist/app/assets/{HomePage-DwkH7OrS.js → HomePage-DLRWTNoL.js} +1 -1
  5. package/dist/app/assets/{MainPage-D38LtZDV.js → MainPage-DsVt5QGM.js} +1 -1
  6. package/dist/app/assets/{ModelPage-DOol8Mz7.js → ModelPage-AwAugZ37.js} +1 -1
  7. package/dist/app/assets/{PackagePage-0tgzA_kO.js → PackagePage-XQ-EWGTC.js} +1 -1
  8. package/dist/app/assets/{RouteError-BaMsOSly.js → RouteError-3Mv8JQw7.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-Cx4SePkx.js → WorkbookPage-DHYYpcYc.js} +1 -1
  10. package/dist/app/assets/{core-CbsC6R_Y.es-Cwf6asf3.js → core-DfcpQGVP.es-DQggNOdX.js} +1 -1
  11. package/dist/app/assets/{index-DNofXMxi.js → index-BUp81Qdm.js} +1 -1
  12. package/dist/app/assets/{index-DL6BZTuw.js → index-D1pdwrUW.js} +1 -1
  13. package/dist/app/assets/{index-U38AyjJL.js → index-Dv5bF4Ii.js} +4 -4
  14. package/dist/app/assets/{index.umd-B68wGGkM.js → index.umd-CQH4LZU8.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/instrumentation.mjs +57 -36
  17. package/dist/package_load_worker.mjs +12213 -0
  18. package/dist/server.mjs +2807 -2729
  19. package/package.json +2 -3
  20. package/src/controller/compile.controller.ts +3 -1
  21. package/src/controller/model.controller.ts +8 -1
  22. package/src/controller/query.controller.ts +3 -0
  23. package/src/health.spec.ts +90 -0
  24. package/src/health.ts +88 -45
  25. package/src/instrumentation.ts +50 -0
  26. package/src/mcp/tools/execute_query_tool.ts +12 -0
  27. package/src/package_load/package_load_pool.spec.ts +252 -0
  28. package/src/package_load/package_load_pool.ts +920 -0
  29. package/src/package_load/package_load_worker.ts +980 -0
  30. package/src/package_load/protocol.ts +336 -0
  31. package/src/query_param_utils.ts +18 -0
  32. package/src/server-old.ts +1 -1
  33. package/src/server.ts +36 -10
  34. package/src/service/db_utils.spec.ts +1 -1
  35. package/src/service/environment.ts +3 -2
  36. package/src/service/environment_store.ts +24 -3
  37. package/src/service/filter_integration.spec.ts +110 -0
  38. package/src/service/given.ts +80 -0
  39. package/src/service/givens_integration.spec.ts +192 -0
  40. package/src/service/model.spec.ts +105 -0
  41. package/src/service/model.ts +287 -16
  42. package/src/service/package.spec.ts +10 -0
  43. package/src/service/package.ts +257 -145
  44. package/src/service/package_worker_path.spec.ts +196 -0
  45. package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +280 -0
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Integration test: exercise `Package.create` with the package-load
3
+ * worker pool enabled (PACKAGE_LOAD_WORKERS=1).
4
+ *
5
+ * Validates that the worker-load path:
6
+ * - reads the manifest, probes embedded databases, and compiles
7
+ * every model in a single off-thread job
8
+ * - produces a live `Package` whose `Model`s have populated
9
+ * `modelDef` / `sources` / `queries`
10
+ * - hydrates the `ModelMaterializer` from `modelDef` on first
11
+ * query (no recompile) — verified end-to-end by running a
12
+ * query through the resulting Model and getting a result
13
+ *
14
+ * Kept separate from `package.spec.ts` so the existing tests keep
15
+ * running on the in-process path without paying worker startup cost.
16
+ *
17
+ * Pool reuse strategy: one `PackageLoadPool` shared across all
18
+ * cases in this file. Spawning a fresh worker per test crashes Bun
19
+ * (segfault) because DuckDB's native bindings don't tolerate being
20
+ * loaded concurrently into multiple worker isolates of the same Bun
21
+ * process. Production uses one pool; this matches.
22
+ */
23
+ import {
24
+ afterAll,
25
+ afterEach,
26
+ beforeAll,
27
+ beforeEach,
28
+ describe,
29
+ expect,
30
+ it,
31
+ } from "bun:test";
32
+ import * as fs from "fs";
33
+ import * as os from "os";
34
+ import * as path from "path";
35
+ import {
36
+ PackageLoadPool,
37
+ __setPackageLoadPoolForTests,
38
+ } from "../package_load/package_load_pool";
39
+ import { Package } from "./package";
40
+
41
+ const ORIGINAL_ENV = process.env.PACKAGE_LOAD_WORKERS;
42
+
43
+ describe("Package.create via worker pool", () => {
44
+ let tempDir: string;
45
+ let pool: PackageLoadPool;
46
+
47
+ beforeAll(async () => {
48
+ process.env.PACKAGE_LOAD_WORKERS = "1";
49
+ pool = new PackageLoadPool(1);
50
+ // Wire our pool into the module-level singleton so Package.create
51
+ // picks it up via getPackageLoadPool().
52
+ await __setPackageLoadPoolForTests(pool);
53
+ });
54
+
55
+ afterAll(async () => {
56
+ await __setPackageLoadPoolForTests(null);
57
+ if (ORIGINAL_ENV === undefined) {
58
+ delete process.env.PACKAGE_LOAD_WORKERS;
59
+ } else {
60
+ process.env.PACKAGE_LOAD_WORKERS = ORIGINAL_ENV;
61
+ }
62
+ });
63
+
64
+ beforeEach(() => {
65
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "publisher-pkg-worker-"));
66
+ });
67
+
68
+ afterEach(() => {
69
+ if (tempDir) {
70
+ // tempDir gets wiped by Package.create on failure (it's the
71
+ // staging-cleanup path); ignore ENOENT here.
72
+ try {
73
+ fs.rmSync(tempDir, { recursive: true, force: true });
74
+ } catch {
75
+ /* already gone */
76
+ }
77
+ tempDir = "";
78
+ }
79
+ });
80
+
81
+ async function makeMalloyConfig(): Promise<{
82
+ malloyConfig: import("@malloydata/malloy").MalloyConfig;
83
+ duckdb: { close: () => Promise<void> };
84
+ }> {
85
+ const { MalloyConfig, FixedConnectionMap } = await import(
86
+ "@malloydata/malloy"
87
+ );
88
+ const { DuckDBConnection } = await import("@malloydata/db-duckdb");
89
+ const duckdb = new DuckDBConnection("duckdb", ":memory:");
90
+ const connections = new FixedConnectionMap(
91
+ new Map([["duckdb", duckdb]]),
92
+ "duckdb",
93
+ );
94
+ const malloyConfig = new MalloyConfig({ connections: {} });
95
+ malloyConfig.wrapConnections(() => connections);
96
+ return { malloyConfig, duckdb };
97
+ }
98
+
99
+ function writeManifest(): void {
100
+ fs.writeFileSync(
101
+ path.join(tempDir, "publisher.json"),
102
+ JSON.stringify({ name: "pkg", description: "test package" }),
103
+ );
104
+ }
105
+
106
+ it("loads a package end-to-end and serves a query through the hydrated materializer", async () => {
107
+ writeManifest();
108
+ // Define `total_v` as a *view* on the source so the query
109
+ // builder's `run: nums -> total_v` form resolves. (Top-level
110
+ // queries take the `run: total_q` form — orthogonal path that
111
+ // the in-process tests already cover.)
112
+ fs.writeFileSync(
113
+ path.join(tempDir, "trivial.malloy"),
114
+ `source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
115
+ measure: total is a.sum()
116
+ view: total_v is { aggregate: total }
117
+ }`,
118
+ );
119
+
120
+ const { malloyConfig, duckdb } = await makeMalloyConfig();
121
+ try {
122
+ const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
123
+ expect(pkg).toBeInstanceOf(Package);
124
+ expect(pkg.getModelPaths()).toEqual(["trivial.malloy"]);
125
+
126
+ const model = pkg.getModel("trivial.malloy");
127
+ expect(model).toBeDefined();
128
+ const apiModel = (await model!.getModel()) as {
129
+ modelDef?: string;
130
+ sources?: { name?: string }[];
131
+ };
132
+ expect(apiModel.modelDef).toBeDefined();
133
+ expect(apiModel.sources?.[0]?.name).toBe("nums");
134
+
135
+ // First query against the package — hydrates the
136
+ // ModelMaterializer from the worker's modelDef without a
137
+ // recompile, then runs the SQL against the *main thread's*
138
+ // DuckDB connection (the only one with the in-memory `nums`
139
+ // source loaded via duckdb.sql()).
140
+ const { result } = await model!.getQueryResults(
141
+ "nums",
142
+ "total_v",
143
+ undefined,
144
+ );
145
+ expect(result.data).toBeDefined();
146
+ } finally {
147
+ await duckdb.close();
148
+ }
149
+ });
150
+
151
+ it("propagates a per-model compile failure as a thrown error from Package.create", async () => {
152
+ writeManifest();
153
+ fs.writeFileSync(
154
+ path.join(tempDir, "broken.malloy"),
155
+ `source: bad is duckdb.sql("select 1 as a") extend {
156
+ measure: oops is THIS_FUNC_DOES_NOT_EXIST(a)
157
+ }`,
158
+ );
159
+
160
+ const { malloyConfig, duckdb } = await makeMalloyConfig();
161
+ try {
162
+ await expect(
163
+ Package.create("env", "pkg", tempDir, malloyConfig),
164
+ ).rejects.toBeInstanceOf(Error);
165
+ } finally {
166
+ await duckdb.close();
167
+ }
168
+ });
169
+
170
+ // NB: kept last in this describe — swapping the singleton for a
171
+ // pre-shutdown pool also tears down the shared `pool` (the swap
172
+ // implementation shuts down the outgoing singleton). Subsequent
173
+ // tests in this describe would see a dead pool. afterAll only
174
+ // resets the singleton to null, so this is safe at the tail.
175
+ it("rewraps pool-infrastructure failures as ServiceUnavailableError (HTTP 503)", async () => {
176
+ writeManifest();
177
+ fs.writeFileSync(
178
+ path.join(tempDir, "trivial.malloy"),
179
+ `source: nums is duckdb.sql("select 1 as a")`,
180
+ );
181
+
182
+ const deadPool = new PackageLoadPool(1);
183
+ await deadPool.shutdown();
184
+ await __setPackageLoadPoolForTests(deadPool);
185
+
186
+ const { ServiceUnavailableError } = await import("../errors");
187
+ const { malloyConfig, duckdb } = await makeMalloyConfig();
188
+ try {
189
+ await expect(
190
+ Package.create("env", "pkg", tempDir, malloyConfig),
191
+ ).rejects.toBeInstanceOf(ServiceUnavailableError);
192
+ } finally {
193
+ await duckdb.close();
194
+ }
195
+ });
196
+ });
@@ -0,0 +1,280 @@
1
+ /// <reference types="bun-types" />
2
+
3
+ /**
4
+ * Regression test for the per-package download/unzip race.
5
+ *
6
+ * Before fix: concurrent POST/PATCH/GET-with-reload against the same package
7
+ * name landed in `downloadPackage` *before* acquiring the per-package mutex.
8
+ * Multiple callers would `rm -rf <targetPath>` and then `cp -r` / `extractAllTo`
9
+ * in parallel, leaving the directory in an inconsistent state. Subsequent
10
+ * reads failed with "Package manifest for ... does not exist" and any
11
+ * in-flight model compilation would 500 with "compiling model path not found".
12
+ *
13
+ * After fix: download is run inside the per-package mutex, so concurrent
14
+ * callers serialize on the same target path. All N requests must succeed
15
+ * and the package must be usable afterwards.
16
+ */
17
+
18
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
19
+ import path from "path";
20
+ import { fileURLToPath } from "url";
21
+ import { RestE2EEnv, startRestE2E } from "../../harness/rest_e2e";
22
+
23
+ const __filename = fileURLToPath(import.meta.url);
24
+ const __dirname = path.dirname(__filename);
25
+
26
+ // Use a unique env name per run so stale state from a previous run (env
27
+ // directory, persisted env metadata in publisher.db) can't poison startup.
28
+ const ENV_NAME = `concurrent-package-test-env-${Date.now()}`;
29
+ const PACKAGE_NAME = "gcs_faa";
30
+ const CONCURRENCY = 12;
31
+
32
+ const FORBIDDEN_ERROR_FRAGMENTS = [
33
+ "Package manifest for",
34
+ "does not exist",
35
+ "compiling model path not found",
36
+ "model path not found",
37
+ ];
38
+
39
+ function findForbiddenError(body: unknown): string | undefined {
40
+ const text = typeof body === "string" ? body : JSON.stringify(body ?? "");
41
+ return FORBIDDEN_ERROR_FRAGMENTS.find((frag) => text.includes(frag));
42
+ }
43
+
44
+ describe("Concurrent package operations (E2E)", () => {
45
+ let env: (RestE2EEnv & { stop(): Promise<void> }) | null = null;
46
+ let baseUrl: string;
47
+ let fixtureDir: string;
48
+
49
+ beforeAll(async () => {
50
+ env = await startRestE2E();
51
+ baseUrl = env.baseUrl;
52
+ fixtureDir = "gs://publisher_test_packages/gcs_faa.zip";
53
+
54
+ // Seed the environment with the package once so the env exists on disk.
55
+ // addEnvironment requires at least one package.
56
+ const createRes = await fetch(`${baseUrl}/api/v0/environments`, {
57
+ method: "POST",
58
+ headers: { "Content-Type": "application/json" },
59
+ body: JSON.stringify({
60
+ name: ENV_NAME,
61
+ packages: [{ name: PACKAGE_NAME, location: fixtureDir }],
62
+ connections: [],
63
+ }),
64
+ });
65
+ if (!createRes.ok) {
66
+ const body = await createRes.text();
67
+ throw new Error(
68
+ `Failed to seed test environment (${createRes.status}): ${body}`,
69
+ );
70
+ }
71
+
72
+ // Wait for the seeded package to be loadable.
73
+ const deadline = Date.now() + 30_000;
74
+ let ready = false;
75
+ while (!ready && Date.now() < deadline) {
76
+ try {
77
+ const res = await fetch(
78
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}`,
79
+ );
80
+ if (res.ok) {
81
+ ready = true;
82
+ break;
83
+ }
84
+ } catch {
85
+ // not ready yet
86
+ }
87
+ await new Promise((r) => setTimeout(r, 250));
88
+ }
89
+ if (!ready) {
90
+ throw new Error("Seeded package did not become available in time");
91
+ }
92
+ });
93
+
94
+ afterAll(async () => {
95
+ if (baseUrl) {
96
+ try {
97
+ await fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}`, {
98
+ method: "DELETE",
99
+ });
100
+ } catch {
101
+ // best-effort cleanup
102
+ }
103
+ }
104
+ await env?.stop();
105
+ env = null;
106
+ });
107
+
108
+ it("concurrent POST /packages for the same name all succeed", async () => {
109
+ const requests = Array.from({ length: CONCURRENCY }, () =>
110
+ fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}/packages`, {
111
+ method: "POST",
112
+ headers: { "Content-Type": "application/json" },
113
+ body: JSON.stringify({
114
+ name: PACKAGE_NAME,
115
+ location: fixtureDir,
116
+ }),
117
+ }),
118
+ );
119
+
120
+ const responses = await Promise.all(requests);
121
+ const bodies = await Promise.all(
122
+ responses.map(async (r) => ({
123
+ status: r.status,
124
+ body: await r.json().catch(() => null),
125
+ })),
126
+ );
127
+
128
+ for (const { status, body } of bodies) {
129
+ expect(status).toBe(200);
130
+ const forbidden = findForbiddenError(body);
131
+ expect(forbidden).toBeUndefined();
132
+ }
133
+
134
+ // Package must be loadable after the storm.
135
+ const res = await fetch(
136
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}`,
137
+ );
138
+ expect(res.status).toBe(200);
139
+ const meta = (await res.json()) as { name?: string };
140
+ expect(meta.name).toBe(PACKAGE_NAME);
141
+
142
+ // Models must be listable — proves the model files survived the unzip
143
+ // race and Package.create read a consistent directory.
144
+ const modelsRes = await fetch(
145
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}/models`,
146
+ );
147
+ expect(modelsRes.status).toBe(200);
148
+ const models = (await modelsRes.json()) as Array<{ path?: string }>;
149
+ expect(Array.isArray(models)).toBe(true);
150
+ expect(models.length).toBeGreaterThan(0);
151
+ });
152
+
153
+ it("concurrent GET /packages/:name?reload=true all succeed", async () => {
154
+ const requests = Array.from({ length: CONCURRENCY }, () =>
155
+ fetch(
156
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}?reload=true`,
157
+ ),
158
+ );
159
+ const responses = await Promise.all(requests);
160
+ const bodies = await Promise.all(
161
+ responses.map(async (r) => ({
162
+ status: r.status,
163
+ body: await r.json().catch(() => null),
164
+ })),
165
+ );
166
+ for (const { status, body } of bodies) {
167
+ expect(status).toBe(200);
168
+ const forbidden = findForbiddenError(body);
169
+ expect(forbidden).toBeUndefined();
170
+ }
171
+
172
+ // Models must still list cleanly.
173
+ const modelsRes = await fetch(
174
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}/models`,
175
+ );
176
+ expect(modelsRes.status).toBe(200);
177
+ });
178
+
179
+ it("simultaneous POST + PATCH for the same package serialize cleanly", async () => {
180
+ // Fire create and update at the same time. Both should land under the
181
+ // same per-package mutex. Whichever wins the mutex first runs first;
182
+ // the loser sees a coherent post-condition. After both settle the
183
+ // package must be loadable and the description must reflect the PATCH
184
+ // when the PATCH ran *after* a successful POST.
185
+ const newDescription = `concurrent-update-${Date.now()}`;
186
+ const [postRes, patchRes] = await Promise.all([
187
+ fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}/packages`, {
188
+ method: "POST",
189
+ headers: { "Content-Type": "application/json" },
190
+ body: JSON.stringify({
191
+ name: PACKAGE_NAME,
192
+ location: fixtureDir,
193
+ }),
194
+ }),
195
+ fetch(
196
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}`,
197
+ {
198
+ method: "PATCH",
199
+ headers: { "Content-Type": "application/json" },
200
+ body: JSON.stringify({
201
+ name: PACKAGE_NAME,
202
+ description: newDescription,
203
+ }),
204
+ },
205
+ ),
206
+ ]);
207
+
208
+ // POST is idempotent here — always succeeds.
209
+ expect(postRes.status).toBe(200);
210
+ const postBody = await postRes.json().catch(() => null);
211
+ expect(findForbiddenError(postBody)).toBeUndefined();
212
+
213
+ // PATCH either succeeds (ran after POST loaded the package) or 404s
214
+ // (ran before POST populated `this.packages`). It must NOT silently
215
+ // rewrite disk and then 404, and must never surface the forbidden
216
+ // error fragments.
217
+ const patchBody = await patchRes.json().catch(() => null);
218
+ expect(findForbiddenError(patchBody)).toBeUndefined();
219
+ expect([200, 404]).toContain(patchRes.status);
220
+
221
+ // Whatever happened, the package must remain loadable.
222
+ const getRes = await fetch(
223
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}`,
224
+ );
225
+ expect(getRes.status).toBe(200);
226
+ const meta = (await getRes.json()) as {
227
+ name?: string;
228
+ description?: string;
229
+ };
230
+ expect(meta.name).toBe(PACKAGE_NAME);
231
+ });
232
+
233
+ it("interleaved POST + GET-reload + model list never surface stale-dir errors", async () => {
234
+ // Worst-case interleave: writers and readers hammering the same
235
+ // package. None of them should observe a missing manifest or a
236
+ // half-extracted directory.
237
+ const work: Array<Promise<{ status: number; body: unknown }>> = [];
238
+ for (let i = 0; i < CONCURRENCY; i++) {
239
+ work.push(
240
+ fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}/packages`, {
241
+ method: "POST",
242
+ headers: { "Content-Type": "application/json" },
243
+ body: JSON.stringify({
244
+ name: PACKAGE_NAME,
245
+ location: fixtureDir,
246
+ }),
247
+ }).then(async (r) => ({
248
+ status: r.status,
249
+ body: await r.json().catch(() => null),
250
+ })),
251
+ );
252
+ work.push(
253
+ fetch(
254
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}?reload=true`,
255
+ ).then(async (r) => ({
256
+ status: r.status,
257
+ body: await r.json().catch(() => null),
258
+ })),
259
+ );
260
+ work.push(
261
+ fetch(
262
+ `${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}/models`,
263
+ ).then(async (r) => ({
264
+ status: r.status,
265
+ body: await r.json().catch(() => null),
266
+ })),
267
+ );
268
+ }
269
+
270
+ const results = await Promise.all(work);
271
+ for (const { status, body } of results) {
272
+ const forbidden = findForbiddenError(body);
273
+ expect(forbidden).toBeUndefined();
274
+ // Model list while the package is being rewritten can transiently
275
+ // return 404 (the package is unloaded mid-rewrite), but never 5xx,
276
+ // and never with the forbidden error fragments.
277
+ expect(status).toBeLessThan(500);
278
+ }
279
+ });
280
+ });