@malloy-publisher/server 0.0.228 → 0.0.230

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 (94) hide show
  1. package/dist/app/api-doc.yaml +102 -10
  2. package/dist/app/assets/EnvironmentPage-wa_EPkwK.js +1 -0
  3. package/dist/app/assets/{HomePage-Bkwc9Woc.js → HomePage-jnCrupQp.js} +1 -1
  4. package/dist/app/assets/{LightMode-Bum_KBpN.js → LightMode-DYbwNULZ.js} +1 -1
  5. package/dist/app/assets/MainPage-CuJLrPNI.js +2 -0
  6. package/dist/app/assets/MaterializationsPage-D_67x2ee.js +1 -0
  7. package/dist/app/assets/ModelPage-D5JtAWqR.js +1 -0
  8. package/dist/app/assets/PackagePage-BRwtqUSG.js +1 -0
  9. package/dist/app/assets/{RouteError-DmJLpLXm.js → RouteError-CBNNrnSD.js} +1 -1
  10. package/dist/app/assets/ThemeEditorPage-CTCeBneA.js +1 -0
  11. package/dist/app/assets/WorkbookPage-SN6f1RBm.js +1 -0
  12. package/dist/app/assets/{core-CEDZMHV1.es-_yGzNgNH.js → core-Dp3q5Ieu.es-CD5FvM2s.js} +10 -10
  13. package/dist/app/assets/{index-CdmFub34.js → index-B3Nn8Vm2.js} +459 -446
  14. package/dist/app/assets/index-BLCx1EdC.js +18 -0
  15. package/dist/app/assets/index-C_tJstcx.js +529 -0
  16. package/dist/app/assets/{index-CE9xhdra.js → index-CfmBVB6M.js} +1 -1
  17. package/dist/app/assets/{index-OEjKNSYb.js → index-DU4r7GdU.js} +573 -413
  18. package/dist/app/index.html +1 -1
  19. package/dist/instrumentation.mjs +1 -1
  20. package/dist/package_load_worker.mjs +109 -8
  21. package/dist/server.mjs +7504 -80
  22. package/package.json +15 -13
  23. package/scripts/bake-duckdb-extensions.js +5 -2
  24. package/src/config.spec.ts +87 -1
  25. package/src/config.ts +142 -1
  26. package/src/controller/materialization.controller.spec.ts +9 -0
  27. package/src/controller/materialization.controller.ts +15 -0
  28. package/src/ducklake_version.spec.ts +163 -0
  29. package/src/ducklake_version.ts +153 -0
  30. package/src/errors.ts +12 -0
  31. package/src/malloy_pin_prereqs.spec.ts +25 -0
  32. package/src/materialization_metrics.ts +33 -0
  33. package/src/mcp/skills/build_skills_bundle.ts +29 -9
  34. package/src/mcp/skills/skills_bundle.json +1 -1
  35. package/src/mcp/skills/skills_bundle.spec.ts +31 -2
  36. package/src/query_param_utils.ts +11 -0
  37. package/src/server.ts +74 -9
  38. package/src/service/authorize.spec.ts +22 -0
  39. package/src/service/authorize.ts +267 -10
  40. package/src/service/authorize_integration.spec.ts +1068 -26
  41. package/src/service/connection.spec.ts +79 -7
  42. package/src/service/connection.ts +312 -25
  43. package/src/service/connection_config.spec.ts +21 -0
  44. package/src/service/connection_config.ts +15 -1
  45. package/src/service/cron_evaluator.spec.ts +79 -0
  46. package/src/service/cron_evaluator.ts +105 -0
  47. package/src/service/ducklake_lazy_attach.spec.ts +110 -0
  48. package/src/service/environment.ts +156 -17
  49. package/src/service/environment_store.spec.ts +569 -14
  50. package/src/service/environment_store.ts +237 -31
  51. package/src/service/environment_store_anchoring.spec.ts +107 -0
  52. package/src/service/extension_fetch_policy.spec.ts +256 -0
  53. package/src/service/manifest_loader.spec.ts +3 -1
  54. package/src/service/manifest_loader.ts +9 -5
  55. package/src/service/materialization_scheduler.spec.ts +289 -0
  56. package/src/service/materialization_scheduler.ts +304 -0
  57. package/src/service/materialization_service.spec.ts +286 -5
  58. package/src/service/materialization_service.ts +121 -8
  59. package/src/service/materialization_test_fixtures.ts +10 -1
  60. package/src/service/model.spec.ts +67 -0
  61. package/src/service/model.ts +656 -31
  62. package/src/service/package.spec.ts +38 -0
  63. package/src/service/package.ts +102 -2
  64. package/src/service/package_quote_bound_tables.spec.ts +171 -0
  65. package/src/service/persistence_policy.spec.ts +31 -0
  66. package/src/service/quoting.spec.ts +57 -1
  67. package/src/service/quoting.ts +33 -0
  68. package/src/storage/DatabaseInterface.ts +15 -0
  69. package/src/storage/duckdb/DuckDBRepository.ts +17 -0
  70. package/src/storage/duckdb/MaterializationRepository.spec.ts +191 -0
  71. package/src/storage/duckdb/MaterializationRepository.ts +68 -0
  72. package/tests/fixtures/persist-schedule-test/data/orders.csv +5 -0
  73. package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +11 -0
  74. package/tests/fixtures/persist-schedule-test/publisher.json +7 -0
  75. package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +5 -0
  76. package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +11 -0
  77. package/tests/fixtures/persist-schedule-warn-test/publisher.json +6 -0
  78. package/tests/integration/materialization/environment_list.integration.spec.ts +133 -0
  79. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +7 -2
  80. package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +150 -0
  81. package/tests/integration/materialization/schedule_edit.integration.spec.ts +126 -0
  82. package/tests/integration/materialization/scheduler.integration.spec.ts +200 -0
  83. package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +172 -0
  84. package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +256 -0
  85. package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +143 -0
  86. package/dist/app/assets/EnvironmentPage-EW2lbGvb.js +0 -1
  87. package/dist/app/assets/MainPage-oiEy7TNM.js +0 -2
  88. package/dist/app/assets/MaterializationsPage-C_VJsTgU.js +0 -1
  89. package/dist/app/assets/ModelPage-z8REqAmk.js +0 -1
  90. package/dist/app/assets/PackagePage-C2Vtt1Ln.js +0 -1
  91. package/dist/app/assets/ThemeEditorPage-BywFjC7A.js +0 -1
  92. package/dist/app/assets/WorkbookPage-DCMizDMR.js +0 -1
  93. package/dist/app/assets/index-DDMrjIT3.js +0 -18
  94. package/dist/app/assets/index-EqslXZ44.js +0 -527
