@malloy-publisher/server 0.0.232 → 0.0.234

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 (83) hide show
  1. package/README.docker.md +1 -0
  2. package/dist/app/api-doc.yaml +269 -10
  3. package/dist/app/assets/{EnvironmentPage-DXEaZIPx.js → EnvironmentPage-DTZQ4Gxc.js} +1 -1
  4. package/dist/app/assets/{HomePage-kofsqpZt.js → HomePage-C5mlDPXK.js} +1 -1
  5. package/dist/app/assets/{LightMode-CNhIlIlJ.js → LightMode-DGNmhG0u.js} +1 -1
  6. package/dist/app/assets/{MainPage-Bgqo8jCy.js → MainPage-CVL_wmP4.js} +1 -1
  7. package/dist/app/assets/{MaterializationsPage-CgBlgGz2.js → MaterializationsPage-DmzMBCpy.js} +1 -1
  8. package/dist/app/assets/{ModelPage-B0TjoDtf.js → ModelPage-Dbvf4QbB.js} +1 -1
  9. package/dist/app/assets/{PackagePage-BL8vnFj1.js → PackagePage-DxdHc2Qs.js} +1 -1
  10. package/dist/app/assets/{RouteError-BzPby0X2.js → RouteError-OJdT4tCd.js} +1 -1
  11. package/dist/app/assets/{ThemeEditorPage-CTEP_9r3.js → ThemeEditorPage-Bk7s0KXY.js} +1 -1
  12. package/dist/app/assets/{WorkbookPage-BwM3BmKw.js → WorkbookPage-j_vCWdN3.js} +1 -1
  13. package/dist/app/assets/{core-CK68iv6w.es-CpRxXBt7.js → core-Rj_4rRnA.es-DoIfLxDJ.js} +1 -1
  14. package/dist/app/assets/{index-B33zGctF.js → index-B_jKMR35.js} +4 -4
  15. package/dist/app/assets/{index-CmkW1MiE.js → index-D-rDyK11.js} +1 -1
  16. package/dist/app/assets/{index-tXJXwdyj.js → index-DWIe_hK0.js} +1 -1
  17. package/dist/app/assets/{index-BkiWKaAF.js → index-hw-xn0X7.js} +1 -1
  18. package/dist/app/index.html +1 -1
  19. package/dist/package_load_worker.mjs +53 -3
  20. package/dist/server.mjs +20277 -925
  21. package/package.json +1 -1
  22. package/src/config.ts +35 -1
  23. package/src/controller/connection.controller.spec.ts +46 -0
  24. package/src/controller/connection.controller.ts +105 -2
  25. package/src/controller/materialization.controller.spec.ts +25 -0
  26. package/src/controller/materialization.controller.ts +60 -0
  27. package/src/controller/model.controller.ts +24 -0
  28. package/src/controller/query.controller.ts +83 -10
  29. package/src/json_utils.spec.ts +51 -0
  30. package/src/json_utils.ts +33 -0
  31. package/src/mcp/handler_utils.ts +10 -2
  32. package/src/mcp/query_envelope.spec.ts +229 -0
  33. package/src/mcp/query_envelope.ts +240 -0
  34. package/src/mcp/server.protocol.spec.ts +128 -16
  35. package/src/mcp/skills/build_skills_bundle.ts +94 -4
  36. package/src/mcp/skills/skills_bundle.json +1 -1
  37. package/src/mcp/skills/skills_bundle.spec.ts +113 -4
  38. package/src/mcp/tool_response.spec.ts +108 -0
  39. package/src/mcp/tool_response.ts +138 -0
  40. package/src/mcp/tools/compile_tool.spec.ts +112 -4
  41. package/src/mcp/tools/compile_tool.ts +61 -30
  42. package/src/mcp/tools/docs_search_tool.ts +6 -16
  43. package/src/mcp/tools/execute_query_tool.spec.ts +154 -3
  44. package/src/mcp/tools/execute_query_tool.ts +131 -155
  45. package/src/mcp/tools/get_context_tool.spec.ts +63 -3
  46. package/src/mcp/tools/get_context_tool.ts +43 -46
  47. package/src/mcp/tools/reload_package_tool.ts +3 -29
  48. package/src/mcp_config.spec.ts +919 -0
  49. package/src/mcp_config.ts +425 -0
  50. package/src/oom_guards.integration.spec.ts +11 -3
  51. package/src/package_load/package_load_pool.ts +2 -0
  52. package/src/package_load/package_load_worker.ts +17 -5
  53. package/src/package_load/protocol.ts +6 -0
  54. package/src/query_metadata_metrics.ts +49 -0
  55. package/src/server.ts +99 -3
  56. package/src/service/build_plan.spec.ts +125 -0
  57. package/src/service/build_plan.ts +108 -7
  58. package/src/service/compile_fragment_techniques.spec.ts +156 -0
  59. package/src/service/connection.spec.ts +371 -1
  60. package/src/service/connection.ts +77 -14
  61. package/src/service/connection_config.spec.ts +60 -0
  62. package/src/service/connection_config.ts +75 -0
  63. package/src/service/duckdb_instance_isolation.spec.ts +137 -0
  64. package/src/service/environment.ts +57 -3
  65. package/src/service/materialization_config_validation.spec.ts +99 -0
  66. package/src/service/materialization_config_validation.ts +120 -0
  67. package/src/service/materialization_schedule_surface.spec.ts +124 -0
  68. package/src/service/materialization_service.spec.ts +119 -0
  69. package/src/service/materialization_service.ts +186 -3
  70. package/src/service/materialization_test_fixtures.ts +86 -21
  71. package/src/service/model.spec.ts +45 -1
  72. package/src/service/model.ts +171 -23
  73. package/src/service/model_limits.spec.ts +28 -0
  74. package/src/service/model_limits.ts +21 -0
  75. package/src/service/package.ts +24 -1
  76. package/src/service/package_manifest.spec.ts +137 -4
  77. package/src/service/package_manifest.ts +140 -5
  78. package/src/service/persist_annotation_validation.spec.ts +12 -0
  79. package/src/service/persist_annotation_validation.ts +9 -4
  80. package/src/service/query_metadata.spec.ts +408 -0
  81. package/src/service/query_metadata.ts +492 -0
  82. package/src/service/query_metadata_identity.spec.ts +149 -0
  83. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +37 -12
