@malloy-publisher/server 0.0.229 → 0.0.231

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 (51) hide show
  1. package/dist/app/api-doc.yaml +18 -9
  2. package/dist/app/assets/{EnvironmentPage-QOoHiVeJ.js → EnvironmentPage-wa_EPkwK.js} +1 -1
  3. package/dist/app/assets/{HomePage-C71GOfVW.js → HomePage-jnCrupQp.js} +1 -1
  4. package/dist/app/assets/{LightMode-CrgCAwLe.js → LightMode-DYbwNULZ.js} +1 -1
  5. package/dist/app/assets/{MainPage-BG5__FN3.js → MainPage-CuJLrPNI.js} +1 -1
  6. package/dist/app/assets/{MaterializationsPage-DE6PnrDR.js → MaterializationsPage-D_67x2ee.js} +1 -1
  7. package/dist/app/assets/{ModelPage-CcBjcbLm.js → ModelPage-D5JtAWqR.js} +1 -1
  8. package/dist/app/assets/{PackagePage-JTy3ztkB.js → PackagePage-BRwtqUSG.js} +1 -1
  9. package/dist/app/assets/{RouteError-Cymbp47a.js → RouteError-CBNNrnSD.js} +1 -1
  10. package/dist/app/assets/{ThemeEditorPage-C_nMnHr8.js → ThemeEditorPage-CTCeBneA.js} +1 -1
  11. package/dist/app/assets/{WorkbookPage-CPQu-DQx.js → WorkbookPage-SN6f1RBm.js} +1 -1
  12. package/dist/app/assets/{core-Coi3caGs.es-CSOmajHS.js → core-Dp3q5Ieu.es-CD5FvM2s.js} +1 -1
  13. package/dist/app/assets/{index-DlWCXghy.js → index-B3Nn8Vm2.js} +459 -446
  14. package/dist/app/assets/index-BLCx1EdC.js +18 -0
  15. package/dist/app/assets/{index-DxArlgRD.js → index-C_tJstcx.js} +4 -4
  16. package/dist/app/assets/{index-CcuuST2X.js → index-CfmBVB6M.js} +1 -1
  17. package/dist/app/assets/{index-CkmABCAw.js → index-DU4r7GdU.js} +435 -422
  18. package/dist/app/index.html +1 -1
  19. package/dist/package_load_worker.mjs +108 -7
  20. package/dist/server.mjs +576 -50
  21. package/package.json +12 -12
  22. package/scripts/bake-duckdb-extensions.js +5 -2
  23. package/src/config.ts +40 -0
  24. package/src/ducklake_version.spec.ts +163 -0
  25. package/src/ducklake_version.ts +153 -0
  26. package/src/errors.ts +12 -0
  27. package/src/malloy_pin_prereqs.spec.ts +25 -0
  28. package/src/mcp/skills/skills_bundle.json +1 -1
  29. package/src/server.ts +7 -0
  30. package/src/service/authorize.spec.ts +22 -0
  31. package/src/service/authorize.ts +267 -10
  32. package/src/service/authorize_integration.spec.ts +1068 -26
  33. package/src/service/connection.spec.ts +71 -0
  34. package/src/service/connection.ts +312 -25
  35. package/src/service/connection_config.spec.ts +21 -0
  36. package/src/service/connection_config.ts +15 -1
  37. package/src/service/ducklake_lazy_attach.spec.ts +110 -0
  38. package/src/service/environment.ts +43 -9
  39. package/src/service/environment_store.spec.ts +197 -12
  40. package/src/service/environment_store.ts +88 -14
  41. package/src/service/extension_fetch_policy.spec.ts +256 -0
  42. package/src/service/materialization_scheduler.spec.ts +29 -0
  43. package/src/service/materialization_service.spec.ts +119 -1
  44. package/src/service/model.spec.ts +67 -0
  45. package/src/service/model.ts +656 -31
  46. package/src/service/package.spec.ts +38 -0
  47. package/src/service/package.ts +12 -1
  48. package/src/storage/duckdb/MaterializationRepository.spec.ts +39 -2
  49. package/src/storage/duckdb/MaterializationRepository.ts +12 -0
  50. package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +256 -0
  51. package/dist/app/assets/index-CM2qhQCI.js +0 -18
