@malloy-publisher/server 0.0.219 → 0.0.220

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 (59) hide show
  1. package/dist/app/api-doc.yaml +240 -19
  2. package/dist/app/assets/{EnvironmentPage-gehnjfC6.js → EnvironmentPage-BqLiaatL.js} +1 -1
  3. package/dist/app/assets/{HomePage-8LQBytE4.js → HomePage-G_xpt9XE.js} +1 -1
  4. package/dist/app/assets/LightMode-s1PDRIsF.js +1 -0
  5. package/dist/app/assets/MainPage-C6TfpC92.js +2 -0
  6. package/dist/app/assets/{MaterializationsPage-D7P1Kp6O.js → MaterializationsPage-BMO1afhm.js} +1 -1
  7. package/dist/app/assets/ModelPage-zHOJwMnU.js +1 -0
  8. package/dist/app/assets/{PackagePage-Cz9fVwSG.js → PackagePage-GCaWmELQ.js} +1 -1
  9. package/dist/app/assets/{RouteError-UONCloyN.js → RouteError-CIC588k4.js} +1 -1
  10. package/dist/app/assets/ThemeEditorPage-BdbIfIGO.js +1 -0
  11. package/dist/app/assets/{WorkbookPage-Bhzqvbq_.js → WorkbookPage-C8ucK_H8.js} +1 -1
  12. package/dist/app/assets/{core-BiGj7BML.es-kMHAa8tP.js → core-DFvqRVqM.es-NtfPVC7h.js} +1 -1
  13. package/dist/app/assets/github-dark-DenFmJkN.es-DHJKELXO.js +1 -0
  14. package/dist/app/assets/index-8E2uLeV9.js +2541 -0
  15. package/dist/app/assets/index-B3NcDPbp.js +18 -0
  16. package/dist/app/assets/index-BxDMCn3s.js +527 -0
  17. package/dist/app/assets/index-aYNf0kTZ.js +1 -0
  18. package/dist/app/assets/index-aYtt-ovi.js +1761 -0
  19. package/dist/app/assets/index-w_0OQJgZ.js +23 -0
  20. package/dist/app/index.html +1 -1
  21. package/dist/runtime/publisher.js +6 -1
  22. package/dist/server.mjs +377 -50
  23. package/dist/sshcrypto-8m50vnmb.node +0 -0
  24. package/package.json +12 -12
  25. package/src/config.theme.spec.ts +178 -0
  26. package/src/config.ts +179 -0
  27. package/src/controller/materialization.controller.spec.ts +4 -4
  28. package/src/controller/materialization.controller.ts +2 -2
  29. package/src/controller/theme.controller.ts +83 -0
  30. package/src/dto/connection.dto.ts +6 -0
  31. package/src/materialization_metrics.ts +1 -1
  32. package/src/runtime/publisher.js +6 -1
  33. package/src/server.ts +58 -1
  34. package/src/service/build_plan.spec.ts +17 -17
  35. package/src/service/build_plan.ts +25 -10
  36. package/src/service/connection.ts +52 -6
  37. package/src/service/connection_fingerprint.spec.ts +102 -0
  38. package/src/service/manifest_loader.spec.ts +5 -5
  39. package/src/service/manifest_loader.ts +4 -4
  40. package/src/service/materialization_service.spec.ts +47 -37
  41. package/src/service/materialization_service.ts +59 -50
  42. package/src/service/materialization_test_fixtures.ts +5 -5
  43. package/src/service/model.ts +1 -1
  44. package/src/service/package.ts +4 -4
  45. package/src/service/theme_store.ts +199 -0
  46. package/src/storage/DatabaseInterface.ts +1 -1
  47. package/src/storage/StorageManager.ts +17 -0
  48. package/src/storage/duckdb/schema.ts +27 -6
  49. package/src/theme_key_parity.spec.ts +57 -0
  50. package/tests/integration/materialization/manifest_binding.integration.spec.ts +1 -1
  51. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +5 -5
  52. package/tests/unit/controller/theme.controller.spec.ts +141 -0
  53. package/tests/unit/service/theme_store.spec.ts +139 -0
  54. package/dist/app/assets/MainPage-DDaZLJw-.js +0 -2
  55. package/dist/app/assets/ModelPage-Do_vhxOc.js +0 -1
  56. package/dist/app/assets/index-VzRbxcF7.js +0 -455
  57. package/dist/app/assets/index-ddq4-5hu.js +0 -1760
  58. package/dist/app/assets/index-qOQF9CXq.js +0 -40
  59. package/dist/app/assets/index.umd-B2kmxDh7.js +0 -2467