@@ -39,6 +39,23 @@ import {
39
39
 
40
40
  type MockRepo = sinon.SinonStubbedInstance<ResourceRepository>;
41
41
 
42
+ /**
43
+ * Turn per-query metadata on for one test and put the environment back after.
44
+ * The feature ships dark, so a test that asserts what a statement carries has
45
+ * to enable it — and saying so per test keeps the default honest here: a test
46
+ * that forgets is testing the shipped default, which is "nothing attached".
47
+ */
48
+ function withQueryMetadataOn(): void {
49
+ const original = process.env.PUBLISHER_QUERY_METADATA;
50
+ beforeEach(() => {
51
+ process.env.PUBLISHER_QUERY_METADATA = "on";
52
+ });
53
+ afterEach(() => {
54
+ if (original === undefined) delete process.env.PUBLISHER_QUERY_METADATA;
55
+ else process.env.PUBLISHER_QUERY_METADATA = original;
56
+ });
57
+ }
58
+
42
59
  describe("redactConnectionSecrets", () => {
43
60
  it("strips credential values but keeps the message legible", () => {
44
61
  const source = {
@@ -877,6 +894,7 @@ describe("MaterializationService", () => {
877
894
  describe("deleteMaterialization telemetry", () => {
878
895
  let ctx: ReturnType<typeof createMocks>;
879
896
  let harness: MetricsHarness;
897
+ withQueryMetadataOn();
880
898
 
881
899
  beforeEach(async () => {
882
900
  ctx = createMocks();
@@ -936,6 +954,27 @@ describe("deleteMaterialization telemetry", () => {
936
954
  ).toBe(0);
937
955
  });
938
956
 
957
+ it("tags the retiring drops as ops work", async () => {
958
+ // A drop is warehouse work someone accounts for later, and it is the one
959
+ // statement an operator goes looking for after a table disappears.
960
+ const runSQL = sinon.stub().resolves();
961
+ dropTablesFixture(runSQL);
962
+
963
+ await ctx.service.deleteMaterialization("my-env", "pkg", "mat-1", {
964
+ dropTables: true,
965
+ });
966
+
967
+ expect(runSQL.callCount).toBeGreaterThan(0);
968
+ for (const call of runSQL.getCalls()) {
969
+ expect(call.args[1]?.queryMetadata).toMatchObject({
970
+ class: "ops",
971
+ environment: "my-env",
972
+ package: "pkg",
973
+ run_id: "mat-1",
974
+ });
975
+ }
976
+ });
977
+
939
978
  it("meters a failed drop as outcome=failure and still deletes the record", async () => {
940
979
  dropTablesFixture(sinon.stub().rejects(new Error("drop boom")));
941
980
 
@@ -1472,6 +1511,7 @@ describe("executeInstructedBuild", () => {
1472
1511
 
1473
1512
  describe("buildOneSource", () => {
1474
1513
  let ctx: ReturnType<typeof createMocks>;
1514
+ withQueryMetadataOn();
1475
1515
  beforeEach(() => {
1476
1516
  ctx = createMocks();
1477
1517
  });
@@ -1481,12 +1521,19 @@ describe("buildOneSource", () => {
1481
1521
  physicalTableName: string,
1482
1522
  dialectName?: string,
1483
1523
  manifest: Manifest = new Manifest(),
1524
+ metadata?: {
1525
+ source?: Record<string, string>;
1526
+ package?: Record<string, string>;
1527
+ connection?: Record<string, string>;
1528
+ context?: Record<string, string | undefined>;
1529
+ },
1484
1530
  ): Promise<{ sourceEntityId: string; physicalTableName: string }> {
1485
1531
  const source = fakeSource({
1486
1532
  name: "orders",
1487
1533
  sourceEntityId: "abcdef1234567890",
1488
1534
  sql: "SELECT * FROM t",
1489
1535
  dialectName,
1536
+ queryMetadata: metadata?.source,
1490
1537
  });
1491
1538
  const instruction: BuildInstruction = {
1492
1539
  sourceEntityId: "abcdef1234567890",
@@ -1504,6 +1551,7 @@ describe("buildOneSource", () => {
1504
1551
  m: Manifest,
1505
1552
  env: unknown,
1506
1553
  built: Record<string, unknown>,
1554
+ buildMetadata?: unknown,
1507
1555
  ) => Promise<{ sourceEntityId: string; physicalTableName: string }>;
1508
1556
  }
1509
1557
  ).buildOneSource(
@@ -1514,14 +1562,85 @@ describe("buildOneSource", () => {
1514
1562
  manifest,
1515
1563
  {
1516
1564
  getApiConnection: () => {
1565
+ if (metadata?.connection) {
1566
+ return { queryMetadata: metadata.connection };
1567
+ }
1517
1568
  throw new Error("no connection config in this colocated test");
1518
1569
  },
1519
1570
  getEnvironmentPath: () => "/tmp/env",
1520
1571
  },
1521
1572
  {}, // builtEntries — colocated build with no prior storage upstreams
1573
+ metadata
1574
+ ? {
1575
+ packageMaterialization: metadata.package
1576
+ ? {
1577
+ schedule: null,
1578
+ freshness: null,
1579
+ queryMetadata: metadata.package,
1580
+ }
1581
+ : null,
1582
+ context: metadata.context ?? { queryClass: "materialize" },
1583
+ }
1584
+ : undefined,
1522
1585
  );
1523
1586
  }
1524
1587
 
1588
+ it("tags every statement of the build with the resolved metadata", async () => {
1589
+ // One unit of work: the warehouse's query history has to show the staging
1590
+ // drop, the CTAS, the swap and the rename as the same build.
1591
+ const runSQL = sinon.stub().resolves();
1592
+ await callBuildOneSource({ runSQL }, "orders_v1", undefined, undefined, {
1593
+ connection: { deployment: "eu" },
1594
+ package: { team: "finance", tier: "bronze" },
1595
+ source: { tier: "gold" },
1596
+ context: { queryClass: "materialize", runId: "run-7" },
1597
+ });
1598
+
1599
+ const bags = runSQL.getCalls().map((c) => c.args[1]?.queryMetadata);
1600
+ expect(bags).toHaveLength(4);
1601
+ for (const bag of bags) {
1602
+ expect(bag).toEqual({
1603
+ deployment: "eu",
1604
+ team: "finance",
1605
+ // The source's own declaration wins over the package's.
1606
+ tier: "gold",
1607
+ class: "materialize",
1608
+ run_id: "run-7",
1609
+ source: "orders",
1610
+ });
1611
+ }
1612
+ });
1613
+
1614
+ it("issues untagged statements when nothing declares metadata", async () => {
1615
+ // No metadata context (an internal caller) means the statements are exactly
1616
+ // what they were before this existed.
1617
+ const runSQL = sinon.stub().resolves();
1618
+ await callBuildOneSource({ runSQL }, "orders_v1");
1619
+ for (const call of runSQL.getCalls()) {
1620
+ expect(call.args[1]?.queryMetadata).toBeUndefined();
1621
+ }
1622
+ });
1623
+
1624
+ it("tags the cleanup drop after a failed build", async () => {
1625
+ // The cleanup statement belongs to the same build, and it is the one an
1626
+ // operator goes looking for after a failure.
1627
+ const runSQL = sinon.stub();
1628
+ runSQL.onCall(0).resolves();
1629
+ runSQL.onCall(1).rejects(new Error("create boom"));
1630
+ runSQL.onCall(2).resolves();
1631
+ await expect(
1632
+ callBuildOneSource({ runSQL }, "orders_v1", undefined, undefined, {
1633
+ source: { team: "finance" },
1634
+ context: { queryClass: "materialize" },
1635
+ }),
1636
+ ).rejects.toThrow("create boom");
1637
+ expect(runSQL.lastCall.args[1]?.queryMetadata).toEqual({
1638
+ team: "finance",
1639
+ class: "materialize",
1640
+ source: "orders",
1641
+ });
1642
+ });
1643
+
1525
1644
  it("stages, swaps, and renames in a crash-safe order", async () => {
1526
1645
  const runSQL = sinon.stub().resolves();
1527
1646
  const entry = await callBuildOneSource({ runSQL }, "orders_v1");
@@ -43,7 +43,14 @@ import {
43
43
  deriveAnnotationFields,
44
44
  projectToPublicColumns,
45
45
  iterGraphSources,
46
+ resolveQueryMetadata,
46
47
  } from "./build_plan";
48
+ import {
49
+ mergeQueryMetadata,
50
+ type QueryContext,
51
+ type QueryMetadata,
52
+ } from "./query_metadata";
53
+ import type { components } from "../api";
47
54
  import { getPersistStorageMode } from "../config";
48
55
  import { EnvironmentStore } from "./environment_store";
49
56
  import { assertMaterializationEligible } from "./materialization_eligibility";
@@ -88,6 +95,47 @@ interface BuildEnvironment {
88
95
  getEnvironmentPath(): string;
89
96
  }
90
97
 
98
+ /**
99
+ * What a build needs to tag the statements it issues: the package-level layer of
100
+ * per-query metadata, and the run's own context. Assembled once per run — every
101
+ * field is constant for the run, so a source only adds its own name.
102
+ */
103
+ interface BuildQueryMetadata {
104
+ packageMaterialization:
105
+ | components["schemas"]["PackageMaterializationConfig"]
106
+ | null;
107
+ context: QueryContext;
108
+ }
109
+
110
+ /**
111
+ * A connection's two metadata layers: the overridable default and the properties
112
+ * the deployment enforces.
113
+ *
114
+ * Fails open — a build must not fail because a connection's config could not be
115
+ * read for its tags — so an unreadable connection costs the layers, never the
116
+ * statement.
117
+ */
118
+ function connectionMetadataLayers(
119
+ environment: BuildEnvironment,
120
+ connectionName: string,
121
+ ): { default: QueryMetadata | null; enforced: QueryMetadata | null } {
122
+ try {
123
+ const connection = environment.getApiConnection(connectionName);
124
+ return {
125
+ default: connection?.queryMetadata ?? null,
126
+ enforced: connection?.queryMetadataEnforced ?? null,
127
+ };
128
+ } catch (error) {
129
+ // Diagnosable rather than silent: the layer this costs is the enforced
130
+ // one, and it is the only drop with no metric behind it.
131
+ logger.debug("No query-metadata layers for connection", {
132
+ connectionName,
133
+ error,
134
+ });
135
+ return { default: null, enforced: null };
136
+ }
137
+ }
138
+
91
139
  /**
92
140
  * Length of the sourceEntityId prefix used when synthesizing staging table
93
141
  * names. 12 hex chars is 48 bits of entropy, well inside every dialect's
@@ -413,6 +461,14 @@ export class MaterializationService {
413
461
  * metadata so a scheduled rebuild is distinguishable from a manual one.
414
462
  */
415
463
  trigger?: "ON_DEMAND" | "SCHEDULER";
464
+ /**
465
+ * What the caller knows about this run and the publisher does not,
466
+ * attached as query metadata to the statements the build issues. Its
467
+ * `trigger` also covers the case the publisher's own `trigger` cannot
468
+ * express (a publish), and its `runId` lets a caller's own id group the
469
+ * build's statements instead of the publisher's materialization id.
470
+ */
471
+ runContext?: components["schemas"]["RunContext"] | null;
416
472
  } = {},
417
473
  ): Promise<Materialization> {
418
474
  const environmentId = await this.resolveEnvironmentId(environmentName);
@@ -477,6 +533,7 @@ export class MaterializationService {
477
533
  referenceManifest: options.referenceManifest,
478
534
  strictUpstreams: options.strictUpstreams,
479
535
  trigger,
536
+ runContext: options.runContext ?? undefined,
480
537
  },
481
538
  signal,
482
539
  ),
@@ -504,6 +561,7 @@ export class MaterializationService {
504
561
  referenceManifest: ManifestReference[] | undefined;
505
562
  strictUpstreams: boolean | undefined;
506
563
  trigger: "ON_DEMAND" | "SCHEDULER";
564
+ runContext?: components["schemas"]["RunContext"];
507
565
  },
508
566
  signal: AbortSignal,
509
567
  ): Promise<void> {
@@ -620,6 +678,25 @@ export class MaterializationService {
620
678
  // Failure-path reclaim is ORCHESTRATED-ONLY on purpose — see
621
679
  // reclaimStorageTablesFromFailedRun.
622
680
  orchestrated ? { environmentId, packageName } : undefined,
681
+ {
682
+ // Optional for the same reason build_plan reads it optionally:
683
+ // callers that build from a lighter package surface still resolve,
684
+ // just without a package-level layer.
685
+ packageMaterialization: pkg.getMaterializationConfig?.() ?? null,
686
+ context: {
687
+ queryClass: "materialize",
688
+ environment: environmentName,
689
+ package: packageName,
690
+ // The caller's trigger wins because it can express a publish,
691
+ // which the publisher's own two-value trigger cannot.
692
+ trigger:
693
+ opts.runContext?.trigger ?? opts.trigger?.toLowerCase(),
694
+ // Default to the materialization id: the publisher always has a
695
+ // run id, so a build's statements group in the backend's query
696
+ // history whether or not the caller supplied one.
697
+ runId: opts.runContext?.runId ?? id,
698
+ },
699
+ },
623
700
  );
624
701
 
625
702
  const sourcesBuilt = instructions.length;
@@ -1083,6 +1160,7 @@ export class MaterializationService {
1083
1160
  // Identity of the run, used only to reclaim storage tables this run created
1084
1161
  // if it fails part-way (see reclaimStorageTablesFromFailedRun).
1085
1162
  owner?: { environmentId: string; packageName: string },
1163
+ buildMetadata?: BuildQueryMetadata,
1086
1164
  ): Promise<Record<string, ManifestEntry>> {
1087
1165
  const { graphs, sources, connectionDigests, connections } = compiled;
1088
1166
 
@@ -1200,6 +1278,7 @@ export class MaterializationService {
1200
1278
  manifest,
1201
1279
  environment,
1202
1280
  entries,
1281
+ buildMetadata,
1203
1282
  );
1204
1283
  entries[sourceEntityId] = entry;
1205
1284
  if (entry.storageConnectionName) builtThisRun.push(entry);
@@ -1327,6 +1406,76 @@ export class MaterializationService {
1327
1406
  }
1328
1407
  }
1329
1408
 
1409
+ /**
1410
+ * The `RunSQLOptions` for one source's build statements: its resolved
1411
+ * per-query metadata, merged under this run's context.
1412
+ *
1413
+ * Layers, least specific first: the executing connection's default, then what
1414
+ * the model side declared for this source (package → model-file → `#@ persist`,
1415
+ * already resolved by {@link resolveQueryMetadata}), then the run's context,
1416
+ * which names the source. There is no request layer — a build has no request.
1417
+ *
1418
+ * Fails OPEN: a build must not fail because metadata could not be assembled,
1419
+ * so an unresolvable connection just contributes no default, and a dropped
1420
+ * property is logged and metered rather than thrown.
1421
+ */
1422
+ private buildRunSQLOptions(
1423
+ persistSource: PersistSource,
1424
+ environment: BuildEnvironment,
1425
+ buildMetadata: BuildQueryMetadata | undefined,
1426
+ ): { queryMetadata?: QueryMetadata } {
1427
+ if (!buildMetadata) return {};
1428
+ const connectionLayers = connectionMetadataLayers(
1429
+ environment,
1430
+ persistSource.connectionName,
1431
+ );
1432
+ const resolved = mergeQueryMetadata({
1433
+ connection: connectionLayers.default,
1434
+ enforced: connectionLayers.enforced,
1435
+ model: resolveQueryMetadata(
1436
+ persistSource,
1437
+ buildMetadata.packageMaterialization,
1438
+ ),
1439
+ context: { ...buildMetadata.context, source: persistSource.name },
1440
+ });
1441
+ if (resolved.drops.length > 0) {
1442
+ logger.warn("Dropped query-metadata properties for a build", {
1443
+ sourceName: persistSource.name,
1444
+ drops: resolved.drops,
1445
+ });
1446
+ }
1447
+ return resolved.metadata ? { queryMetadata: resolved.metadata } : {};
1448
+ }
1449
+
1450
+ /**
1451
+ * The `RunSQLOptions` for the drops that retire a materialization's tables.
1452
+ * No model layer: the source's declaration described how to BUILD it, and the
1453
+ * source may no longer exist by the time its table is retired.
1454
+ */
1455
+ private dropRunSQLOptions(
1456
+ environment: BuildEnvironment,
1457
+ connectionName: string,
1458
+ environmentName: string,
1459
+ packageName: string,
1460
+ materializationId: string,
1461
+ ): { queryMetadata?: QueryMetadata } {
1462
+ const connectionLayers = connectionMetadataLayers(
1463
+ environment,
1464
+ connectionName,
1465
+ );
1466
+ const resolved = mergeQueryMetadata({
1467
+ connection: connectionLayers.default,
1468
+ enforced: connectionLayers.enforced,
1469
+ context: {
1470
+ queryClass: "ops",
1471
+ environment: environmentName,
1472
+ package: packageName,
1473
+ runId: materializationId,
1474
+ },
1475
+ });
1476
+ return resolved.metadata ? { queryMetadata: resolved.metadata } : {};
1477
+ }
1478
+
1330
1479
  /**
1331
1480
  * Build a single instructed source into its assigned physical table.
1332
1481
  * COPY uses a staging table + atomic rename for crash-safety; the staging
@@ -1340,6 +1489,7 @@ export class MaterializationService {
1340
1489
  manifest: Manifest,
1341
1490
  environment: BuildEnvironment,
1342
1491
  builtEntries: Record<string, ManifestEntry>,
1492
+ buildMetadata?: BuildQueryMetadata,
1343
1493
  ): Promise<ManifestEntry> {
1344
1494
  const sourceEntityId = instruction.sourceEntityId;
1345
1495
  const physicalTableName = instruction.physicalTableName;
@@ -1404,6 +1554,15 @@ export class MaterializationService {
1404
1554
  );
1405
1555
  }
1406
1556
 
1557
+ // Every statement of this source's build carries the same metadata, so the
1558
+ // warehouse's query history shows the staging CTAS, the drop and the rename
1559
+ // as one attributable unit of work.
1560
+ const runOptions = this.buildRunSQLOptions(
1561
+ persistSource,
1562
+ environment,
1563
+ buildMetadata,
1564
+ );
1565
+
1407
1566
  const bareName = bareTableName(physicalTableName);
1408
1567
  const stagingTableName = `${physicalTableName}${stagingSuffix(sourceEntityId)}`;
1409
1568
  // The control plane sends the logical (unquoted) physical name; dialect-
@@ -1416,18 +1575,29 @@ export class MaterializationService {
1416
1575
  const quotedBareName = quoteIdentifier(bareName, dialect);
1417
1576
 
1418
1577
  const startTime = performance.now();
1419
- await connection.runSQL(`DROP TABLE IF EXISTS ${quotedStaging}`);
1578
+ await connection.runSQL(
1579
+ `DROP TABLE IF EXISTS ${quotedStaging}`,
1580
+ runOptions,
1581
+ );
1420
1582
  try {
1421
1583
  await connection.runSQL(
1422
1584
  `CREATE TABLE ${quotedStaging} AS (${buildSQL})`,
1585
+ runOptions,
1586
+ );
1587
+ await connection.runSQL(
1588
+ `DROP TABLE IF EXISTS ${quotedPhysical}`,
1589
+ runOptions,
1423
1590
  );
1424
- await connection.runSQL(`DROP TABLE IF EXISTS ${quotedPhysical}`);
1425
1591
  await connection.runSQL(
1426
1592
  `ALTER TABLE ${quotedStaging} RENAME TO ${quotedBareName}`,
1593
+ runOptions,
1427
1594
  );
1428
1595
  } catch (err) {
1429
1596
  try {
1430
- await connection.runSQL(`DROP TABLE IF EXISTS ${quotedStaging}`);
1597
+ await connection.runSQL(
1598
+ `DROP TABLE IF EXISTS ${quotedStaging}`,
1599
+ runOptions,
1600
+ );
1431
1601
  } catch (cleanupErr) {
1432
1602
  logger.warn(
1433
1603
  "Failed to clean up staging table after a failed build; physical leak",
@@ -2045,11 +2215,23 @@ export class MaterializationService {
2045
2215
  // successfully also drops successfully (container paths, hyphenated
2046
2216
  // BigQuery project ids, etc.).
2047
2217
  const dialect = connection.dialectName;
2218
+ // Dropping a materialized table is warehouse work someone will have
2219
+ // to account for later, so it is tagged like the build that created
2220
+ // it — `ops` rather than `materialize`, because this is the
2221
+ // lifecycle operation, not a build.
2222
+ const dropOptions = this.dropRunSQLOptions(
2223
+ environment,
2224
+ connectionName,
2225
+ environmentName,
2226
+ packageName,
2227
+ m.id,
2228
+ );
2048
2229
  await connection.runSQL(
2049
2230
  `DROP TABLE IF EXISTS ${quoteTablePath(
2050
2231
  physicalTableName,
2051
2232
  dialect,
2052
2233
  )}`,
2234
+ dropOptions,
2053
2235
  );
2054
2236
  // A crash between staging-create and rename can leave the staging
2055
2237
  // table behind; clean it up too while we hold the connection.
@@ -2058,6 +2240,7 @@ export class MaterializationService {
2058
2240
  `${physicalTableName}${stagingSuffix(entry.sourceEntityId)}`,
2059
2241
  dialect,
2060
2242
  )}`,
2243
+ dropOptions,
2061
2244
  );
2062
2245
  recordDropTables("success", "in_warehouse");
2063
2246
  logger.info("Dropped materialized table on delete", {
@@ -80,34 +80,78 @@ interface FakeFreshnessSchedule {
80
80
  freshness?: { window?: string; fallback?: string };
81
81
  }
82
82
 
83
+ /** A nested tag tree: scalar leaves and property collections. */
84
+ type FakeTagTree = { [key: string]: string | FakeTagTree };
85
+
83
86
  /**
84
- * Build a fake Malloy `Tag` supporting both readers the build plan uses:
85
- * `entries()` (scalar `#@ persist` key=value pairs, for deriveAnnotationFields)
86
- * and the path-based `text(...at)` (dotted `freshness.window`, for
87
- * resolveFreshness).
87
+ * Build a fake Malloy `Tag` over a nested tree, supporting all three readers the
88
+ * build plan uses: `entries()` (scalar `#@ persist` key=value pairs, for
89
+ * deriveAnnotationFields), the path-based `text(...at)` (dotted
90
+ * `freshness.window`, for resolveFreshness) and `tag(...at)` (a property
91
+ * collection — `queryMetadata { … }`, or the `materialization` envelope).
88
92
  */
89
- function fakeTag(
90
- fields: Record<string, string> | undefined,
91
- fs: FakeFreshnessSchedule | undefined,
92
- ) {
93
+ function fakeTagFromTree(tree: FakeTagTree) {
94
+ const walk = (at: string[]): string | FakeTagTree | undefined => {
95
+ let node: string | FakeTagTree | undefined = tree;
96
+ for (const segment of at) {
97
+ if (node === undefined || typeof node !== "object") return undefined;
98
+ node = node[segment];
99
+ }
100
+ return node;
101
+ };
93
102
  return {
94
- *entries() {
95
- for (const [key, value] of Object.entries(fields ?? {})) {
96
- yield [key, { text: () => value }];
103
+ *entries(): Generator<[string, { text(): string | undefined }]> {
104
+ for (const [key, value] of Object.entries(tree)) {
105
+ yield [
106
+ key,
107
+ { text: () => (typeof value === "string" ? value : undefined) },
108
+ ];
97
109
  }
98
110
  },
99
111
  text(...at: string[]): string | undefined {
100
- if (at.length === 1) {
101
- return fields?.[at[0]];
102
- }
103
- if (at.length === 2 && at[0] === "freshness") {
104
- return fs?.freshness?.[at[1] as "window" | "fallback"];
105
- }
106
- return undefined;
112
+ const node = walk(at);
113
+ return typeof node === "string" ? node : undefined;
114
+ },
115
+ tag(...at: string[]) {
116
+ const node = walk(at);
117
+ return node !== undefined && typeof node === "object"
118
+ ? fakeTagFromTree(node)
119
+ : undefined;
107
120
  },
108
121
  };
109
122
  }
110
123
 
124
+ /** Assemble one tag layer's tree from the pieces a fake source declares. */
125
+ function tagTree(pieces: {
126
+ fields?: Record<string, string>;
127
+ freshness?: FakeFreshnessSchedule;
128
+ queryMetadata?: Record<string, string>;
129
+ /** Contents of the `materialization` envelope (model-file layer). */
130
+ materialization?: {
131
+ freshness?: FakeFreshnessSchedule;
132
+ queryMetadata?: Record<string, string>;
133
+ };
134
+ }): FakeTagTree {
135
+ const tree: FakeTagTree = { ...(pieces.fields ?? {}) };
136
+ if (pieces.freshness?.freshness) {
137
+ tree.freshness = { ...pieces.freshness.freshness } as FakeTagTree;
138
+ }
139
+ if (pieces.queryMetadata) tree.queryMetadata = { ...pieces.queryMetadata };
140
+ if (pieces.materialization) {
141
+ const envelope: FakeTagTree = {};
142
+ if (pieces.materialization.freshness?.freshness) {
143
+ envelope.freshness = {
144
+ ...pieces.materialization.freshness.freshness,
145
+ } as FakeTagTree;
146
+ }
147
+ if (pieces.materialization.queryMetadata) {
148
+ envelope.queryMetadata = { ...pieces.materialization.queryMetadata };
149
+ }
150
+ tree.materialization = envelope;
151
+ }
152
+ return tree;
153
+ }
154
+
111
155
  export function fakeSource(opts: {
112
156
  name: string;
113
157
  sourceEntityId: string;
@@ -118,8 +162,17 @@ export function fakeSource(opts: {
118
162
  annotationFields?: Record<string, string>;
119
163
  /** Source-level (`#@`) freshness (dotted keys). */
120
164
  freshnessSchedule?: FakeFreshnessSchedule;
121
- /** Model-file-level (`##`) freshness default. */
165
+ /** Model-file-level (`##`) freshness default, in the deprecated bare form. */
122
166
  modelFreshnessSchedule?: FakeFreshnessSchedule;
167
+ /** Model-file-level (`## materialization.*`) envelope declarations. */
168
+ modelMaterialization?: {
169
+ freshness?: FakeFreshnessSchedule;
170
+ queryMetadata?: Record<string, string>;
171
+ };
172
+ /** Source-level (`#@ persist queryMetadata.*`) per-query metadata. */
173
+ queryMetadata?: Record<string, string>;
174
+ /** Model-file-level bare (`## queryMetadata.*`) per-query metadata. */
175
+ modelQueryMetadata?: Record<string, string>;
123
176
  /**
124
177
  * Spy on the args Malloy's SQL generation is handed — chiefly the
125
178
  * `buildManifest` that resolves upstream persist references — so a test can
@@ -147,12 +200,24 @@ export function fakeSource(opts: {
147
200
  },
148
201
  annotations: {
149
202
  parseAsTag: () => ({
150
- tag: fakeTag(fields, opts.freshnessSchedule),
203
+ tag: fakeTagFromTree(
204
+ tagTree({
205
+ fields,
206
+ freshness: opts.freshnessSchedule,
207
+ queryMetadata: opts.queryMetadata,
208
+ }),
209
+ ),
151
210
  }),
152
211
  },
153
212
  modelAnnotations: {
154
213
  parseAsTag: () => ({
155
- tag: fakeTag(undefined, opts.modelFreshnessSchedule),
214
+ tag: fakeTagFromTree(
215
+ tagTree({
216
+ freshness: opts.modelFreshnessSchedule,
217
+ queryMetadata: opts.modelQueryMetadata,
218
+ materialization: opts.modelMaterialization,
219
+ }),
220
+ ),
156
221
  }),
157
222
  },
158
223
  } as unknown as PersistSource;