@malloy-publisher/server 0.0.188 → 0.0.382-dev

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 (52) hide show
  1. package/dist/app/api-doc.yaml +423 -60
  2. package/dist/app/assets/{HomePage-DsuUvSI_.js → HomePage-Dn3E4CuB.js} +1 -1
  3. package/dist/app/assets/{MainPage-DHWFkEN6.js → MainPage-BzB3yoqi.js} +1 -1
  4. package/dist/app/assets/{ModelPage-DNwcx1nE.js → ModelPage-C9O_sAXT.js} +1 -1
  5. package/dist/app/assets/{PackagePage-DSgz9G2V.js → PackagePage-DcxKEjBX.js} +1 -1
  6. package/dist/app/assets/{ProjectPage-CSdPosLV.js → ProjectPage-BDj307rF.js} +1 -1
  7. package/dist/app/assets/{RouteError-orw1RX8q.js → RouteError-DAShbVCG.js} +1 -1
  8. package/dist/app/assets/{WorkbookPage-Bp-BpGjL.js → WorkbookPage-Cs_XYEaB.js} +1 -1
  9. package/dist/app/assets/{core-B4ZYB7aS.es-8Zh0TkSr.js → core-CjeTkq8O.es-BqRc6yhC.js} +1 -1
  10. package/dist/app/assets/{index-BL2TJgTw.js → index-15BOvhp0.js} +4 -4
  11. package/dist/app/assets/{index-BWJkzsfl.js → index-Bb2jqquW.js} +1 -1
  12. package/dist/app/assets/{index-BefdHHMa.js → index-D68X76-7.js} +1 -1
  13. package/dist/app/assets/{index.umd-lY-87l4L.js → index.umd-DGBekgSu.js} +1 -1
  14. package/dist/app/index.html +1 -1
  15. package/dist/instrumentation.js +98 -77
  16. package/dist/server.js +1834 -450
  17. package/package.json +5 -3
  18. package/src/controller/connection.controller.ts +27 -20
  19. package/src/controller/manifest.controller.ts +29 -0
  20. package/src/controller/materialization.controller.ts +125 -0
  21. package/src/controller/model.controller.ts +0 -2
  22. package/src/controller/package.controller.ts +53 -2
  23. package/src/errors.ts +24 -0
  24. package/src/server.ts +196 -5
  25. package/src/service/manifest_service.spec.ts +201 -0
  26. package/src/service/manifest_service.ts +106 -0
  27. package/src/service/materialization_service.spec.ts +648 -0
  28. package/src/service/materialization_service.ts +929 -0
  29. package/src/service/materialized_table_gc.spec.ts +383 -0
  30. package/src/service/materialized_table_gc.ts +279 -0
  31. package/src/service/model.ts +25 -4
  32. package/src/service/package.ts +50 -0
  33. package/src/service/project_store.ts +21 -2
  34. package/src/service/quoting.ts +41 -0
  35. package/src/service/resolve_project.ts +13 -0
  36. package/src/storage/DatabaseInterface.ts +103 -1
  37. package/src/storage/{StorageManager.spec.ts → StorageManager.mock.ts} +9 -0
  38. package/src/storage/StorageManager.ts +119 -1
  39. package/src/storage/duckdb/DuckDBManifestStore.ts +70 -0
  40. package/src/storage/duckdb/DuckDBRepository.ts +99 -9
  41. package/src/storage/duckdb/ManifestRepository.ts +119 -0
  42. package/src/storage/duckdb/MaterializationRepository.ts +249 -0
  43. package/src/storage/duckdb/manifest_store.spec.ts +133 -0
  44. package/src/storage/duckdb/schema.ts +59 -1
  45. package/src/storage/ducklake/DuckLakeManifestStore.ts +146 -0
  46. package/tests/fixtures/persist-test/data/orders.csv +5 -0
  47. package/tests/fixtures/persist-test/persist_test.malloy +11 -0
  48. package/tests/fixtures/persist-test/publisher.json +5 -0
  49. package/tests/fixtures/publisher.config.json +15 -0
  50. package/tests/harness/rest_e2e.ts +68 -0
  51. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +470 -0
  52. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +2 -2