@@ -0,0 +1,256 @@
1
+ import { DuckDBConnection } from "@malloydata/db-duckdb";
2
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
3
+ import fs from "fs/promises";
4
+ import path from "path";
5
+ import sinon from "sinon";
6
+ import { getExtensionFetchPolicy } from "../config";
7
+ import {
8
+ applyExtensionSessionSettings,
9
+ buildEnvironmentMalloyConfig,
10
+ } from "./connection";
11
+
12
+ describe("getExtensionFetchPolicy", () => {
13
+ const original = process.env.EXTENSION_FETCH_POLICY;
14
+ afterEach(() => {
15
+ if (original === undefined) delete process.env.EXTENSION_FETCH_POLICY;
16
+ else process.env.EXTENSION_FETCH_POLICY = original;
17
+ });
18
+
19
+ it("defaults to on-demand when unset or empty", () => {
20
+ delete process.env.EXTENSION_FETCH_POLICY;
21
+ expect(getExtensionFetchPolicy()).toBe("on-demand");
22
+ process.env.EXTENSION_FETCH_POLICY = " ";
23
+ expect(getExtensionFetchPolicy()).toBe("on-demand");
24
+ });
25
+
26
+ it("accepts the two policies case-insensitively", () => {
27
+ process.env.EXTENSION_FETCH_POLICY = "local-only";
28
+ expect(getExtensionFetchPolicy()).toBe("local-only");
29
+ process.env.EXTENSION_FETCH_POLICY = "On-Demand";
30
+ expect(getExtensionFetchPolicy()).toBe("on-demand");
31
+ });
32
+
33
+ it("throws loudly on an unrecognised value", () => {
34
+ process.env.EXTENSION_FETCH_POLICY = "offline";
35
+ expect(() => getExtensionFetchPolicy()).toThrow(
36
+ /Invalid value for EXTENSION_FETCH_POLICY/,
37
+ );
38
+ });
39
+ });
40
+
41
+ describe("EXTENSION_FETCH_POLICY=local-only extension loading", () => {
42
+ const envPath = path.join(process.cwd(), "test-extension-policy");
43
+ const original = process.env.EXTENSION_FETCH_POLICY;
44
+
45
+ beforeEach(async () => {
46
+ await fs.mkdir(envPath, { recursive: true });
47
+ });
48
+ afterEach(async () => {
49
+ sinon.restore();
50
+ if (original === undefined) delete process.env.EXTENSION_FETCH_POLICY;
51
+ else process.env.EXTENSION_FETCH_POLICY = original;
52
+ await fs.rm(envPath, { recursive: true, force: true }).catch(() => {});
53
+ });
54
+
55
+ it("never runs INSTALL and raises a loud, actionable error when a required extension is missing", async () => {
56
+ process.env.EXTENSION_FETCH_POLICY = "local-only";
57
+
58
+ // Session PRAGMAs and metadata probes succeed; the first extension LOAD
59
+ // fails, simulating an extension that was never baked into the image.
60
+ const runSQL = sinon
61
+ .stub(DuckDBConnection.prototype, "runSQL")
62
+ .resolves({ rows: [] } as never);
63
+ runSQL
64
+ .withArgs(sinon.match(/^LOAD ducklake/))
65
+ .rejects(new Error('Extension "ducklake" not found'));
66
+
67
+ const config = buildEnvironmentMalloyConfig(
68
+ [
69
+ {
70
+ name: "ducklake_localonly",
71
+ type: "ducklake",
72
+ ducklakeConnection: {
73
+ catalog: {
74
+ postgresConnection: {
75
+ host: "192.0.2.1",
76
+ port: 5432,
77
+ userName: "nobody",
78
+ password: "nobody",
79
+ databaseName: "catalog",
80
+ },
81
+ },
82
+ storage: {
83
+ bucketUrl: "s3://test-bucket",
84
+ s3Connection: {
85
+ accessKeyId: "test",
86
+ secretAccessKey: "test",
87
+ },
88
+ },
89
+ },
90
+ },
91
+ ] as never,
92
+ envPath,
93
+ );
94
+
95
+ await expect(
96
+ config.malloyConfig.connections.lookupConnection("ducklake_localonly"),
97
+ ).rejects.toThrow(/local-only.*ducklake|ducklake.*local-only/s);
98
+
99
+ // local-only must never issue an INSTALL — not even for a missing extension.
100
+ const ranInstall = runSQL
101
+ .getCalls()
102
+ .some((c) => /^\s*INSTALL\s/i.test(String(c.args[0])));
103
+ expect(ranInstall).toBe(false);
104
+
105
+ // And it did pin implicit auto-install off on the session (item 3).
106
+ const setAutoinstallOff = runSQL
107
+ .getCalls()
108
+ .some((c) =>
109
+ /autoinstall_known_extensions\s*=\s*false/i.test(String(c.args[0])),
110
+ );
111
+ expect(setAutoinstallOff).toBe(true);
112
+
113
+ await config.releaseConnections().catch(() => {});
114
+ });
115
+ });
116
+
117
+ describe("applyExtensionSessionSettings", () => {
118
+ const original = process.env.EXTENSION_FETCH_POLICY;
119
+ afterEach(() => {
120
+ sinon.restore();
121
+ if (original === undefined) delete process.env.EXTENSION_FETCH_POLICY;
122
+ else process.env.EXTENSION_FETCH_POLICY = original;
123
+ });
124
+
125
+ // Minimal stand-in — the helper only ever calls runSQL.
126
+ const mockConn = (runSQL: sinon.SinonStub) =>
127
+ ({ runSQL }) as unknown as DuckDBConnection;
128
+
129
+ it("pins autoinstall off (autoload on) under local-only", async () => {
130
+ process.env.EXTENSION_FETCH_POLICY = "local-only";
131
+ const runSQL = sinon.stub().resolves({ rows: [] });
132
+ await applyExtensionSessionSettings(mockConn(runSQL));
133
+ const sql = runSQL.getCalls().map((c) => String(c.args[0]));
134
+ expect(
135
+ sql.some((s) => /autoinstall_known_extensions\s*=\s*false/i.test(s)),
136
+ ).toBe(true);
137
+ expect(
138
+ sql.some((s) => /autoload_known_extensions\s*=\s*true/i.test(s)),
139
+ ).toBe(true);
140
+ });
141
+
142
+ it("is a no-op for a generic session under on-demand", async () => {
143
+ delete process.env.EXTENSION_FETCH_POLICY;
144
+ const runSQL = sinon.stub().resolves({ rows: [] });
145
+ await applyExtensionSessionSettings(mockConn(runSQL));
146
+ expect(runSQL.callCount).toBe(0);
147
+ });
148
+
149
+ it("pins a tier session even under on-demand (alwaysDisableAutoinstall)", async () => {
150
+ delete process.env.EXTENSION_FETCH_POLICY;
151
+ const runSQL = sinon.stub().resolves({ rows: [] });
152
+ await applyExtensionSessionSettings(mockConn(runSQL), {
153
+ alwaysDisableAutoinstall: true,
154
+ });
155
+ expect(
156
+ runSQL
157
+ .getCalls()
158
+ .some((c) =>
159
+ /autoinstall_known_extensions\s*=\s*false/i.test(
160
+ String(c.args[0]),
161
+ ),
162
+ ),
163
+ ).toBe(true);
164
+ });
165
+
166
+ it("pins each connection only once (dedup)", async () => {
167
+ process.env.EXTENSION_FETCH_POLICY = "local-only";
168
+ const runSQL = sinon.stub().resolves({ rows: [] });
169
+ const conn = mockConn(runSQL);
170
+ await applyExtensionSessionSettings(conn);
171
+ await applyExtensionSessionSettings(conn);
172
+ expect(runSQL.callCount).toBe(2); // two SETs from the first call only
173
+ });
174
+
175
+ it("fails closed under local-only when the pin cannot be applied", async () => {
176
+ process.env.EXTENSION_FETCH_POLICY = "local-only";
177
+ const runSQL = sinon.stub().rejects(new Error("pragma unsupported"));
178
+ await expect(
179
+ applyExtensionSessionSettings(mockConn(runSQL)),
180
+ ).rejects.toThrow(/EXTENSION_FETCH_POLICY=local-only/);
181
+ });
182
+
183
+ it("does NOT fail a tier attach under on-demand when the pin fails", async () => {
184
+ delete process.env.EXTENSION_FETCH_POLICY;
185
+ const runSQL = sinon.stub().rejects(new Error("pragma unsupported"));
186
+ // Resolves (warn-and-continue), does not throw.
187
+ await applyExtensionSessionSettings(mockConn(runSQL), {
188
+ alwaysDisableAutoinstall: true,
189
+ });
190
+ expect(runSQL.callCount).toBeGreaterThan(0);
191
+ });
192
+ });
193
+
194
+ describe("EXTENSION_FETCH_POLICY=on-demand (default) — no behaviour change", () => {
195
+ const envPath = path.join(process.cwd(), "test-extension-policy-ondemand");
196
+ const original = process.env.EXTENSION_FETCH_POLICY;
197
+
198
+ beforeEach(async () => {
199
+ await fs.mkdir(envPath, { recursive: true });
200
+ });
201
+ afterEach(async () => {
202
+ sinon.restore();
203
+ if (original === undefined) delete process.env.EXTENSION_FETCH_POLICY;
204
+ else process.env.EXTENSION_FETCH_POLICY = original;
205
+ await fs.rm(envPath, { recursive: true, force: true }).catch(() => {});
206
+ });
207
+
208
+ it("leaves autoinstall at DuckDB's default on a generic attach session", async () => {
209
+ // Default policy: an ordinary DuckDB connection with an attachment must
210
+ // behave exactly as before — Publisher installs the attachment's extension
211
+ // explicitly, but it must NOT disable DuckDB's implicit auto-install (that
212
+ // would regress a user whose query references an un-provisioned extension).
213
+ delete process.env.EXTENSION_FETCH_POLICY;
214
+
215
+ const runSQL = sinon
216
+ .stub(DuckDBConnection.prototype, "runSQL")
217
+ .resolves({ rows: [] } as never);
218
+
219
+ const config = buildEnvironmentMalloyConfig(
220
+ [
221
+ {
222
+ name: "gen_duck",
223
+ type: "duckdb",
224
+ duckdbConnection: {
225
+ attachedDatabases: [
226
+ {
227
+ name: "pg_attached",
228
+ type: "postgres",
229
+ postgresConnection: {
230
+ connectionString: "postgresql://localhost/test",
231
+ },
232
+ },
233
+ ],
234
+ },
235
+ },
236
+ ] as never,
237
+ envPath,
238
+ );
239
+
240
+ await config.malloyConfig.connections.lookupConnection("gen_duck");
241
+
242
+ const issued = runSQL.getCalls().map((c) => String(c.args[0]));
243
+ // The attachment path ran (postgres explicitly installed)...
244
+ expect(issued.some((sql) => /^\s*INSTALL\s+postgres/i.test(sql))).toBe(
245
+ true,
246
+ );
247
+ // ...but implicit auto-install was left untouched — no behaviour change.
248
+ expect(
249
+ issued.some((sql) =>
250
+ /autoinstall_known_extensions\s*=\s*false/i.test(sql),
251
+ ),
252
+ ).toBe(false);
253
+
254
+ await config.releaseConnections().catch(() => {});
255
+ });
256
+ });
@@ -257,4 +257,33 @@ describe("MaterializationScheduler", () => {
257
257
  await sched.tick(dueLater + 1);
258
258
  expect(service.calls.length).toBe(3);
259
259
  });
