@malloy-publisher/server 0.0.233 → 0.0.235

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 (62) hide show
  1. package/README.docker.md +1 -0
  2. package/dist/app/api-doc.yaml +208 -0
  3. package/dist/app/assets/{EnvironmentPage-DutP7T8h.js → EnvironmentPage-BsAnavYN.js} +1 -1
  4. package/dist/app/assets/{HomePage-BcxDrBfl.js → HomePage-CADE138j.js} +1 -1
  5. package/dist/app/assets/{LightMode-BJukGxgz.js → LightMode-Cfh7KzN8.js} +1 -1
  6. package/dist/app/assets/{MainPage-DXbwlMeF.js → MainPage-CO3pRlnV.js} +2 -2
  7. package/dist/app/assets/{MaterializationsPage-BBQksmTU.js → MaterializationsPage-p9YjkRXZ.js} +1 -1
  8. package/dist/app/assets/{ModelPage-C6tK51uU.js → ModelPage-C1OSTv-x.js} +1 -1
  9. package/dist/app/assets/{PackagePage-Bo3cwwZE.js → PackagePage-e4kN75YR.js} +1 -1
  10. package/dist/app/assets/{RouteError-BufkcAKE.js → RouteError-CzbfOkng.js} +1 -1
  11. package/dist/app/assets/{ThemeEditorPage-DICvvKpa.js → ThemeEditorPage-CciagFTq.js} +1 -1
  12. package/dist/app/assets/{WorkbookPage-Dkwt75Nj.js → WorkbookPage-DNWmkCXa.js} +1 -1
  13. package/dist/app/assets/{core-C0nunIQT.es-DlMLKZBK.js → core-Rj_4rRnA.es-BZyvITuO.js} +1 -1
  14. package/dist/app/assets/{index-CmEVVe-8.js → index-CH2AcDzc.js} +4 -4
  15. package/dist/app/assets/{index-qnhU9CGo.js → index-DQa463gC.js} +2 -2
  16. package/dist/app/assets/{index-Cs4WVm2z.js → index-DQpV7MyA.js} +1 -1
  17. package/dist/app/assets/{index-BabP-V-S.js → index-DzaYbhnD.js} +1 -1
  18. package/dist/app/assets/{index-BusxL5Pt.js → index-VBbcc8s6.js} +1 -1
  19. package/dist/app/index.html +1 -1
  20. package/dist/package_load_worker.mjs +53 -3
  21. package/dist/server.mjs +835 -97
  22. package/package.json +12 -12
  23. package/src/config.ts +35 -1
  24. package/src/controller/connection.controller.spec.ts +46 -0
  25. package/src/controller/connection.controller.ts +105 -2
  26. package/src/controller/materialization.controller.spec.ts +25 -0
  27. package/src/controller/materialization.controller.ts +60 -0
  28. package/src/controller/model.controller.ts +24 -0
  29. package/src/controller/query.controller.ts +83 -15
  30. package/src/mcp/handler_utils.ts +10 -2
  31. package/src/mcp/query_envelope.ts +10 -0
  32. package/src/mcp/skills/skills_bundle.json +1 -1
  33. package/src/mcp/tools/execute_query_tool.spec.ts +131 -0
  34. package/src/mcp/tools/execute_query_tool.ts +62 -25
  35. package/src/mcp_config.spec.ts +919 -0
  36. package/src/mcp_config.ts +425 -0
  37. package/src/oom_guards.integration.spec.ts +11 -3
  38. package/src/package_load/package_load_pool.ts +2 -0
  39. package/src/package_load/package_load_worker.ts +17 -5
  40. package/src/package_load/protocol.ts +6 -0
  41. package/src/query_metadata_metrics.ts +49 -0
  42. package/src/server.ts +99 -3
  43. package/src/service/build_plan.spec.ts +125 -0
  44. package/src/service/build_plan.ts +108 -7
  45. package/src/service/connection_config.ts +49 -0
  46. package/src/service/environment.ts +57 -3
  47. package/src/service/materialization_config_validation.spec.ts +99 -0
  48. package/src/service/materialization_config_validation.ts +120 -0
  49. package/src/service/materialization_schedule_surface.spec.ts +124 -0
  50. package/src/service/materialization_service.spec.ts +119 -0
  51. package/src/service/materialization_service.ts +186 -3
  52. package/src/service/materialization_test_fixtures.ts +86 -21
  53. package/src/service/model.spec.ts +45 -1
  54. package/src/service/model.ts +145 -19
  55. package/src/service/package.ts +24 -1
  56. package/src/service/package_manifest.spec.ts +137 -4
  57. package/src/service/package_manifest.ts +140 -5
  58. package/src/service/persist_annotation_validation.spec.ts +12 -0
  59. package/src/service/persist_annotation_validation.ts +9 -4
  60. package/src/service/query_metadata.spec.ts +408 -0
  61. package/src/service/query_metadata.ts +492 -0
  62. package/src/service/query_metadata_identity.spec.ts +149 -0