@@ -14,13 +14,13 @@ import {
14
14
  MaterializationMode,
15
15
  recordAutoLoadOutcome,
16
16
  recordDropTables,
17
+ recordManifestBindDegraded,
17
18
  recordMaterializationRun,
18
19
  recordSourceBuildDuration,
19
20
  recordSourcesOutcome,
20
21
  } from "../materialization_metrics";
21
22
  import {
22
23
  BuildInstruction,
23
- BuildManifest,
24
24
  BuildManifestResult,
25
25
  BuildPlan,
26
26
  FreshnessManifest,
@@ -41,7 +41,12 @@ import {
41
41
  iterGraphSources,
42
42
  } from "./build_plan";
43
43
  import { EnvironmentStore } from "./environment_store";
44
- import { bareTableName, quoteIdentifier, quoteTablePath } from "./quoting";
44
+ import {
45
+ bareTableName,
46
+ quoteIdentifier,
47
+ quoteManifestTablePath,
48
+ quoteTablePath,
49
+ } from "./quoting";
45
50
  import { resolveEnvironmentId } from "./resolve_environment";
46
51
 
47
52
  /**
@@ -177,6 +182,38 @@ export class MaterializationService {
177
182
  );
178
183
  }
179
184
 
185
+ /**
186
+ * Every materialization across all packages in an environment, newest first.
187
+ * Each record carries its `packageName`, so an env-scoped view can group or
188
+ * label by package without a per-package fan-out.
189
+ */
190
+ async listEnvironmentMaterializations(
191
+ environmentName: string,
192
+ options?: { limit?: number; offset?: number },
193
+ ): Promise<Materialization[]> {
194
+ const environmentId = await this.resolveEnvironmentId(environmentName);
195
+ return this.repository.listMaterializationsByEnvironment(
196
+ environmentId,
197
+ options,
198
+ );
199
+ }
200
+
201
+ /**
202
+ * `created_at` of the newest scheduler-fired materialization for a package,
203
+ * or null if none. The standalone scheduler uses this on its first arm to
204
+ * recover a fire missed during downtime (see MaterializationScheduler.arm).
205
+ */
206
+ async getLatestScheduledFireAt(
207
+ environmentName: string,
208
+ packageName: string,
209
+ ): Promise<Date | null> {
210
+ const environmentId = await this.resolveEnvironmentId(environmentName);
211
+ return this.repository.getLatestScheduledFireAt(
212
+ environmentId,
213
+ packageName,
214
+ );
215
+ }
216
+
180
217
  async getMaterialization(
181
218
  environmentName: string,
182
219
  packageName: string,
@@ -219,6 +256,13 @@ export class MaterializationService {
219
256
  buildInstructions?: BuildInstruction[];
220
257
  referenceManifest?: ManifestReference[];
221
258
  strictUpstreams?: boolean;
259
+ /**
260
+ * What initiated this run. `ON_DEMAND` (default) = a manual/API create;
261
+ * `SCHEDULER` = the standalone materialization scheduler firing a
262
+ * package's `materialization.schedule` cron. Recorded on the run
263
+ * metadata so a scheduled rebuild is distinguishable from a manual one.
264
+ */
265
+ trigger?: "ON_DEMAND" | "SCHEDULER";
222
266
  } = {},
223
267
  ): Promise<Materialization> {
224
268
  const environmentId = await this.resolveEnvironmentId(environmentName);
@@ -244,10 +288,12 @@ export class MaterializationService {
244
288
  }
245
289
 
246
290
  const forceRefresh = options.forceRefresh ?? false;
291
+ const trigger = options.trigger ?? "ON_DEMAND";
247
292
  const metadata = {
248
293
  forceRefresh,
249
294
  sourceNames: options.sourceNames ?? null,
250
295
  mode: orchestrated ? "orchestrated" : "auto",
296
+ trigger,
251
297
  };
252
298
 
253
299
  let created: Materialization;
@@ -280,6 +326,7 @@ export class MaterializationService {
280
326
  buildInstructions,
281
327
  referenceManifest: options.referenceManifest,
282
328
  strictUpstreams: options.strictUpstreams,
329
+ trigger,
283
330
  },
284
331
  signal,
285
332
  ),
@@ -306,6 +353,7 @@ export class MaterializationService {
306
353
  buildInstructions: BuildInstruction[] | undefined;
307
354
  referenceManifest: ManifestReference[] | undefined;
308
355
  strictUpstreams: boolean | undefined;
356
+ trigger: "ON_DEMAND" | "SCHEDULER";
309
357
  },
310
358
  signal: AbortSignal,
311
359
  ): Promise<void> {
@@ -379,6 +427,7 @@ export class MaterializationService {
379
427
  forceRefresh: opts.forceRefresh,
380
428
  sourceNames: opts.sourceNames ?? null,
381
429
  mode,
430
+ trigger: opts.trigger,
382
431
  sourcesBuilt,
383
432
  sourcesReused,
384
433
  durationMs,
@@ -466,9 +515,13 @@ export class MaterializationService {
466
515
  /**
467
516
  * Project the caller-supplied upstream reference manifest into the seed
468
517
  * entry map `executeInstructedBuild` consumes. Each reference is keyed by the
469
- * compiler's manifest-lookup sourceEntityId and carries only the physical
470
- * table name — enough for the build Manifest to resolve a downstream persist
471
- * reference to the existing table without rebuilding the upstream.
518
+ * compiler's manifest-lookup sourceEntityId and carries the physical table
519
+ * name plus the connection it lives on — enough for the build Manifest to
520
+ * resolve a downstream persist reference to the existing table, and to quote
521
+ * that reference for the connection's dialect so it resolves on a
522
+ * case-folding engine (see {@link quoteSeedTablePath}). `connectionName` is
523
+ * optional on the wire: an older control plane that omits it seeds unquoted,
524
+ * exactly as before.
472
525
  */
473
526
  private referenceManifestToEntries(
474
527
  referenceManifest: ManifestReference[] | undefined,
@@ -478,6 +531,7 @@ export class MaterializationService {
478
531
  entries[ref.sourceEntityId] = {
479
532
  sourceEntityId: ref.sourceEntityId,
480
533
  physicalTableName: ref.physicalTableName,
534
+ connectionName: ref.connectionName,
481
535
  };
482
536
  }
483
537
  return entries;
@@ -524,11 +578,17 @@ export class MaterializationService {
524
578
  // stamps freshness (dataAsOf/window/fallback) on the wire manifest it
525
579
  // distributes, not on this in-memory post-build load, so these sources are
526
580
  // bound un-gated (always serve the freshly-built table).
527
- const manifestEntries: BuildManifest["entries"] = {};
581
+ const manifestEntries: FreshnessManifest = {};
528
582
  for (const [sourceEntityId, entry] of Object.entries(entries)) {
529
583
  if (entry.physicalTableName) {
530
584
  manifestEntries[sourceEntityId] = {
531
585
  tableName: entry.physicalTableName,
586
+ // Carried so the bind step can quote the physical path for the
587
+ // connection's dialect (Package.quoteBoundTableNames) — the
588
+ // build CREATEd it quoted, so an unquoted read would miss on a
589
+ // case-folding engine in the window before the control plane's
590
+ // wire-manifest rebind.
591
+ connectionName: entry.connectionName,
532
592
  };
533
593
  }
534
594
  }
@@ -586,6 +646,43 @@ export class MaterializationService {
586
646
  }
587
647
  }
588
648
 
649
+ /**
650
+ * Quote a seeded upstream's physical path for the in-memory build Manifest,
651
+ * mirroring the CREATE side ({@link quoteManifestTablePath}) so a downstream
652
+ * persist source resolves the reference on a case-folding engine. The seed's
653
+ * own connection (carried on the entry) supplies the dialect. If it can't be
654
+ * resolved — an older control plane omitted `connectionName`, or the named
655
+ * connection isn't part of this build — the path binds unquoted: the build
656
+ * then fails loudly at the downstream CREATE on a case-folding engine (a
657
+ * self-signaling miss, unlike the serve path), and is unaffected elsewhere.
658
+ */
659
+ private quoteSeedTablePath(
660
+ sourceEntityId: string,
661
+ physicalTableName: string,
662
+ connectionName: string | undefined,
663
+ connections: Map<string, MalloyConnection>,
664
+ ): string {
665
+ const connection = connectionName
666
+ ? connections.get(connectionName)
667
+ : undefined;
668
+ if (!connection) {
669
+ // A named-but-absent connection is a real gap (the seed can't be
670
+ // quoted); a missing name is the benign older-CP default. Only the
671
+ // former is worth a signal.
672
+ if (connectionName) {
673
+ recordManifestBindDegraded();
674
+ logger.warn(
675
+ "Seeded upstream names a connection not present in this build; " +
676
+ "leaving its manifest path unquoted (a downstream build will " +
677
+ "fail on a case-folding engine)",
678
+ { sourceEntityId, connectionName },
679
+ );
680
+ }
681
+ return physicalTableName;
682
+ }
683
+ return quoteManifestTablePath(physicalTableName, connection.dialectName);
684
+ }
685
+
589
686
  /**
590
687
  * Shared build loop for both auto-run and orchestrated builds. Seeds the
591
688
  * manifest with carried-forward (reused) upstream entries so downstream
@@ -630,8 +727,19 @@ export class MaterializationService {
630
727
  const entries: Record<string, ManifestEntry> = {};
631
728
  for (const [sourceEntityId, entry] of Object.entries(seedEntries)) {
632
729
  if (entry.physicalTableName) {
730
+ // The build Manifest feeds a downstream persist's `FROM` verbatim,
731
+ // so a seeded upstream must carry the SAME quoting the builder
732
+ // CREATEd it with — else the downstream CREATE misses the
733
+ // case-preserved table on a case-folding engine. The seed keeps its
734
+ // logical (unquoted) name in `entries` (the committed manifest, in
735
+ // logical-name space); only the in-memory build Manifest is quoted.
633
736
  manifest.update(sourceEntityId, {
634
- tableName: entry.physicalTableName,
737
+ tableName: this.quoteSeedTablePath(
738
+ sourceEntityId,
739
+ entry.physicalTableName,
740
+ entry.connectionName,
741
+ connections,
742
+ ),
635
743
  });
636
744
  }
637
745
  entries[sourceEntityId] = entry;
@@ -733,7 +841,12 @@ export class MaterializationService {
733
841
  }
734
842
 
735
843
  // Make this table visible to downstream sources built later in this run.
736
- manifest.update(sourceEntityId, { tableName: physicalTableName });
844
+ // Record the SAME quoted path the CREATE used (not the logical name): the
845
+ // build Manifest is pasted into a downstream `FROM` verbatim, so on a
846
+ // case-folding engine an unquoted name would miss the case-preserved table
847
+ // just written. The returned entry (below) keeps the logical name for the
848
+ // committed manifest; only this in-memory build Manifest is quoted.
849
+ manifest.update(sourceEntityId, { tableName: quotedPhysical });
737
850
 
738
851
  const durationMs = Math.round(performance.now() - startTime);
739
852
  recordSourceBuildDuration(durationMs);
@@ -120,6 +120,12 @@ export function fakeSource(opts: {
120
120
  freshnessSchedule?: FakeFreshnessSchedule;
121
121
  /** Model-file-level (`##`) freshness default. */
122
122
  modelFreshnessSchedule?: FakeFreshnessSchedule;
123
+ /**
124
+ * Spy on the args Malloy's SQL generation is handed — chiefly the
125
+ * `buildManifest` that resolves upstream persist references — so a test can
126
+ * assert what physical name a downstream build sees for its upstream.
127
+ */
128
+ onGetSQL?: (sqlOpts: unknown) => void;
123
129
  }): PersistSource {
124
130
  const fields = opts.annotationFields;
125
131
  return {
@@ -128,7 +134,10 @@ export function fakeSource(opts: {
128
134
  connectionName: opts.connectionName ?? "duckdb",
129
135
  dialectName: opts.dialectName ?? "duckdb",
130
136
  makeBuildId: () => opts.sourceEntityId,
131
- getSQL: () => opts.sql ?? "SELECT 1",
137
+ getSQL: (sqlOpts?: unknown) => {
138
+ opts.onGetSQL?.(sqlOpts);
139
+ return opts.sql ?? "SELECT 1";
140
+ },
132
141
  annotations: {
133
142
  parseAsTag: () => ({
134
143
  tag: fakeTag(fields, opts.freshnessSchedule),
@@ -4,6 +4,7 @@ import fs from "fs/promises";
4
4
  import sinon from "sinon";
5
5
 
6
6
  import {
7
+ AccessDeniedError,
7
8
  BadRequestError,
8
9
  ModelNotFoundError,
9
10
  PayloadTooLargeError,
@@ -420,6 +421,11 @@ describe("service/model", () => {
420
421
  undefined,
421
422
  undefined,
422
423
  undefined,
424
+ undefined,
425
+ // Model surfaces `region` so filterGivensToModelSurface (see
426
+ // model.ts) forwards it rather than dropping it as unknown.
427
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
428
+ [{ name: "region", type: "string" }] as any,
423
429
  );
424
430
 
425
431
  await expect(
@@ -716,6 +722,11 @@ describe("service/model", () => {
716
722
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
717
723
  runnableCells as any,
718
724
  undefined,
725
+ undefined,
726
+ // Model surfaces `target_code` so filterGivensToModelSurface
727
+ // (see model.ts) forwards it rather than dropping it as unknown.
728
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
729
+ [{ name: "target_code", type: "string" }] as any,
719
730
  );
720
731
 
721
732
  await expect(
@@ -819,6 +830,62 @@ describe("service/model", () => {
819
830
  });
820
831
  });
821
832
 
833
+ describe("authorize struct-resolution drift invariant", () => {
834
+ // collectAllReachableGates walks struct.fields using Malloy's own
835
+ // isJoined ('join' in sd) / isSourceDef (struct.type is a known source
836
+ // kind) guards. If a future Malloy version adds a new source `type`
837
+ // that isSourceDef doesn't yet recognize, a genuinely-joined field
838
+ // would fail isSourceDef and used to be silently `continue`d past —
839
+ // failing OPEN (the joined source's gate is never found, so it's never
840
+ // evaluated). This confirms the walk instead denies loudly when it
841
+ // finds a field that IS a join but can't be resolved to a walkable
842
+ // SourceDef, rather than silently treating it as "nothing to gate".
843
+ it("denies rather than silently skipping when a joined field doesn't resolve to a walkable SourceDef", async () => {
844
+ const driftedJoinField = {
845
+ name: "mystery_join",
846
+ join: "one", // isJoined: true ('join' in sd)
847
+ // A source `type` isSourceDef doesn't (yet) recognize -- stands
848
+ // in for a future Malloy source kind the duck-typed guard hasn't
849
+ // been updated to cover.
850
+ type: "future_source_kind",
851
+ fields: [],
852
+ };
853
+ const runStruct = {
854
+ type: "table",
855
+ name: "root",
856
+ fields: [driftedJoinField],
857
+ };
858
+ const modelDef = { contents: {}, exports: [], queryList: [] };
859
+ const runnable = {
860
+ getPreparedQuery: sinon.stub().resolves({
861
+ _query: { structRef: runStruct },
862
+ _modelDef: modelDef,
863
+ }),
864
+ };
865
+
866
+ const model = new Model(
867
+ packageName,
868
+ mockModelPath,
869
+ {},
870
+ "model",
871
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
872
+ { loadQuery: sinon.stub() } as any, // modelMaterializer
873
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
874
+ modelDef as any,
875
+ undefined,
876
+ undefined,
877
+ undefined,
878
+ undefined,
879
+ undefined,
880
+ );
881
+
882
+ await expect(
883
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
884
+ model.assertAuthorizedForAllSources(runnable as any, {}),
885
+ ).rejects.toThrow(AccessDeniedError);
886
+ });
887
+ });
888
+
822
889
  describe("static methods", () => {
823
890
  describe("getModelRuntime", () => {
824
891
  it("should throw ModelNotFoundError for invalid modelPath", async () => {