260
+
261
+ it("prunes arming state when a package unloads, then re-anchors on reload without a stale fire", async () => {
262
+ // A never-fired schedule (fakeService default lastFireAt = null), so a
263
+ // fresh arm anchors from `now` and does not catch up.
264
+ const service = fakeService();
265
+ const p = fakePackage("p", { schedule: "* * * * *" });
266
+ // makeScheduler closes over this array via getLoadedPackages, so mutating
267
+ // it simulates the package leaving and re-entering the loaded set.
268
+ const loaded = [p];
269
+ const sched = makeScheduler(loaded, service);
270
+
271
+ await sched.tick(t0); // arm: nextFire = t0 + 60s
272
+
273
+ // Unload: the package leaves the loaded set, so the sweep prunes its state
274
+ // (tick's not-seen cleanup) instead of leaving a stale nextFire behind.
275
+ loaded.length = 0;
276
+ await sched.tick(dueLater); // past the old nextFire, but the package is gone
277
+ expect(service.calls.length).toBe(0);
278
+
279
+ // Reload: with the state pruned, arm is fresh — anchored from `now`, so the
280
+ // pre-unload nextFire can't resurface as a spurious catch-up.
281
+ loaded.push(p);
282
+ await sched.tick(dueLater + 1); // now < fresh nextFire -> no fire
283
+ expect(service.calls.length).toBe(0);
284
+
285
+ // It is genuinely re-armed (not inert): the next occurrence still fires.
286
+ await sched.tick(dueLater + 1 + 60_001);
287
+ expect(service.calls.length).toBe(1);
288
+ });
260
289
  });