package/src/server.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  BadRequestError,
21
21
  internalErrorToHttpError,
22
22
  NotImplementedError,
23
+ ServiceUnavailableError,
23
24
  } from "./errors";
24
25
  import {
25
26
  drainingGuard,
@@ -38,6 +39,7 @@ import { setFilterDeprecationHeaders } from "./filter_deprecation";
38
39
  import { checkHeapConfiguration } from "./heap_check";
39
40
  import { queryConcurrency } from "./query_concurrency";
40
41
  import { MaterializationController } from "./controller/materialization.controller";
42
+ import { ThemeController } from "./controller/theme.controller";
41
43
  import { initializeMcpServer } from "./mcp/server";
42
44
  import { startAgentMcpServer } from "./mcp/agent_server";
43
45
  import { registerLegacyRoutes } from "./server-old";
@@ -45,6 +47,7 @@ import { EnvironmentStore } from "./service/environment_store";
45
47
  import { MaterializationService } from "./service/materialization_service";
46
48
  import { normalizeQueryArray } from "./query_param_utils";
47
49
  import { PackageMemoryGovernor } from "./service/package_memory_governor";
50
+ import { ThemeStore } from "./service/theme_store";
48
51
  import { assertSafePackageName, safeJoinUnderRoot } from "./path_safety";
49
52
 
50
53
  export { normalizeQueryArray } from "./query_param_utils";
@@ -207,6 +210,8 @@ const materializationService = new MaterializationService(environmentStore);
207
210
  const materializationController = new MaterializationController(
208
211
  materializationService,
209
212
  );
213
+ const themeStore = new ThemeStore(environmentStore.storageManager, SERVER_ROOT);
214
+ const themeController = new ThemeController(themeStore, SERVER_ROOT);
210
215
 
211
216
  export const mcpApp = express();
212
217
 
@@ -722,7 +727,20 @@ app.get(
722
727
  app.get(`${API_PREFIX}/status`, async (_req, res) => {
723
728
  try {
724
729
  const status = await environmentStore.getStatus();
725
- res.status(200).json(status);
730
+ // Compose theme onto the status response so the SDK can read both
731
+ // in one round trip on app boot. ThemeStore is the source of truth;
732
+ // publisher.config.json is only a boot seed (see ThemeStore). The
733
+ // field is always present (an empty object means "no overrides
734
+ // yet"), so the OpenAPI shape and the runtime payload agree.
735
+ // The theme here is cosmetic, so during the brief window before storage
736
+ // initializes report no overrides rather than 500 an endpoint the
737
+ // control plane polls for serving state (themeStore.get() throws until
738
+ // storage is ready). GET /theme, the editor's authoritative load, is
739
+ // answered with 503 during that window instead.
740
+ const theme = environmentStore.storageManager.isInitialized()
741
+ ? await themeStore.get()
742
+ : undefined;
743
+ res.status(200).json({ ...status, theme: theme ?? {} });
726
744
  } catch (error) {
727
745
  logger.error("Error getting status", { error });
728
746
  const { json, status } = internalErrorToHttpError(error as Error);
@@ -730,6 +748,45 @@ app.get(`${API_PREFIX}/status`, async (_req, res) => {
730
748
  }
731
749
  });
732
750
 
751
+ app.get(`${API_PREFIX}/theme`, async (_req, res) => {
752
+ try {
753
+ if (!environmentStore.storageManager.isInitialized()) {
754
+ // Storage is still initializing. Answer 503 (not 200 with an empty
755
+ // theme) so the Theme Editor's load stays in an error state and
756
+ // never adopts {} as the authoritative saved baseline, which would
757
+ // let a subsequent edit auto-save {} over the real persisted theme.
758
+ throw new ServiceUnavailableError(
759
+ "Theme storage is still initializing. Retry shortly.",
760
+ );
761
+ }
762
+ res.status(200).json(await themeController.getTheme());
763
+ } catch (error) {
764
+ logger.error("Error getting theme", { error });
765
+ const { json, status } = internalErrorToHttpError(error as Error);
766
+ res.status(status).json(json);
767
+ }
768
+ });
769
+
770
+ app.put(`${API_PREFIX}/theme`, async (req, res) => {
771
+ try {
772
+ res.status(200).json(await themeController.putTheme(req.body));
773
+ } catch (error) {
774
+ logger.error("Error saving theme", { error });
775
+ const { json, status } = internalErrorToHttpError(error as Error);
776
+ res.status(status).json(json);
777
+ }
778
+ });
779
+
780
+ app.delete(`${API_PREFIX}/theme`, async (_req, res) => {
781
+ try {
782
+ res.status(200).json(await themeController.resetTheme());
783
+ } catch (error) {
784
+ logger.error("Error resetting theme", { error });
785
+ const { json, status } = internalErrorToHttpError(error as Error);
786
+ res.status(status).json(json);
787
+ }
788
+ });
789
+
733
790
  app.get(`${API_PREFIX}/watch-mode/status`, watchModeController.getWatchStatus);
734
791
  app.post(`${API_PREFIX}/watch-mode/start`, watchModeController.startWatching);
735
792
  app.post(`${API_PREFIX}/watch-mode/stop`, watchModeController.stopWatchMode);
@@ -4,7 +4,7 @@ import * as sinon from "sinon";
4
4
  import type { BuildGraph as MalloyBuildGraph } from "@malloydata/malloy";
5
5
  import {
6
6
  compilePackageBuildPlan,
7
- computeBuildId,
7
+ computeSourceEntityId,
8
8
  computePackageBuildPlan,
9
9
  deriveAnnotationFields,
10
10
  deriveBuildPlan,
@@ -27,8 +27,8 @@ describe("flattenDependsOn", () => {
27
27
 
28
28
  describe("iterGraphSources", () => {
29
29
  it("yields resolvable sources in dependency order, skipping missing ones", () => {
30
- const a = fakeSource({ name: "a", buildId: "ba" });
31
- const b = fakeSource({ name: "b", buildId: "bb" });
30
+ const a = fakeSource({ name: "a", sourceEntityId: "ba" });
31
+ const b = fakeSource({ name: "b", sourceEntityId: "bb" });
32
32
  const graph = {
33
33
  connectionName: "duckdb",
34
34
  nodes: [
@@ -52,9 +52,9 @@ describe("iterGraphSources", () => {
52
52
  // and every transitive persist dependency is nested in dependsOn. All
53
53
  // three must be yielded (so all get built), leaf-first so a downstream
54
54
  // build reads its upstream's freshly materialized table.
55
- const root = fakeSource({ name: "root", buildId: "br" });
56
- const mid = fakeSource({ name: "mid", buildId: "bm" });
57
- const leaf = fakeSource({ name: "leaf", buildId: "bl" });
55
+ const root = fakeSource({ name: "root", sourceEntityId: "br" });
56
+ const mid = fakeSource({ name: "mid", sourceEntityId: "bm" });
57
+ const leaf = fakeSource({ name: "leaf", sourceEntityId: "bl" });
58
58
  const graph = {
59
59
  connectionName: "duckdb",
60
60
  nodes: [
@@ -85,9 +85,9 @@ describe("iterGraphSources", () => {
85
85
  it("deduplicates a shared (diamond) dependency across roots", () => {
86
86
  // r1 and r2 both depend on `shared`; it must be yielded exactly once and
87
87
  // before both dependents.
88
- const r1 = fakeSource({ name: "r1", buildId: "b1" });
89
- const r2 = fakeSource({ name: "r2", buildId: "b2" });
90
- const shared = fakeSource({ name: "shared", buildId: "bs" });
88
+ const r1 = fakeSource({ name: "r1", sourceEntityId: "b1" });
89
+ const r2 = fakeSource({ name: "r2", sourceEntityId: "b2" });
90
+ const shared = fakeSource({ name: "shared", sourceEntityId: "bs" });
91
91
  const graph = {
92
92
  connectionName: "duckdb",
93
93
  nodes: [
@@ -149,7 +149,7 @@ describe("deriveAnnotationFields", () => {
149
149
  });
150
150
  });
151
151
 
152
- describe("computeBuildId", () => {
152
+ describe("computeSourceEntityId", () => {
153
153
  it("delegates to PersistSource.makeBuildId with the connection digest and SQL", () => {
154
154
  const makeBuildId = sinon.stub().returns("computed-id");
155
155
  const source = {
@@ -158,7 +158,7 @@ describe("computeBuildId", () => {
158
158
  getSQL: () => "SELECT 7",
159
159
  } as unknown as PersistSource;
160
160
 
161
- const id = computeBuildId(source, { duckdb: "dig-1" });
161
+ const id = computeSourceEntityId(source, { duckdb: "dig-1" });
162
162
 
163
163
  expect(id).toBe("computed-id");
164
164
  expect(makeBuildId.calledOnceWithExactly("dig-1", "SELECT 7")).toBe(true);
@@ -188,7 +188,7 @@ describe("deriveBuildPlan", () => {
188
188
  it("projects graphs and sources into the wire build plan", () => {
189
189
  const orders = fakeSource({
190
190
  name: "orders",
191
- buildId: "bid-orders",
191
+ sourceEntityId: "bid-orders",
192
192
  sql: "SELECT 1",
193
193
  });
194
194
  const plan = deriveBuildPlan(
@@ -206,15 +206,15 @@ describe("deriveBuildPlan", () => {
206
206
  expect(plan.sources["orders@m"]).toMatchObject({
207
207
  name: "orders",
208
208
  connectionName: "duckdb",
209
- buildId: "bid-orders",
209
+ sourceEntityId: "bid-orders",
210
210
  sql: "SELECT 1",
211
211
  columns: [],
212
212
  });
213
213
  });
214
214
 
215
215
  it("honors the sourceNames filter", () => {
216
- const a = fakeSource({ name: "a", buildId: "bid-a" });
217
- const b = fakeSource({ name: "b", buildId: "bid-b" });
216
+ const a = fakeSource({ name: "a", sourceEntityId: "bid-a" });
217
+ const b = fakeSource({ name: "b", sourceEntityId: "bid-b" });
218
218
  const plan = deriveBuildPlan(
219
219
  [
220
220
  {
@@ -231,8 +231,8 @@ describe("deriveBuildPlan", () => {
231
231
  });
232
232
 
233
233
  it("carries the per-source package-relative modelPath", () => {
234
- const a = fakeSource({ name: "a", buildId: "bid-a" });
235
- const b = fakeSource({ name: "b", buildId: "bid-b" });
234
+ const a = fakeSource({ name: "a", sourceEntityId: "bid-a" });
235
+ const b = fakeSource({ name: "b", sourceEntityId: "bid-b" });
236
236
  const plan = deriveBuildPlan(
237
237
  [
238
238
  {
@@ -112,8 +112,8 @@ export function flattenDependsOn(node: {
112
112
  * persist dependency is nested under a root in its recursive {@code dependsOn}
113
113
  * tree (and present in {@code sources}). Walking only {@code graph.nodes}
114
114
  * silently skips every intermediate persist source, so it never gets
115
- * materialized (it only gets a table by coincidence when it shares a buildId
116
- * with a root). We post-order DFS the {@code dependsOn} tree
115
+ * materialized (it only gets a table by coincidence when it shares a
116
+ * sourceEntityId with a root). We post-order DFS the {@code dependsOn} tree
117
117
  * so dependencies are built first (a downstream build can then read its upstream
118
118
  * source's freshly materialized table), deduplicating shared (diamond)
119
119
  * dependencies by sourceID so each is yielded once. This mirrors the canonical
@@ -147,11 +147,23 @@ export function* iterGraphSources(
147
147
  }
148
148
 
149
149
  /**
150
- * The buildId for a persist source: a stable digest of its connection identity
151
- * and canonical SQL. Centralizes the (source, connectionDigests) call shape so
152
- * planning, self-instruction, and build all agree on the same id.
150
+ * The sourceEntityId for a persist source: a stable content address of its
151
+ * connection identity and canonical SQL. Centralizes the
152
+ * (source, connectionDigests) call shape so planning, self-instruction, build,
153
+ * and serve-time manifest resolution all agree on the same id.
154
+ *
155
+ * <p>This is the single seam between the publisher and the Malloy compiler's
156
+ * source-identity recipe. Today it delegates to
157
+ * {@code PersistSource.makeBuildId(connectionDigest, sql)} — the compiler's
158
+ * current hex content hash. The connection's contribution is already
159
+ * fingerprint-aware: when a connection carries an API `fingerprint`, its
160
+ * digest IS that fingerprint verbatim (see applyConnectionFingerprint in
161
+ * connection.ts), so ids stay stable across credential rotation. When the
162
+ * compiler ships the scoped UUID5 sourceEntityId recipe (scope + connection
163
+ * fingerprint + canonical SQL), swap the delegation below to the new compiler
164
+ * API — this function is the only place the publisher derives the id.
153
165
  */
154
- export function computeBuildId(
166
+ export function computeSourceEntityId(
155
167
  source: PersistSource,
156
168
  connectionDigests: Record<string, string>,
157
169
  ): string {
@@ -246,9 +258,9 @@ export async function compilePackageBuildPlan(
246
258
  const conn = connections.get(graph.connectionName);
247
259
  if (!conn) {
248
260
  // The connection failed to resolve (already warned in
249
- // resolvePackageConnections). Its buildIds will be computed without a
250
- // digest, so surface it as a discrete correctness signal rather than
251
- // skipping silently.
261
+ // resolvePackageConnections). Its sourceEntityIds will be computed
262
+ // without a digest, so surface it as a discrete correctness signal
263
+ // rather than skipping silently.
252
264
  recordConnectionDigestSkipped();
253
265
  logger.warn("Skipping connection digest; connection did not resolve", {
254
266
  connectionName: graph.connectionName,
@@ -256,6 +268,9 @@ export async function compilePackageBuildPlan(
256
268
  continue;
257
269
  }
258
270
  if (!connectionDigests[graph.connectionName]) {
271
+ // getDigest() is fingerprint-aware: a connection configured with an
272
+ // API `fingerprint` returns it verbatim (applyConnectionFingerprint),
273
+ // so a credential rotation does not re-address its sources.
259
274
  connectionDigests[graph.connectionName] = await conn.getDigest();
260
275
  }
261
276
  }
@@ -297,7 +312,7 @@ export function deriveBuildPlan(
297
312
  sourceID: source.sourceID,
298
313
  connectionName: source.connectionName,
299
314
  dialect: source.dialectName,
300
- buildId: computeBuildId(source, connectionDigests),
315
+ sourceEntityId: computeSourceEntityId(source, connectionDigests),
301
316
  sql: source.getSQL(),
302
317
  columns: deriveColumns(source),
303
318
  annotationFields: deriveAnnotationFields(source),
@@ -1028,6 +1028,38 @@ function getMetadataForLookup(
1028
1028
  return name ? metadata.get(name) : undefined;
1029
1029
  }
1030
1030
 
1031
+ /**
1032
+ * Make a connection's digest the caller-supplied API `fingerprint`, verbatim.
1033
+ *
1034
+ * The fingerprint is the connection's data identity as declared by the caller
1035
+ * (secret-free, stable across credential rotation). When set, it replaces the
1036
+ * locally derived `getDigest()` so it becomes the connection's contribution to
1037
+ * content-addressed source ids everywhere a digest is read:
1038
+ *
1039
+ * - build planning (`compilePackageBuildPlan` -> `conn.getDigest()`),
1040
+ * - the package-load worker's connection-metadata RPC, and
1041
+ * - Malloy's own serve-time manifest resolution (the runtime calls
1042
+ * `conn.getDigest()` internally when a build manifest is bound).
1043
+ *
1044
+ * Overriding the method on the resolved instance — inside the one
1045
+ * `lookupConnection` wrapper every resolution passes through — is what keeps
1046
+ * build-time and serve-time ids identical; applying it per call site would
1047
+ * invite divergence and a silent live-serving fallback. The value is used
1048
+ * verbatim (never re-hashed or combined with local details) and treated as an
1049
+ * opaque token: it is not a secret, but it is never logged or parsed.
1050
+ * Without a fingerprint the connection keeps its locally derived digest.
1051
+ */
1052
+ function applyConnectionFingerprint(
1053
+ connection: Connection,
1054
+ metadata: EnvironmentConnectionMetadata | undefined,
1055
+ ): Connection {
1056
+ const fingerprint = metadata?.apiConnection.fingerprint;
1057
+ if (fingerprint) {
1058
+ connection.getDigest = () => fingerprint;
1059
+ }
1060
+ return connection;
1061
+ }
1062
+
1031
1063
  function isDuckDBConnection(
1032
1064
  connection: Connection,
1033
1065
  ): connection is DuckDBConnection {
@@ -1095,10 +1127,11 @@ export function buildEnvironmentMalloyConfig(
1095
1127
  }
1096
1128
 
1097
1129
  malloyConfig.wrapConnections(
1098
- (base: LookupConnection<Connection>): LookupConnection<Connection> => ({
1099
- lookupConnection: async (name?: string): Promise<Connection> => {
1100
- const metadata = getMetadataForLookup(assembled.metadata, name);
1101
-
1130
+ (base: LookupConnection<Connection>): LookupConnection<Connection> => {
1131
+ const resolveConnection = async (
1132
+ name: string | undefined,
1133
+ metadata: EnvironmentConnectionMetadata | undefined,
1134
+ ): Promise<Connection> => {
1102
1135
  if (metadata?.isDuckLake) {
1103
1136
  let connectionPromise = duckLakeCache.get(name!);
1104
1137
  if (!connectionPromise) {
@@ -1210,8 +1243,21 @@ export function buildEnvironmentMalloyConfig(
1210
1243
  await attachOnce(connection, metadata);
1211
1244
  }
1212
1245
  return connection;
1213
- },
1214
- }),
1246
+ };
1247
+
1248
+ return {
1249
+ lookupConnection: async (name?: string): Promise<Connection> => {
1250
+ const metadata = getMetadataForLookup(assembled.metadata, name);
1251
+ // Every resolution branch funnels through this one exit so a
1252
+ // configured fingerprint is applied no matter which wrapper
1253
+ // produced the connection (see applyConnectionFingerprint).
1254
+ return applyConnectionFingerprint(
1255
+ await resolveConnection(name, metadata),
1256
+ metadata,
1257
+ );
1258
+ },
1259
+ };
1260
+ },
1215
1261
  );
1216
1262
 
1217
1263
  return {
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { components } from "../api";
3
+ import {
4
+ buildEnvironmentMalloyConfig,
5
+ EnvironmentMalloyConfig,
6
+ } from "./connection";
7
+
8
+ type ApiConnection = components["schemas"]["Connection"];
9
+
10
+ /**
11
+ * The connection `fingerprint` seam: when a connection carries an API
12
+ * fingerprint, the resolved Malloy connection's getDigest() must return it
13
+ * verbatim. Every consumer of a connection's identity — build planning, the
14
+ * package-load worker's digest RPC, and Malloy's serve-time manifest
15
+ * resolution — reads getDigest() off a connection resolved through the same
16
+ * environment lookup wrapper, so this one seam is what keeps build-time and
17
+ * serve-time source ids in agreement.
18
+ *
19
+ * Uses a postgres connection because it is constructed lazily (no network I/O
20
+ * until a query runs), so digests can be read offline.
21
+ */
22
+
23
+ function pgConnection(overrides: Partial<ApiConnection> = {}): ApiConnection {
24
+ return {
25
+ name: "pg",
26
+ type: "postgres",
27
+ postgresConnection: {
28
+ host: "db.example.com",
29
+ port: 5432,
30
+ databaseName: "analytics",
31
+ userName: "svc_user",
32
+ password: "hunter2",
33
+ },
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ async function digestOf(connection: ApiConnection): Promise<string> {
39
+ let config: EnvironmentMalloyConfig | undefined;
40
+ try {
41
+ config = buildEnvironmentMalloyConfig([connection]);
42
+ const resolved = await config.malloyConfig.connections.lookupConnection(
43
+ connection.name,
44
+ );
45
+ return resolved.getDigest();
46
+ } finally {
47
+ await config?.releaseConnections();
48
+ }
49
+ }
50
+
51
+ describe("connection fingerprint", () => {
52
+ it("uses the fingerprint verbatim as the connection digest", async () => {
53
+ const digest = await digestOf(
54
+ pgConnection({ fingerprint: "fp-opaque-token-1" }),
55
+ );
56
+ expect(digest).toBe("fp-opaque-token-1");
57
+ });
58
+
59
+ it("keeps the digest stable across a credential rotation", async () => {
60
+ const before = await digestOf(pgConnection({ fingerprint: "fp-stable" }));
61
+ const after = await digestOf(
62
+ pgConnection({
63
+ fingerprint: "fp-stable",
64
+ postgresConnection: {
65
+ host: "db.example.com",
66
+ port: 5432,
67
+ databaseName: "analytics",
68
+ userName: "rotated_user",
69
+ password: "rotated-password",
70
+ },
71
+ }),
72
+ );
73
+ expect(after).toBe(before);
74
+ });
75
+
76
+ it("changes the digest when the fingerprint changes", async () => {
77
+ const one = await digestOf(pgConnection({ fingerprint: "fp-1" }));
78
+ const two = await digestOf(pgConnection({ fingerprint: "fp-2" }));
79
+ expect(one).not.toBe(two);
80
+ });
81
+
82
+ it("falls back to the locally derived digest when omitted", async () => {
83
+ const digest = await digestOf(pgConnection());
84
+ expect(typeof digest).toBe("string");
85
+ expect(digest.length).toBeGreaterThan(0);
86
+ // Behavior is unchanged from before the fingerprint existed: the digest
87
+ // is the connection's own derivation, deterministic for one config.
88
+ expect(await digestOf(pgConnection())).toBe(digest);
89
+ });
90
+
91
+ it("round-trips the fingerprint on the API connection", async () => {
92
+ const config = buildEnvironmentMalloyConfig([
93
+ pgConnection({ fingerprint: "fp-round-trip" }),
94
+ ]);
95
+ try {
96
+ const api = config.apiConnections.find((c) => c.name === "pg");
97
+ expect(api?.fingerprint).toBe("fp-round-trip");
98
+ } finally {
99
+ await config.releaseConnections();
100
+ }
101
+ });
102
+ });
@@ -29,9 +29,9 @@ describe("fetchManifestEntries", () => {
29
29
  builtAt: new Date().toISOString(),
30
30
  strict: false,
31
31
  entries: {
32
- b1: { buildId: "b1", physicalTableName: "schema.orders_mz" },
32
+ b1: { sourceEntityId: "b1", physicalTableName: "schema.orders_mz" },
33
33
  b2: {
34
- buildId: "b2",
34
+ sourceEntityId: "b2",
35
35
  physicalTableName: "schema.daily_mz",
36
36
  connectionName: "bq",
37
37
  },
@@ -48,7 +48,7 @@ describe("fetchManifestEntries", () => {
48
48
 
49
49
  it("reads via a file:// URI", async () => {
50
50
  const file = await writeManifest({
51
- entries: { b1: { buildId: "b1", physicalTableName: "t1" } },
51
+ entries: { b1: { sourceEntityId: "b1", physicalTableName: "t1" } },
52
52
  });
53
53
 
54
54
  const entries = await fetchManifestEntries(pathToFileURL(file).href);
@@ -59,8 +59,8 @@ describe("fetchManifestEntries", () => {
59
59
  it("skips entries without a physicalTableName", async () => {
60
60
  const file = await writeManifest({
61
61
  entries: {
62
- good: { buildId: "good", physicalTableName: "t1" },
63
- bad: { buildId: "bad" },
62
+ good: { sourceEntityId: "good", physicalTableName: "t1" },
63
+ bad: { sourceEntityId: "bad" },
64
64
  },
65
65
  });
66
66
 
@@ -58,7 +58,7 @@ async function readManifestBytes(uri: string): Promise<string> {
58
58
 
59
59
  /**
60
60
  * Fetch and parse the control-plane-computed build manifest at `uri`, returning
61
- * the Malloy-runtime binding map (`buildId -> { tableName }`). The wire manifest
61
+ * the Malloy-runtime binding map (`sourceEntityId -> { tableName }`). The wire manifest
62
62
  * keys physical tables under `physicalTableName`; the Malloy runtime consumes
63
63
  * `tableName`, so we translate here. Entries missing a physical table are
64
64
  * skipped. Throws if the URI can't be read or parsed.
@@ -80,16 +80,16 @@ export async function fetchManifestEntries(
80
80
  }
81
81
 
82
82
  const entries: BuildManifest["entries"] = {};
83
- for (const [buildId, entry] of Object.entries(parsed.entries ?? {})) {
83
+ for (const [sourceEntityId, entry] of Object.entries(parsed.entries ?? {})) {
84
84
  const physicalTableName = entry?.physicalTableName;
85
85
  if (!physicalTableName) {
86
86
  logger.warn("Manifest entry has no physicalTableName; skipping", {
87
87
  uri,
88
- buildId,
88
+ sourceEntityId,
89
89
  });
90
90
  continue;
91
91
  }
92
- entries[buildId] = { tableName: physicalTableName };
92
+ entries[sourceEntityId] = { tableName: physicalTableName };
93
93
  }
94
94
  return entries;
95
95
  }