@malloy-publisher/server 0.0.208 → 0.0.209

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 (39) hide show
  1. package/dist/app/api-doc.yaml +84 -66
  2. package/dist/app/assets/{EnvironmentPage-DDRxo015.js → EnvironmentPage-BRMCY9d8.js} +1 -1
  3. package/dist/app/assets/HomePage-DQzgkiMI.js +1 -0
  4. package/dist/app/assets/{MainPage-DHVFRXPc.js → MainPage-sZdUjUcu.js} +2 -2
  5. package/dist/app/assets/MaterializationsPage-C_jQQUCJ.js +1 -0
  6. package/dist/app/assets/{ModelPage-B8tF_hYc.js → ModelPage-Bh62OIEq.js} +1 -1
  7. package/dist/app/assets/{PackagePage-LzaaviPn.js → PackagePage-DJW4xjLp.js} +1 -1
  8. package/dist/app/assets/{RouteError-vAYvRAi3.js → RouteError-Vi5yGs_F.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-CTjs2hXr.js → WorkbookPage-BvJMi21d.js} +1 -1
  10. package/dist/app/assets/{core-AOmIKwkc.es-B29cca-e.js → core-BbW0t3RQ.es-L-mZcOk3.js} +1 -1
  11. package/dist/app/assets/{index-Dj4uKosi.js → index-CCcHdeew.js} +1 -1
  12. package/dist/app/assets/{index-CVGIZdxd.js → index-CNFX-CGL.js} +1 -1
  13. package/dist/app/assets/{index-Db2wvjL3.js → index-DuqTjxM_.js} +114 -114
  14. package/dist/app/assets/{index.umd-BRRXibWA.js → index.umd-GgEb4WfT.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/server.mjs +5822 -304
  17. package/package.json +2 -1
  18. package/src/controller/materialization.controller.spec.ts +125 -0
  19. package/src/controller/materialization.controller.ts +23 -27
  20. package/src/materialization_metrics.ts +117 -34
  21. package/src/server-old.ts +2 -11
  22. package/src/server.ts +2 -10
  23. package/src/service/build_plan.spec.ts +116 -0
  24. package/src/service/build_plan.ts +238 -0
  25. package/src/service/connection.ts +4 -0
  26. package/src/service/connection_config.spec.ts +182 -1
  27. package/src/service/connection_config.ts +70 -0
  28. package/src/service/db_utils.spec.ts +159 -1
  29. package/src/service/db_utils.ts +131 -0
  30. package/src/service/materialization_service.spec.ts +388 -184
  31. package/src/service/materialization_service.ts +156 -442
  32. package/src/service/materialization_test_fixtures.ts +119 -0
  33. package/src/service/package.ts +41 -1
  34. package/src/storage/DatabaseInterface.ts +5 -13
  35. package/src/storage/duckdb/MaterializationRepository.ts +5 -14
  36. package/src/storage/duckdb/schema.ts +4 -5
  37. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +72 -211
  38. package/dist/app/assets/HomePage-BeIoPOVO.js +0 -1
  39. package/dist/app/assets/MaterializationsPage-BYnr56IV.js +0 -1
@@ -12,16 +12,9 @@ const PROJECT_NAME = "test-project";
12
12
  const PACKAGE_NAME = "persist-test";
13
13
  const API = `/api/v0/environments/${PROJECT_NAME}/packages/${PACKAGE_NAME}`;
14
14
 
15
- /** Statuses from which no background round is in flight. */
16
- const SETTLED_STATUSES = [
17
- "BUILD_PLAN_READY",
18
- "MANIFEST_FILE_READY",
19
- "FAILED",
20
- "CANCELLED",
21
- ];
22
15
  const TERMINAL_STATUSES = ["MANIFEST_FILE_READY", "FAILED", "CANCELLED"];
23
16
 
24
- describe("Materialization REST API: auto-run + two-round (E2E)", () => {
17
+ describe("Materialization REST API: single-call (E2E)", () => {
25
18
  let env: (RestE2EEnv & { stop(): Promise<void> }) | null = null;
26
19
  let baseUrl: string;
27
20
 
@@ -91,16 +84,13 @@ describe("Materialization REST API: auto-run + two-round (E2E)", () => {
91
84
  return `${baseUrl}${API}${p}`;
92
85
  }
93
86
 
94
- // Most tests here exercise the control-plane two-round flow, so default to
95
- // pauseBetweenPhases=true (pause at BUILD_PLAN_READY). The auto-run group
96
- // overrides this with pauseBetweenPhases=false.
97
87
  async function createMaterialization(
98
88
  body: Record<string, unknown> = {},
99
89
  ): Promise<Response> {
100
90
  return fetch(url("/materializations"), {
101
91
  method: "POST",
102
92
  headers: { "Content-Type": "application/json" },
103
- body: JSON.stringify({ pauseBetweenPhases: true, ...body }),
93
+ body: JSON.stringify(body),
104
94
  });
105
95
  }
106
96
 
@@ -129,10 +119,8 @@ describe("Materialization REST API: auto-run + two-round (E2E)", () => {
129
119
  throw new Error(`Materialization ${id} did not reach the expected state`);
130
120
  }
131
121
 
132
- const pollUntilSettled = (id: string) =>
133
- pollUntil(id, (s) => SETTLED_STATUSES.includes(s));
134
- const pollUntilTerminal = (id: string) =>
135
- pollUntil(id, (s) => TERMINAL_STATUSES.includes(s));
122
+ const pollUntilTerminal = (id: string, timeoutMs = 90_000) =>
123
+ pollUntil(id, (s) => TERMINAL_STATUSES.includes(s), timeoutMs);
136
124
 
137
125
  /**
138
126
  * Drive a materialization to a terminal state and delete its record so it
@@ -142,9 +130,8 @@ describe("Materialization REST API: auto-run + two-round (E2E)", () => {
142
130
  const res = await fetch(url(`/materializations/${id}`));
143
131
  if (res.status !== 200) return;
144
132
 
145
- // Let any in-flight round settle before acting on it.
146
- const settled = await pollUntilSettled(id);
147
- if (!TERMINAL_STATUSES.includes(settled.status as string)) {
133
+ const current = (await res.json()) as Record<string, unknown>;
134
+ if (!TERMINAL_STATUSES.includes(current.status as string)) {
148
135
  await fetch(url(`/materializations/${id}?action=stop`), {
149
136
  method: "POST",
150
137
  });
@@ -153,11 +140,12 @@ describe("Materialization REST API: auto-run + two-round (E2E)", () => {
153
140
  await fetch(url(`/materializations/${id}`), { method: "DELETE" });
154
141
  }
155
142
 
156
- /** First planned source from a BUILD_PLAN_READY materialization. */
157
- function firstPlannedSource(
158
- materialization: Record<string, unknown>,
159
- ): Record<string, unknown> {
160
- const plan = materialization.buildPlan as Record<string, unknown>;
143
+ /** Read Package.buildPlan and return its first planned source. */
144
+ async function firstPlanSource(): Promise<Record<string, unknown>> {
145
+ const res = await fetch(url(""));
146
+ expect(res.status).toBe(200);
147
+ const pkg = (await res.json()) as Record<string, unknown>;
148
+ const plan = pkg.buildPlan as Record<string, unknown>;
161
149
  expect(plan).toBeDefined();
162
150
  const sources = plan.sources as Record<string, Record<string, unknown>>;
163
151
  const values = Object.values(sources);
@@ -165,29 +153,22 @@ describe("Materialization REST API: auto-run + two-round (E2E)", () => {
165
153
  return values[0];
166
154
  }
167
155
 
168
- // ── Group A0: Auto-run lifecycle (default) ───────────────────────
156
+ // ── Group A: Auto-run lifecycle (default) ────────────────────────
169
157
 
170
158
  describe("auto-run lifecycle (default)", () => {
171
159
  it(
172
160
  "runs all phases on create, self-assigns names, and auto-loads",
173
161
  async () => {
174
- // pauseBetweenPhases=false (default): the publisher runs compile +
175
- // plan + build + load in one pass with no Round 2.
176
- const createRes = await createMaterialization({
177
- pauseBetweenPhases: false,
178
- });
162
+ // No buildInstructions: the publisher compiles, self-assigns names,
163
+ // builds every persist source, and auto-loads in one pass.
164
+ const createRes = await createMaterialization();
179
165
  expect(createRes.status).toBe(201);
180
166
  const created = (await createRes.json()) as Record<string, unknown>;
181
167
  expect(created.status).toBe("PENDING");
182
- expect(created.pauseBetweenPhases).toBe(false);
183
168
  const id = created.id as string;
184
169
 
185
170
  // It settles at MANIFEST_FILE_READY without any build instruction.
186
- const built = await pollUntil(
187
- id,
188
- (s) => TERMINAL_STATUSES.includes(s),
189
- 90_000,
190
- );
171
+ const built = await pollUntilTerminal(id);
191
172
  expect(built.status).toBe("MANIFEST_FILE_READY");
192
173
 
193
174
  // The manifest carries the self-assigned physical table name (from
@@ -213,59 +194,37 @@ describe("Materialization REST API: auto-run + two-round (E2E)", () => {
213
194
  },
214
195
  { timeout: 120_000 },
215
196
  );
197
+ });
216
198
 
217
- it("rejects action=build on an auto-run materialization (409)", async () => {
218
- // Force a pause so we can observe a BUILD_PLAN_READY record, but mark it
219
- // auto-run via metadata by creating a real auto-run and racing build is
220
- // flaky; instead assert the guard on a freshly created auto-run that we
221
- // immediately try to build. Auto-run never needs Round 2.
222
- const createRes = await createMaterialization({
223
- pauseBetweenPhases: false,
224
- });
225
- expect(createRes.status).toBe(201);
226
- const { id } = (await createRes.json()) as { id: string };
199
+ // ── Group B: Orchestrated single-call build ──────────────────────
227
200
 
228
- // Drive to terminal so there is no in-flight round, then assert build
229
- // is rejected (auto-run records never accept action=build).
230
- await pollUntil(id, (s) => TERMINAL_STATUSES.includes(s), 90_000);
201
+ describe("orchestrated build (buildInstructions)", () => {
202
+ it(
203
+ "builds directly into caller-assigned names from Package.buildPlan",
204
+ async () => {
205
+ // Read the plan off the package, derive one caller-assigned
206
+ // instruction, and create the materialization already building it.
207
+ const source = await firstPlanSource();
208
+ expect(source.name).toBe("order_summary");
209
+ expect(typeof source.buildId).toBe("string");
231
210
 
232
- const buildRes = await fetch(
233
- url(`/materializations/${id}?action=build`),
234
- {
235
- method: "POST",
236
- headers: { "Content-Type": "application/json" },
237
- body: JSON.stringify({
211
+ const physicalTableName = "order_summary_built";
212
+ const createRes = await createMaterialization({
213
+ buildInstructions: {
238
214
  sources: [
239
215
  {
240
- buildId: "x",
241
- materializedTableId: "mt",
242
- physicalTableName: "t",
216
+ buildId: source.buildId,
217
+ sourceID: source.sourceID,
218
+ materializedTableId: "mt-order-summary",
219
+ physicalTableName,
243
220
  realization: "COPY",
244
221
  },
245
222
  ],
246
- }),
247
- },
248
- );
249
- expect(buildRes.status).toBe(409);
250
-
251
- await fetch(url(`/materializations/${id}?dropTables=true`), {
252
- method: "DELETE",
253
- });
254
- });
255
- });
256
-
257
- // ── Group A: Full two-round lifecycle (happy path) ────────────────
258
-
259
- describe("full two-round lifecycle", () => {
260
- it(
261
- "plans (round 1), builds on control-plane instruction (round 2), then deletes",
262
- async () => {
263
- // Round 1: create kicks off compile + plan in the background.
264
- const createRes = await createMaterialization();
223
+ },
224
+ });
265
225
  expect(createRes.status).toBe(201);
266
226
  const created = (await createRes.json()) as Record<string, unknown>;
267
227
  expect(created.status).toBe("PENDING");
268
- expect(created.id).toBeDefined();
269
228
  const id = created.id as string;
270
229
 
271
230
  // List should include the new run.
@@ -274,49 +233,10 @@ describe("Materialization REST API: auto-run + two-round (E2E)", () => {
274
233
  const list = (await listRes.json()) as Record<string, unknown>[];
275
234
  expect(list.some((m) => m.id === id)).toBe(true);
276
235
 
277
- // Round 1 completes at BUILD_PLAN_READY with a plan for our source.
278
- const planned = await pollUntil(
279
- id,
280
- (s) => s === "BUILD_PLAN_READY" || TERMINAL_STATUSES.includes(s),
281
- );
282
- expect(planned.status).toBe("BUILD_PLAN_READY");
283
- const source = firstPlannedSource(planned);
284
- expect(source.name).toBe("order_summary");
285
- expect(typeof source.buildId).toBe("string");
286
-
287
- // Round 2: control plane instructs a COPY build into a physical name.
288
- const physicalTableName = "order_summary_built";
289
- const buildRes = await fetch(
290
- url(`/materializations/${id}?action=build`),
291
- {
292
- method: "POST",
293
- headers: { "Content-Type": "application/json" },
294
- body: JSON.stringify({
295
- sources: [
296
- {
297
- buildId: source.buildId,
298
- sourceID: source.sourceID,
299
- materializedTableId: "mt-order-summary",
300
- physicalTableName,
301
- realization: "COPY",
302
- },
303
- ],
304
- }),
305
- },
306
- );
307
- expect(buildRes.status).toBe(202);
308
-
309
- // Round 2 completes at MANIFEST_FILE_READY with an inline manifest.
310
- const built = await pollUntil(
311
- id,
312
- (s) =>
313
- s === "MANIFEST_FILE_READY" ||
314
- s === "FAILED" ||
315
- s === "CANCELLED",
316
- );
236
+ // Settles at MANIFEST_FILE_READY with the caller-assigned name.
237
+ const built = await pollUntilTerminal(id);
317
238
  expect(built.status).toBe("MANIFEST_FILE_READY");
318
239
  const manifest = built.manifest as Record<string, unknown>;
319
- expect(manifest).toBeDefined();
320
240
  const entries = manifest.entries as Record<
321
241
  string,
322
242
  Record<string, unknown>
@@ -327,7 +247,7 @@ describe("Materialization REST API: auto-run + two-round (E2E)", () => {
327
247
  expect(entry.sourceName).toBe("order_summary");
328
248
 
329
249
  // A terminal materialization can be deleted; dropTables=true also
330
- // drops the physical table this run produced in Round 2.
250
+ // drops the physical table this run produced.
331
251
  const deleteRes = await fetch(
332
252
  url(`/materializations/${id}?dropTables=true`),
333
253
  { method: "DELETE" },
@@ -340,9 +260,25 @@ describe("Materialization REST API: auto-run + two-round (E2E)", () => {
340
260
  },
341
261
  { timeout: 90_000 },
342
262
  );
263
+
264
+ it("rejects buildInstructions with an unknown buildId at create (400)", async () => {
265
+ const createRes = await createMaterialization({
266
+ buildInstructions: {
267
+ sources: [
268
+ {
269
+ buildId: "not-a-real-build-id",
270
+ materializedTableId: "mt",
271
+ physicalTableName: "t",
272
+ realization: "COPY",
273
+ },
274
+ ],
275
+ },
276
+ });
277
+ expect(createRes.status).toBe(400);
278
+ });
343
279
  });
344
280
 
345
- // ── Group A1: Serve-time routing (the v0 payoff) ─────────────────
281
+ // ── Group C: Serve-time routing (the payoff) ─────────────────────
346
282
  //
347
283
  // Materialization only pays off if *served* queries scan the materialized
348
284
  // table instead of recomputing from the base table. The auto-run path builds
@@ -350,8 +286,7 @@ describe("Materialization REST API: auto-run + two-round (E2E)", () => {
350
286
  // process, so routing is provable in-process: capture the live SQL (scans the
351
287
  // base CSV), run an auto-run materialization, then assert both the executed
352
288
  // query SQL and the /compile preview SQL now scan the physical table and no
353
- // longer touch the base CSV. The /compile half guards the parity fix
354
- // (Environment.compileSource threads the package's bound manifest).
289
+ // longer touch the base CSV.
355
290
  describe("serve-time routing (auto-load)", () => {
356
291
  const MODEL_PATH = "persist_test.malloy";
357
292
  const QUERY = "run: order_summary -> { aggregate: c is count() }";
@@ -390,8 +325,6 @@ describe("Materialization REST API: auto-run + two-round (E2E)", () => {
390
325
  "routes served + compiled queries to the materialized table after auto-load",
391
326
  async () => {
392
327
  // Reset to live: a prior group may have left an in-memory binding.
393
- // Auto-load never writes manifestLocation to publisher.json, so a
394
- // reload re-reads the live model.
395
328
  await fetch(`${baseUrl}${API}?reload=true`);
396
329
 
397
330
  // Baseline: with nothing materialized, both paths recompute from the
@@ -401,16 +334,10 @@ describe("Materialization REST API: auto-run + two-round (E2E)", () => {
401
334
 
402
335
  // Build + auto-load in one pass (self-assigns physicalTableName =
403
336
  // "order_summary" from `#@ persist name="order_summary"`).
404
- const createRes = await createMaterialization({
405
- pauseBetweenPhases: false,
406
- });
337
+ const createRes = await createMaterialization();
407
338
  expect(createRes.status).toBe(201);
408
339
  const { id } = (await createRes.json()) as { id: string };
409
- const built = await pollUntil(
410
- id,
411
- (s) => TERMINAL_STATUSES.includes(s),
412
- 90_000,
413
- );
340
+ const built = await pollUntilTerminal(id);
414
341
  expect(built.status).toBe("MANIFEST_FILE_READY");
415
342
 
416
343
  // The payoff: the served query now scans the materialized table and
@@ -436,25 +363,26 @@ describe("Materialization REST API: auto-run + two-round (E2E)", () => {
436
363
  );
437
364
  });
438
365
 
439
- // ── Group B: State machine and error cases ───────────────────────
366
+ // ── Group D: State machine and error cases ───────────────────────
440
367
 
441
368
  describe("state machine and errors", () => {
442
- it("stops a plan-ready materialization (-> CANCELLED)", async () => {
369
+ it("stops an in-flight materialization (-> CANCELLED)", async () => {
370
+ // Create and immediately stop while the background build is still
371
+ // starting (PENDING). Cooperative abort drives it to CANCELLED.
443
372
  const createRes = await createMaterialization();
444
373
  expect(createRes.status).toBe(201);
445
374
  const { id } = (await createRes.json()) as { id: string };
446
375
 
447
- await pollUntil(id, (s) => s === "BUILD_PLAN_READY");
448
-
449
376
  const stopRes = await fetch(
450
377
  url(`/materializations/${id}?action=stop`),
451
- {
452
- method: "POST",
453
- },
378
+ { method: "POST" },
454
379
  );
455
380
  expect(stopRes.status).toBe(200);
456
- const stopped = (await stopRes.json()) as Record<string, unknown>;
457
- expect(stopped.status).toBe("CANCELLED");
381
+
382
+ const settled = await pollUntilTerminal(id);
383
+ // The build may occasionally win the race and complete; either way it
384
+ // reaches a terminal state and stop returned 200.
385
+ expect(TERMINAL_STATUSES).toContain(settled.status as string);
458
386
 
459
387
  await cleanup(id);
460
388
  });
@@ -470,75 +398,12 @@ describe("Materialization REST API: auto-run + two-round (E2E)", () => {
470
398
  await cleanup(id);
471
399
  });
472
400
 
473
- it("rejects building from a non-plan-ready state with 409", async () => {
474
- const createRes = await createMaterialization();
475
- expect(createRes.status).toBe(201);
476
- const { id } = (await createRes.json()) as { id: string };
477
-
478
- await pollUntil(id, (s) => s === "BUILD_PLAN_READY");
479
- await fetch(url(`/materializations/${id}?action=stop`), {
480
- method: "POST",
481
- });
482
- await pollUntil(id, (s) => s === "CANCELLED");
483
-
484
- const buildRes = await fetch(
485
- url(`/materializations/${id}?action=build`),
486
- {
487
- method: "POST",
488
- headers: { "Content-Type": "application/json" },
489
- body: JSON.stringify({
490
- sources: [
491
- {
492
- buildId: "deadbeef",
493
- materializedTableId: "mt",
494
- physicalTableName: "t",
495
- realization: "COPY",
496
- },
497
- ],
498
- }),
499
- },
500
- );
501
- expect(buildRes.status).toBe(409);
502
-
503
- await fetch(url(`/materializations/${id}`), { method: "DELETE" });
504
- });
505
-
506
- it("rejects a build instruction with an unknown buildId (400)", async () => {
507
- const createRes = await createMaterialization();
508
- expect(createRes.status).toBe(201);
509
- const { id } = (await createRes.json()) as { id: string };
510
-
511
- await pollUntil(id, (s) => s === "BUILD_PLAN_READY");
512
-
513
- const buildRes = await fetch(
514
- url(`/materializations/${id}?action=build`),
515
- {
516
- method: "POST",
517
- headers: { "Content-Type": "application/json" },
518
- body: JSON.stringify({
519
- sources: [
520
- {
521
- buildId: "not-a-real-build-id",
522
- materializedTableId: "mt",
523
- physicalTableName: "t",
524
- realization: "COPY",
525
- },
526
- ],
527
- }),
528
- },
529
- );
530
- expect(buildRes.status).toBe(400);
531
-
532
- await cleanup(id);
533
- });
534
-
535
401
  it("rejects deleting a non-terminal materialization with 409", async () => {
536
402
  const createRes = await createMaterialization();
537
403
  expect(createRes.status).toBe(201);
538
404
  const { id } = (await createRes.json()) as { id: string };
539
405
 
540
- await pollUntil(id, (s) => s === "BUILD_PLAN_READY");
541
-
406
+ // Delete immediately, while the background build is still PENDING.
542
407
  const deleteRes = await fetch(url(`/materializations/${id}`), {
543
408
  method: "DELETE",
544
409
  });
@@ -552,13 +417,9 @@ describe("Materialization REST API: auto-run + two-round (E2E)", () => {
552
417
  expect(createRes.status).toBe(201);
553
418
  const { id } = (await createRes.json()) as { id: string };
554
419
 
555
- await pollUntil(id, (s) => s === "BUILD_PLAN_READY");
556
-
557
420
  const res = await fetch(
558
421
  url(`/materializations/${id}?action=frobnicate`),
559
- {
560
- method: "POST",
561
- },
422
+ { method: "POST" },
562
423
  );
563
424
  expect(res.status).toBe(400);
564
425
 
@@ -1 +0,0 @@
1
- import{B as n,j as t,a as o}from"./index-Db2wvjL3.js";function s(){const a=n();return t.jsx(o,{onClickEnvironment:a})}export{s as default};
@@ -1 +0,0 @@
1
- import{G as r,B as t,j as e,_ as c,a3 as o}from"./index-Db2wvjL3.js";function m(){const{environmentName:s,packageName:a}=r(),n=t();if(s)if(a){const i=c({environmentName:s,packageName:a});return e.jsx(o,{onClickPackageFile:n,resourceUri:i})}else return e.jsx("div",{children:e.jsx("h2",{children:"Missing package name"})});else return e.jsx("div",{children:e.jsx("h2",{children:"Missing environment name"})})}export{m as default};