@@ -1,6 +1,6 @@
1
1
  import type { Connection as MalloyConnection } from "@malloydata/malloy";
2
2
  import { Manifest } from "@malloydata/malloy";
3
- import { beforeEach, describe, expect, it } from "bun:test";
3
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
4
4
  import * as sinon from "sinon";
5
5
  import {
6
6
  BadRequestError,
@@ -27,6 +27,11 @@ import {
27
27
  MaterializationService,
28
28
  stagingSuffix,
29
29
  } from "./materialization_service";
30
+ import { resetMaterializationTelemetryForTesting } from "../materialization_metrics";
31
+ import {
32
+ startMetricsHarness,
33
+ type MetricsHarness,
34
+ } from "../test_helpers/metrics_harness";
30
35
 
31
36
  type MockRepo = sinon.SinonStubbedInstance<ResourceRepository>;
32
37
 
@@ -500,6 +505,86 @@ describe("MaterializationService", () => {
500
505
  });
501
506
  });
502
507
 
508
+ describe("deleteMaterialization telemetry", () => {
509
+ let ctx: ReturnType<typeof createMocks>;
510
+ let harness: MetricsHarness;
511
+
512
+ beforeEach(async () => {
513
+ ctx = createMocks();
514
+ harness = await startMetricsHarness();
515
+ // Drop cached instruments so recordDropTables re-binds to this provider.
516
+ resetMaterializationTelemetryForTesting();
517
+ });
518
+
519
+ afterEach(async () => {
520
+ resetMaterializationTelemetryForTesting();
521
+ await harness.shutdown();
522
+ });
523
+
524
+ const DROP_COUNTER = "publisher_materialization_drop_tables_total";
525
+
526
+ // A terminal materialization whose manifest names one physical table, so
527
+ // dropMaterializedTables issues exactly one DROP — the emission under test.
528
+ function dropTablesFixture(runSQL: sinon.SinonStub) {
529
+ const getMalloyConnection = sinon
530
+ .stub()
531
+ .resolves({ runSQL, dialectName: "duckdb" });
532
+ (ctx.environmentStore.getEnvironment as sinon.SinonStub).resolves({
533
+ getPackage: sinon.stub().resolves({ getMalloyConnection }),
534
+ withPackageLock: async (_n: string, fn: () => Promise<unknown>) =>
535
+ fn(),
536
+ });
537
+ ctx.repository.getMaterializationById.resolves(
538
+ makeMaterialization({
539
+ status: "MANIFEST_FILE_READY",
540
+ manifest: {
541
+ builtAt: new Date().toISOString(),
542
+ strict: false,
543
+ entries: {
544
+ b1: {
545
+ sourceEntityId: "b1",
546
+ physicalTableName: "orders_mz",
547
+ connectionName: "duckdb",
548
+ },
549
+ },
550
+ },
551
+ }),
552
+ );
553
+ }
554
+
555
+ it("meters a successful physical-table drop as outcome=success", async () => {
556
+ dropTablesFixture(sinon.stub().resolves());
557
+
558
+ await ctx.service.deleteMaterialization("my-env", "pkg", "mat-1", {
559
+ dropTables: true,
560
+ });
561
+
562
+ expect(
563
+ await harness.collectCounter(DROP_COUNTER, { outcome: "success" }),
564
+ ).toBe(1);
565
+ expect(
566
+ await harness.collectCounter(DROP_COUNTER, { outcome: "failure" }),
567
+ ).toBe(0);
568
+ });
569
+
570
+ it("meters a failed drop as outcome=failure and still deletes the record", async () => {
571
+ dropTablesFixture(sinon.stub().rejects(new Error("drop boom")));
572
+
573
+ // The drop is best-effort: a failure is metered but never surfaces to the
574
+ // caller, and the record is still removed.
575
+ await ctx.service.deleteMaterialization("my-env", "pkg", "mat-1", {
576
+ dropTables: true,
577
+ });
578
+
579
+ expect(
580
+ await harness.collectCounter(DROP_COUNTER, { outcome: "failure" }),
581
+ ).toBe(1);
582
+ expect(ctx.repository.deleteMaterialization.calledOnceWith("mat-1")).toBe(
583
+ true,
584
+ );
585
+ });
586
+ });
587
+
503
588
  describe("stagingSuffix", () => {
504
589
  it("derives a short, stable suffix from the sourceEntityId", () => {
505
590
  expect(stagingSuffix("abcdef1234567890")).toBe("_abcdef123456");
@@ -1002,6 +1087,39 @@ describe("buildOneSource", () => {
1002
1087
  );
1003
1088
  });
1004
1089
 
1090
+ it("rethrows the original build error when staging cleanup also fails", async () => {
1091
+ const runSQL = sinon.stub();
1092
+ runSQL.onCall(0).resolves(); // initial staging drop
1093
+ runSQL.onCall(1).rejects(new Error("create boom")); // CREATE TABLE AS
1094
+ runSQL.onCall(2).rejects(new Error("cleanup boom")); // cleanup drop fails
1095
+ // The cleanup failure is swallowed (logged as a physical leak); the
1096
+ // original build error is what propagates, so the run is classified by its
1097
+ // real cause rather than the cleanup noise.
1098
+ await expect(callBuildOneSource({ runSQL }, "orders_v1")).rejects.toThrow(
1099
+ "create boom",
1100
+ );
1101
+ expect(runSQL.callCount).toBe(3);
1102
+ expect(runSQL.lastCall.args[0]).toBe(
1103
+ 'DROP TABLE IF EXISTS "orders_v1_abcdef123456"',
1104
+ );
1105
+ });
1106
+
1107
+ it("drops staging when the build is cancelled mid-run (no _staging orphan)", async () => {
1108
+ // buildOneSource is cancellation-agnostic: a cancel surfaces as a thrown
1109
+ // build SQL and takes the same staging-cleanup path as a failure, so a
1110
+ // cancelled build leaves no orphaned _staging table behind.
1111
+ const runSQL = sinon.stub();
1112
+ runSQL.onCall(0).resolves(); // initial staging drop
1113
+ runSQL.onCall(1).rejects(new Error("Build cancelled")); // aborted mid-CREATE
1114
+ runSQL.onCall(2).resolves(); // cleanup staging drop
1115
+ await expect(callBuildOneSource({ runSQL }, "orders_v1")).rejects.toThrow(
1116
+ "Build cancelled",
1117
+ );
1118
+ expect(runSQL.lastCall.args[0]).toBe(
1119
+ 'DROP TABLE IF EXISTS "orders_v1_abcdef123456"',
1120
+ );
1121
+ });
1122
+
1005
1123
  it("quotes each segment of a container path for a backtick dialect", async () => {
1006
1124
  const runSQL = sinon.stub().resolves();
1007
1125
  // Container path (dataset.table) on a backtick dialect (BigQuery): each
@@ -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 () => {