@@ -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;
@@ -895,12 +895,14 @@ describe("service/model", () => {
895
895
  describe("runtime storage failure → freshnessFallback=live", () => {
896
896
  const originalMode = process.env.PERSIST_STORAGE_MODE;
897
897
  const originalDefaultRows = process.env.PUBLISHER_DEFAULT_QUERY_ROW_LIMIT;
898
+ const originalMetadata = process.env.PUBLISHER_QUERY_METADATA;
898
899
 
899
900
  afterEach(() => {
900
901
  sinon.restore();
901
902
  for (const [name, original] of [
902
903
  ["PERSIST_STORAGE_MODE", originalMode],
903
904
  ["PUBLISHER_DEFAULT_QUERY_ROW_LIMIT", originalDefaultRows],
905
+ ["PUBLISHER_QUERY_METADATA", originalMetadata],
904
906
  ] as const) {
905
907
  if (original === undefined) delete process.env[name];
906
908
  else process.env[name] = original;
@@ -936,7 +938,10 @@ describe("service/model", () => {
936
938
  getPreparedResult:
937
939
  opts.storageFailsAt === "prepare"
938
940
  ? sinon.stub().rejects(storageErr)
939
- : sinon.stub().resolves({ resultExplore: { limit: 0 } }),
941
+ : sinon.stub().resolves({
942
+ resultExplore: { limit: 0 },
943
+ connectionName: "lake",
944
+ }),
940
945
  run:
941
946
  opts.storageFailsAt === "run"
942
947
  ? sinon.stub().rejects(storageErr)
@@ -954,6 +959,7 @@ describe("service/model", () => {
954
959
  const liveRunnable = {
955
960
  getPreparedResult: sinon.stub().resolves({
956
961
  resultExplore: { limit: opts.livePreparedLimit ?? 0 },
962
+ connectionName: "live_pg",
957
963
  }),
958
964
  run: liveRun,
959
965
  };
@@ -1020,6 +1026,44 @@ describe("service/model", () => {
1020
1026
  expect(liveRun.firstCall.args[0].rowLimit).toBe(250);
1021
1027
  });
1022
1028
 
1029
+ it("tags the live retry, with the LIVE connection's layers and the same id", async () => {
1030
+ // Two failures in one: the statement that actually answered the query
1031
+ // carried no attribution at all, so the full-cost fallback — the one an
1032
+ // operator goes looking for on a bill — landed in the untagged bucket;
1033
+ // and the response still returned the id from the bag resolved before
1034
+ // the storage attempt, pointing a caller at the statement that FAILED.
1035
+ // The layers must come from the live connection: on this tier the store
1036
+ // is routinely a different connection, with different enforced
1037
+ // properties.
1038
+ process.env.PUBLISHER_QUERY_METADATA = "on";
1039
+ const { model, liveRun } = routedModel({
1040
+ shapeBindings: [binding("daily", "live")],
1041
+ storageFailsAt: "run",
1042
+ });
1043
+
1044
+ const result = await model.getQueryResults(
1045
+ undefined,
1046
+ undefined,
1047
+ "run: daily -> x",
1048
+ undefined,
1049
+ undefined,
1050
+ undefined,
1051
+ undefined,
1052
+ {
1053
+ correlationId: "corr-1",
1054
+ connectionMetadata: (connectionName: string) =>
1055
+ connectionName === "live_pg"
1056
+ ? { default: null, enforced: { tenant: "acme" } }
1057
+ : { default: null, enforced: { tenant: "wrong" } },
1058
+ },
1059
+ );
1060
+
1061
+ const attached = liveRun.firstCall.args[0].queryMetadata;
1062
+ expect(attached.tenant).toBe("acme");
1063
+ expect(attached.query_id).toBe("corr-1");
1064
+ expect(result.queryCorrelationId).toBe("corr-1");
1065
+ });
1066
+
1023
1067
  it("does not record a live-served answer as a storage hit", async () => {
1024
1068
  // The hit rate is the tier's headline KPI; counting a fallback as a hit
1025
1069
  // makes it RISE while the tier is broken.