@@ -0,0 +1,470 @@
1
+ /// <reference types="bun-types" />
2
+
3
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
4
+ import path from "path";
5
+ import { fileURLToPath } from "url";
6
+ import { RestE2EEnv, startRestE2E } from "../../harness/rest_e2e";
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
10
+
11
+ const PROJECT_NAME = "test-project";
12
+ const PACKAGE_NAME = "persist-test";
13
+ const API = `/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}`;
14
+
15
+ describe("Materialization & Manifest REST API (E2E)", () => {
16
+ let env: (RestE2EEnv & { stop(): Promise<void> }) | null = null;
17
+ let baseUrl: string;
18
+
19
+ beforeAll(async () => {
20
+ env = await startRestE2E();
21
+ baseUrl = env.baseUrl;
22
+
23
+ // Create the test project via the REST API using an absolute
24
+ // path to the fixture so it works regardless of SERVER_ROOT.
25
+ const fixtureDir = path.resolve(__dirname, "../../fixtures/persist-test");
26
+ const createRes = await fetch(`${baseUrl}/api/v0/projects`, {
27
+ method: "POST",
28
+ headers: { "Content-Type": "application/json" },
29
+ body: JSON.stringify({
30
+ name: PROJECT_NAME,
31
+ packages: [{ name: PACKAGE_NAME, location: fixtureDir }],
32
+ connections: [],
33
+ }),
34
+ });
35
+ if (!createRes.ok) {
36
+ const body = await createRes.text();
37
+ throw new Error(
38
+ `Failed to create test project (${createRes.status}): ${body}`,
39
+ );
40
+ }
41
+
42
+ // Wait for the package to finish loading.
43
+ const deadline = Date.now() + 30_000;
44
+ let pkgReady = false;
45
+ while (!pkgReady && Date.now() < deadline) {
46
+ try {
47
+ const res = await fetch(
48
+ `${baseUrl}/api/v0/projects/${PROJECT_NAME}/packages/${PACKAGE_NAME}`,
49
+ );
50
+ if (res.ok) {
51
+ pkgReady = true;
52
+ break;
53
+ }
54
+ } catch {
55
+ // not ready yet
56
+ }
57
+ await new Promise((r) => setTimeout(r, 500));
58
+ }
59
+ if (!pkgReady) {
60
+ throw new Error("Test package did not become available in time");
61
+ }
62
+ });
63
+
64
+ afterAll(async () => {
65
+ // Tear down the test project, then the HTTP server.
66
+ if (baseUrl) {
67
+ try {
68
+ await fetch(`${baseUrl}/api/v0/projects/${PROJECT_NAME}`, {
69
+ method: "DELETE",
70
+ });
71
+ } catch {
72
+ // best-effort cleanup
73
+ }
74
+ }
75
+ await env?.stop();
76
+ env = null;
77
+ });
78
+
79
+ // ── helpers ──────────────────────────────────────────────────────
80
+
81
+ function url(p: string): string {
82
+ return `${baseUrl}${API}${p}`;
83
+ }
84
+
85
+ async function createMaterialization(
86
+ body: Record<string, unknown> = {},
87
+ ): Promise<Response> {
88
+ return fetch(url("/materializations"), {
89
+ method: "POST",
90
+ headers: { "Content-Type": "application/json" },
91
+ body: JSON.stringify(body),
92
+ });
93
+ }
94
+
95
+ async function pollUntilTerminal(
96
+ id: string,
97
+ timeoutMs = 30_000,
98
+ ): Promise<Record<string, unknown>> {
99
+ const deadline = Date.now() + timeoutMs;
100
+ while (Date.now() < deadline) {
101
+ const res = await fetch(url(`/materializations/${id}`));
102
+ expect(res.status).toBe(200);
103
+ const data = (await res.json()) as Record<string, unknown>;
104
+ const status = data.status as string;
105
+ if (["SUCCESS", "FAILED", "CANCELLED"].includes(status)) {
106
+ return data;
107
+ }
108
+ await new Promise((r) => setTimeout(r, 500));
109
+ }
110
+ throw new Error(`Materialization ${id} did not reach terminal state`);
111
+ }
112
+
113
+ /**
114
+ * Clean up a materialization so it doesn't interfere with other tests.
115
+ * Stops it if active, then deletes if terminal.
116
+ */
117
+ async function cleanup(id: string): Promise<void> {
118
+ const res = await fetch(url(`/materializations/${id}`));
119
+ if (res.status !== 200) return;
120
+ const data = (await res.json()) as Record<string, unknown>;
121
+ const status = data.status as string;
122
+
123
+ if (status === "PENDING" || status === "RUNNING") {
124
+ await fetch(url(`/materializations/${id}?action=stop`), {
125
+ method: "POST",
126
+ });
127
+ await pollUntilTerminal(id);
128
+ }
129
+ await fetch(url(`/materializations/${id}`), { method: "DELETE" });
130
+ }
131
+
132
+ // ── Group A: Full lifecycle with persist sources ──────────────────
133
+
134
+ describe("full lifecycle (happy path)", () => {
135
+ let materializationId: string;
136
+
137
+ afterAll(async () => {
138
+ if (materializationId) await cleanup(materializationId);
139
+ });
140
+
141
+ it(
142
+ "should create, start, build, verify manifest, and delete",
143
+ async () => {
144
+ // 1. Create
145
+ const createRes = await createMaterialization({
146
+ autoLoadManifest: true,
147
+ });
148
+ expect(createRes.status).toBe(201);
149
+ const created = (await createRes.json()) as Record<string, unknown>;
150
+ expect(created.status).toBe("PENDING");
151
+ expect(created.id).toBeDefined();
152
+ materializationId = created.id as string;
153
+
154
+ // 2. List
155
+ const listRes = await fetch(url("/materializations"));
156
+ expect(listRes.status).toBe(200);
157
+ const list = (await listRes.json()) as Record<string, unknown>[];
158
+ expect(list.some((m) => m.id === materializationId)).toBe(true);
159
+
160
+ // 3. Get by ID
161
+ const getRes = await fetch(
162
+ url(`/materializations/${materializationId}`),
163
+ );
164
+ expect(getRes.status).toBe(200);
165
+ const got = (await getRes.json()) as Record<string, unknown>;
166
+ expect(got.status).toBe("PENDING");
167
+
168
+ // 4. Start
169
+ const startRes = await fetch(
170
+ url(`/materializations/${materializationId}?action=start`),
171
+ { method: "POST" },
172
+ );
173
+ expect(startRes.status).toBe(202);
174
+ const started = (await startRes.json()) as Record<string, unknown>;
175
+ expect(started.status).toBe("RUNNING");
176
+
177
+ // 5. Poll until terminal
178
+ const terminal = await pollUntilTerminal(materializationId);
179
+ expect(terminal.status).toBe("SUCCESS");
180
+ const metadata = terminal.metadata as Record<string, unknown>;
181
+ expect(metadata.sourcesBuilt).toBeGreaterThan(0);
182
+
183
+ // 6. Get manifest
184
+ const manifestRes = await fetch(url("/manifest"));
185
+ expect(manifestRes.status).toBe(200);
186
+ const manifest = (await manifestRes.json()) as Record<
187
+ string,
188
+ unknown
189
+ >;
190
+ expect(manifest.entries).toBeDefined();
191
+ const entries = manifest.entries as Record<string, unknown>;
192
+ expect(Object.keys(entries).length).toBeGreaterThan(0);
193
+ const firstEntry = Object.values(entries)[0] as Record<
194
+ string,
195
+ unknown
196
+ >;
197
+ expect(firstEntry.tableName).toBe("order_summary");
198
+
199
+ // 7. Reload manifest
200
+ const reloadRes = await fetch(url("/manifest?action=reload"), {
201
+ method: "POST",
202
+ });
203
+ expect(reloadRes.status).toBe(200);
204
+ const reloadedManifest = (await reloadRes.json()) as Record<
205
+ string,
206
+ unknown
207
+ >;
208
+ expect(reloadedManifest.entries).toBeDefined();
209
+
210
+ // 8. Delete
211
+ const deleteRes = await fetch(
212
+ url(`/materializations/${materializationId}`),
213
+ { method: "DELETE" },
214
+ );
215
+ expect(deleteRes.status).toBe(204);
216
+ materializationId = ""; // prevent afterAll cleanup
217
+ },
218
+ { timeout: 60_000 },
219
+ );
220
+ });
221
+
222
+ // ── Group B: Error cases and state machine validation ────────────
223
+
224
+ describe("error cases", () => {
225
+ it("should stop a PENDING materialization (PENDING -> CANCELLED)", async () => {
226
+ const createRes = await createMaterialization();
227
+ expect(createRes.status).toBe(201);
228
+ const created = (await createRes.json()) as Record<string, unknown>;
229
+ const id = created.id as string;
230
+
231
+ const stopRes = await fetch(
232
+ url(`/materializations/${id}?action=stop`),
233
+ {
234
+ method: "POST",
235
+ },
236
+ );
237
+ expect(stopRes.status).toBe(200);
238
+ const stopped = (await stopRes.json()) as Record<string, unknown>;
239
+ expect(stopped.status).toBe("CANCELLED");
240
+
241
+ await cleanup(id);
242
+ });
243
+
244
+ it("should reject a second concurrent materialization with 409", async () => {
245
+ const first = await createMaterialization();
246
+ expect(first.status).toBe(201);
247
+ const firstData = (await first.json()) as Record<string, unknown>;
248
+ const firstId = firstData.id as string;
249
+
250
+ const second = await createMaterialization();
251
+ expect(second.status).toBe(409);
252
+
253
+ await cleanup(firstId);
254
+ });
255
+
256
+ it("should reject starting a CANCELLED materialization with 409", async () => {
257
+ const createRes = await createMaterialization();
258
+ expect(createRes.status).toBe(201);
259
+ const created = (await createRes.json()) as Record<string, unknown>;
260
+ const id = created.id as string;
261
+
262
+ await fetch(url(`/materializations/${id}?action=stop`), {
263
+ method: "POST",
264
+ });
265
+
266
+ const startRes = await fetch(
267
+ url(`/materializations/${id}?action=start`),
268
+ {
269
+ method: "POST",
270
+ },
271
+ );
272
+ expect(startRes.status).toBe(409);
273
+
274
+ await cleanup(id);
275
+ });
276
+
277
+ it("should reject deleting a PENDING materialization with 409", async () => {
278
+ const createRes = await createMaterialization();
279
+ expect(createRes.status).toBe(201);
280
+ const created = (await createRes.json()) as Record<string, unknown>;
281
+ const id = created.id as string;
282
+
283
+ const deleteRes = await fetch(url(`/materializations/${id}`), {
284
+ method: "DELETE",
285
+ });
286
+ expect(deleteRes.status).toBe(409);
287
+
288
+ await cleanup(id);
289
+ });
290
+
291
+ it("should return 404 for a non-existent materialization", async () => {
292
+ const res = await fetch(
293
+ url("/materializations/non-existent-id-12345"),
294
+ );
295
+ expect(res.status).toBe(404);
296
+ });
297
+ });
298
+
299
+ // ── Group C: Package Teardown ────────────────────────────────────
300
+
301
+ describe("package teardown", () => {
302
+ it(
303
+ "dryRun reports stale entries without dropping tables or deleting rows",
304
+ async () => {
305
+ // Run a full build so there are manifest entries to tear down.
306
+ const createRes = await createMaterialization({
307
+ autoLoadManifest: true,
308
+ });
309
+ expect(createRes.status).toBe(201);
310
+ const created = (await createRes.json()) as Record<string, unknown>;
311
+ const id = created.id as string;
312
+ await fetch(url(`/materializations/${id}?action=start`), {
313
+ method: "POST",
314
+ });
315
+ const terminal = await pollUntilTerminal(id);
316
+ expect(terminal.status).toBe("SUCCESS");
317
+
318
+ // Must delete the materialization record before teardown
319
+ // (teardown refuses to run while an active materialization exists).
320
+ await fetch(url(`/materializations/${id}`), { method: "DELETE" });
321
+
322
+ // dryRun teardown — should report entries but not actually drop them.
323
+ const teardownRes = await fetch(url("/materializations/teardown"), {
324
+ method: "POST",
325
+ headers: { "Content-Type": "application/json" },
326
+ body: JSON.stringify({ dryRun: true }),
327
+ });
328
+ expect(teardownRes.status).toBe(200);
329
+ const teardownResult = (await teardownRes.json()) as Record<
330
+ string,
331
+ unknown
332
+ >;
333
+ const dropped = teardownResult.dropped as Record<string, unknown>[];
334
+ expect(dropped).toBeDefined();
335
+ expect(teardownResult.errors).toBeDefined();
336
+
337
+ // Manifest should still be intact after a dry run.
338
+ const manifestRes = await fetch(url("/manifest"));
339
+ expect(manifestRes.status).toBe(200);
340
+ const manifest = (await manifestRes.json()) as Record<
341
+ string,
342
+ unknown
343
+ >;
344
+ const entries = manifest.entries as Record<string, unknown>;
345
+ expect(Object.keys(entries).length).toBeGreaterThan(0);
346
+ },
347
+ { timeout: 60_000 },
348
+ );
349
+
350
+ it(
351
+ "live teardown drops stale manifest entries and cleans up tables",
352
+ async () => {
353
+ // Build so there are manifest entries.
354
+ const createRes = await createMaterialization({
355
+ autoLoadManifest: true,
356
+ });
357
+ expect(createRes.status).toBe(201);
358
+ const created = (await createRes.json()) as Record<string, unknown>;
359
+ const id = created.id as string;
360
+ await fetch(url(`/materializations/${id}?action=start`), {
361
+ method: "POST",
362
+ });
363
+ const terminal = await pollUntilTerminal(id);
364
+ expect(terminal.status).toBe("SUCCESS");
365
+
366
+ await fetch(url(`/materializations/${id}`), { method: "DELETE" });
367
+
368
+ // Live teardown — should drop everything since all entries are
369
+ // stale (no active build claims them).
370
+ const teardownRes = await fetch(url("/materializations/teardown"), {
371
+ method: "POST",
372
+ headers: { "Content-Type": "application/json" },
373
+ body: JSON.stringify({}),
374
+ });
375
+ expect(teardownRes.status).toBe(200);
376
+ const teardownResult = (await teardownRes.json()) as Record<
377
+ string,
378
+ unknown
379
+ >;
380
+ const dropped = teardownResult.dropped as Record<string, unknown>[];
381
+ expect(dropped.length).toBeGreaterThan(0);
382
+ expect((teardownResult.errors as unknown[]).length).toBe(0);
383
+
384
+ // Manifest should be empty after live teardown.
385
+ const manifestRes = await fetch(url("/manifest"));
386
+ expect(manifestRes.status).toBe(200);
387
+ const manifest = (await manifestRes.json()) as Record<
388
+ string,
389
+ unknown
390
+ >;
391
+ const entries = manifest.entries as Record<string, unknown>;
392
+ expect(Object.keys(entries).length).toBe(0);
393
+ },
394
+ { timeout: 60_000 },
395
+ );
396
+
397
+ it("teardown rejects while an active materialization exists", async () => {
398
+ const createRes = await createMaterialization();
399
+ expect(createRes.status).toBe(201);
400
+ const created = (await createRes.json()) as Record<string, unknown>;
401
+ const id = created.id as string;
402
+
403
+ const teardownRes = await fetch(url("/materializations/teardown"), {
404
+ method: "POST",
405
+ headers: { "Content-Type": "application/json" },
406
+ body: JSON.stringify({}),
407
+ });
408
+ expect(teardownRes.status).toBe(409);
409
+
410
+ await cleanup(id);
411
+ });
412
+
413
+ it(
414
+ "forceRefresh rebuilds and post-build GC step executes",
415
+ async () => {
416
+ // First build — populates manifest.
417
+ const first = await createMaterialization({
418
+ autoLoadManifest: true,
419
+ });
420
+ expect(first.status).toBe(201);
421
+ const firstData = (await first.json()) as Record<string, unknown>;
422
+ const firstId = firstData.id as string;
423
+ await fetch(url(`/materializations/${firstId}?action=start`), {
424
+ method: "POST",
425
+ });
426
+ const firstTerminal = await pollUntilTerminal(firstId);
427
+ expect(firstTerminal.status).toBe("SUCCESS");
428
+ await fetch(url(`/materializations/${firstId}`), {
429
+ method: "DELETE",
430
+ });
431
+
432
+ // Second build with forceRefresh — the buildId won't change
433
+ // (hash of SQL + connection is identical), but forceRefresh
434
+ // forces a rebuild rather than skipping.
435
+ const second = await createMaterialization({
436
+ forceRefresh: true,
437
+ autoLoadManifest: true,
438
+ });
439
+ expect(second.status).toBe(201);
440
+ const secondData = (await second.json()) as Record<string, unknown>;
441
+ const secondId = secondData.id as string;
442
+ await fetch(url(`/materializations/${secondId}?action=start`), {
443
+ method: "POST",
444
+ });
445
+ const secondTerminal = await pollUntilTerminal(secondId);
446
+ expect(secondTerminal.status).toBe("SUCCESS");
447
+
448
+ const metadata = secondTerminal.metadata as Record<string, unknown>;
449
+ // forceRefresh should actually rebuild, not skip.
450
+ expect(metadata.sourcesBuilt).toBeGreaterThan(0);
451
+ expect(metadata.sourcesSkipped).toBe(0);
452
+ // Post-build GC step ran (arrays present even if empty).
453
+ expect(metadata.gcDropped).toBeDefined();
454
+ expect(metadata.gcErrors).toBeDefined();
455
+
456
+ // Manifest should still have entries after rebuild.
457
+ const manifestRes = await fetch(url("/manifest"));
458
+ const manifest = (await manifestRes.json()) as Record<
459
+ string,
460
+ unknown
461
+ >;
462
+ const entries = manifest.entries as Record<string, unknown>;
463
+ expect(Object.keys(entries).length).toBeGreaterThan(0);
464
+
465
+ await cleanup(secondId);
466
+ },
467
+ { timeout: 90_000 },
468
+ );
469
+ });
470
+ });
@@ -7,7 +7,7 @@ import type {
7
7
  Result,
8
8
  } from "@modelcontextprotocol/sdk/types.js"; // Keep these base types
9
9
  import { ErrorCode } from "@modelcontextprotocol/sdk/types.js";
10
- import { afterAll, beforeAll, describe, expect, fail, it } from "bun:test";
10
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
11
11
  import { URL } from "url";
12
12
  import { MCP_ERROR_MESSAGES } from "../../../src/mcp/mcp_constants"; // Keep for error message checks
13
13
 
@@ -381,7 +381,7 @@ describe("MCP Tool Handlers (E2E Integration)", () => {
381
381
  // Await the promise - it should reject due to the closure
382
382
  await toolPromise;
383
383
 
384
- fail("Promise should have rejected due to cancellation");
384
+ throw new Error("Promise should have rejected due to cancellation");
385
385
  } catch (error) {
386
386
  // Check that the error is an Error instance and the message indicates closure/cancellation
387
387
  expect(error).toBeInstanceOf(Error);