@malloy-publisher/server 0.0.228 → 0.0.230

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/app/api-doc.yaml +102 -10
  2. package/dist/app/assets/EnvironmentPage-wa_EPkwK.js +1 -0
  3. package/dist/app/assets/{HomePage-Bkwc9Woc.js → HomePage-jnCrupQp.js} +1 -1
  4. package/dist/app/assets/{LightMode-Bum_KBpN.js → LightMode-DYbwNULZ.js} +1 -1
  5. package/dist/app/assets/MainPage-CuJLrPNI.js +2 -0
  6. package/dist/app/assets/MaterializationsPage-D_67x2ee.js +1 -0
  7. package/dist/app/assets/ModelPage-D5JtAWqR.js +1 -0
  8. package/dist/app/assets/PackagePage-BRwtqUSG.js +1 -0
  9. package/dist/app/assets/{RouteError-DmJLpLXm.js → RouteError-CBNNrnSD.js} +1 -1
  10. package/dist/app/assets/ThemeEditorPage-CTCeBneA.js +1 -0
  11. package/dist/app/assets/WorkbookPage-SN6f1RBm.js +1 -0
  12. package/dist/app/assets/{core-CEDZMHV1.es-_yGzNgNH.js → core-Dp3q5Ieu.es-CD5FvM2s.js} +10 -10
  13. package/dist/app/assets/{index-CdmFub34.js → index-B3Nn8Vm2.js} +459 -446
  14. package/dist/app/assets/index-BLCx1EdC.js +18 -0
  15. package/dist/app/assets/index-C_tJstcx.js +529 -0
  16. package/dist/app/assets/{index-CE9xhdra.js → index-CfmBVB6M.js} +1 -1
  17. package/dist/app/assets/{index-OEjKNSYb.js → index-DU4r7GdU.js} +573 -413
  18. package/dist/app/index.html +1 -1
  19. package/dist/instrumentation.mjs +1 -1
  20. package/dist/package_load_worker.mjs +109 -8
  21. package/dist/server.mjs +7504 -80
  22. package/package.json +15 -13
  23. package/scripts/bake-duckdb-extensions.js +5 -2
  24. package/src/config.spec.ts +87 -1
  25. package/src/config.ts +142 -1
  26. package/src/controller/materialization.controller.spec.ts +9 -0
  27. package/src/controller/materialization.controller.ts +15 -0
  28. package/src/ducklake_version.spec.ts +163 -0
  29. package/src/ducklake_version.ts +153 -0
  30. package/src/errors.ts +12 -0
  31. package/src/malloy_pin_prereqs.spec.ts +25 -0
  32. package/src/materialization_metrics.ts +33 -0
  33. package/src/mcp/skills/build_skills_bundle.ts +29 -9
  34. package/src/mcp/skills/skills_bundle.json +1 -1
  35. package/src/mcp/skills/skills_bundle.spec.ts +31 -2
  36. package/src/query_param_utils.ts +11 -0
  37. package/src/server.ts +74 -9
  38. package/src/service/authorize.spec.ts +22 -0
  39. package/src/service/authorize.ts +267 -10
  40. package/src/service/authorize_integration.spec.ts +1068 -26
  41. package/src/service/connection.spec.ts +79 -7
  42. package/src/service/connection.ts +312 -25
  43. package/src/service/connection_config.spec.ts +21 -0
  44. package/src/service/connection_config.ts +15 -1
  45. package/src/service/cron_evaluator.spec.ts +79 -0
  46. package/src/service/cron_evaluator.ts +105 -0
  47. package/src/service/ducklake_lazy_attach.spec.ts +110 -0
  48. package/src/service/environment.ts +156 -17
  49. package/src/service/environment_store.spec.ts +569 -14
  50. package/src/service/environment_store.ts +237 -31
  51. package/src/service/environment_store_anchoring.spec.ts +107 -0
  52. package/src/service/extension_fetch_policy.spec.ts +256 -0
  53. package/src/service/manifest_loader.spec.ts +3 -1
  54. package/src/service/manifest_loader.ts +9 -5
  55. package/src/service/materialization_scheduler.spec.ts +289 -0
  56. package/src/service/materialization_scheduler.ts +304 -0
  57. package/src/service/materialization_service.spec.ts +286 -5
  58. package/src/service/materialization_service.ts +121 -8
  59. package/src/service/materialization_test_fixtures.ts +10 -1
  60. package/src/service/model.spec.ts +67 -0
  61. package/src/service/model.ts +656 -31
  62. package/src/service/package.spec.ts +38 -0
  63. package/src/service/package.ts +102 -2
  64. package/src/service/package_quote_bound_tables.spec.ts +171 -0
  65. package/src/service/persistence_policy.spec.ts +31 -0
  66. package/src/service/quoting.spec.ts +57 -1
  67. package/src/service/quoting.ts +33 -0
  68. package/src/storage/DatabaseInterface.ts +15 -0
  69. package/src/storage/duckdb/DuckDBRepository.ts +17 -0
  70. package/src/storage/duckdb/MaterializationRepository.spec.ts +191 -0
  71. package/src/storage/duckdb/MaterializationRepository.ts +68 -0
  72. package/tests/fixtures/persist-schedule-test/data/orders.csv +5 -0
  73. package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +11 -0
  74. package/tests/fixtures/persist-schedule-test/publisher.json +7 -0
  75. package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +5 -0
  76. package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +11 -0
  77. package/tests/fixtures/persist-schedule-warn-test/publisher.json +6 -0
  78. package/tests/integration/materialization/environment_list.integration.spec.ts +133 -0
  79. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +7 -2
  80. package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +150 -0
  81. package/tests/integration/materialization/schedule_edit.integration.spec.ts +126 -0
  82. package/tests/integration/materialization/scheduler.integration.spec.ts +200 -0
  83. package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +172 -0
  84. package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +256 -0
  85. package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +143 -0
  86. package/dist/app/assets/EnvironmentPage-EW2lbGvb.js +0 -1
  87. package/dist/app/assets/MainPage-oiEy7TNM.js +0 -2
  88. package/dist/app/assets/MaterializationsPage-C_VJsTgU.js +0 -1
  89. package/dist/app/assets/ModelPage-z8REqAmk.js +0 -1
  90. package/dist/app/assets/PackagePage-C2Vtt1Ln.js +0 -1
  91. package/dist/app/assets/ThemeEditorPage-BywFjC7A.js +0 -1
  92. package/dist/app/assets/WorkbookPage-DCMizDMR.js +0 -1
  93. package/dist/app/assets/index-DDMrjIT3.js +0 -18
  94. package/dist/app/assets/index-EqslXZ44.js +0 -527
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
+ import * as path from "node:path";
3
+ import { buildSkills, type SkillEntry } from "./build_skills_bundle";
2
4
  import bundle from "./skills_bundle.json";
3
5
 
4
6
  const skills = (
@@ -7,9 +9,36 @@ const skills = (
7
9
  }
8
10
  ).skills;
9
11
 
12
+ /** The repo's top-level skills/, which this bundle is generated from. */
13
+ const sourceDir = path.join(
14
+ import.meta.dir,
15
+ "..",
16
+ "..",
17
+ "..",
18
+ "..",
19
+ "..",
20
+ "skills",
21
+ );
22
+
23
+ /**
24
+ * Codepoint order. The committed bundle is sorted with localeCompare, which
25
+ * depends on the runtime's locale, and this suite runs on three platforms.
26
+ * Membership and content are what matter here; file order is cosmetic.
27
+ */
28
+ const byName = (a: SkillEntry, b: SkillEntry) =>
29
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
30
+
10
31
  describe("skills_bundle.json (generated dual-channel asset)", () => {
11
- it("contains the full ported skill set", () => {
12
- expect(skills.length).toBeGreaterThanOrEqual(24);
32
+ it("is in sync with skills/", () => {
33
+ // Regenerate with:
34
+ // bun run src/mcp/skills/build_skills_bundle.ts ../../skills
35
+ expect([...skills].sort(byName)).toEqual(
36
+ [...buildSkills(sourceDir)].sort(byName),
37
+ );
38
+ });
39
+
40
+ it("is not empty", () => {
41
+ expect(skills.length).toBeGreaterThan(0);
13
42
  });
14
43
 
15
44
  it("every skill has a nonempty name, description, and body", () => {
@@ -16,3 +16,14 @@ export function normalizeQueryArray(value: unknown): string[] | undefined {
16
16
  if (Array.isArray(value)) return value.map(String);
17
17
  return [String(value)];
18
18
  }
19
+
20
+ /**
21
+ * Parse an Express query param as a non-negative integer, or `undefined` when
22
+ * it is absent or not a finite integer. Degrades a garbage value (`?limit=abc`)
23
+ * to "unset" rather than passing `NaN` down into a SQL `LIMIT`/`OFFSET` bind.
24
+ */
25
+ export function parseNonNegativeIntParam(value: unknown): number | undefined {
26
+ if (value === undefined || value === null) return undefined;
27
+ const parsed = parseInt(String(value), 10);
28
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined;
29
+ }
package/src/server.ts CHANGED
@@ -34,7 +34,11 @@ import {
34
34
  } from "./instrumentation";
35
35
  import { logger, loggerMiddleware } from "./logger";
36
36
 
37
- import { getMemoryGovernorConfig } from "./config";
37
+ import {
38
+ getExtensionFetchPolicy,
39
+ getMaterializationSchedulerConfig,
40
+ getMemoryGovernorConfig,
41
+ } from "./config";
38
42
  import { setFilterDeprecationHeaders } from "./filter_deprecation";
39
43
  import { checkHeapConfiguration } from "./heap_check";
40
44
  import { queryConcurrency } from "./query_concurrency";
@@ -43,8 +47,12 @@ import { ThemeController } from "./controller/theme.controller";
43
47
  import { initializeMcpServer } from "./mcp/server";
44
48
  import { registerLegacyRoutes } from "./server-old";
45
49
  import { EnvironmentStore } from "./service/environment_store";
50
+ import { MaterializationScheduler } from "./service/materialization_scheduler";
46
51
  import { MaterializationService } from "./service/materialization_service";
47
- import { normalizeQueryArray } from "./query_param_utils";
52
+ import {
53
+ normalizeQueryArray,
54
+ parseNonNegativeIntParam,
55
+ } from "./query_param_utils";
48
56
  import { PackageMemoryGovernor } from "./service/package_memory_governor";
49
57
  import { ThemeStore } from "./service/theme_store";
50
58
  import { assertSafePackageName, safeJoinUnderRoot } from "./path_safety";
@@ -183,7 +191,7 @@ app.use(httpMetricsMiddleware);
183
191
  // looks low for the default caps" advisory so operators don't
184
192
  // chase OOMKills before checking the obvious config.
185
193
  checkHeapConfiguration();
186
- const environmentStore = new EnvironmentStore(SERVER_ROOT);
194
+ export const environmentStore = new EnvironmentStore(SERVER_ROOT);
187
195
  const watchModeController = new WatchModeController(environmentStore);
188
196
  const connectionController = new ConnectionController(environmentStore);
189
197
  const modelController = new ModelController(environmentStore);
@@ -192,6 +200,12 @@ const modelController = new ModelController(environmentStore);
192
200
  // that Environment.getPackage / addPackage consult before allocating
193
201
  // any new package — the server responds with HTTP 503 instead of
194
202
  // OOM-killing the pod.
203
+ // Validate the DuckDB extension-fetch policy at boot so an unrecognised value
204
+ // (e.g. a k8s-manifest typo) fails the boot loudly here, rather than surfacing
205
+ // on the first query that resolves a DuckDB connection — which matters most for
206
+ // an operator relying on `local-only` for a no-network guarantee. Logging the
207
+ // resolved policy also records the posture the server booted with.
208
+ logger.info(`DuckDB extension-fetch policy: ${getExtensionFetchPolicy()}`);
195
209
  const memoryGovernorConfig = getMemoryGovernorConfig();
196
210
  const memoryGovernor = memoryGovernorConfig
197
211
  ? new PackageMemoryGovernor(memoryGovernorConfig)
@@ -206,6 +220,32 @@ const materializationService = new MaterializationService(environmentStore);
206
220
  const materializationController = new MaterializationController(
207
221
  materializationService,
208
222
  );
223
+ /**
224
+ * Construct and start the standalone materialization scheduler from environment
225
+ * config, or return null when the feature is disabled
226
+ * (`PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER` unset/false — the default, so an
227
+ * orchestrated deployment never runs it). Extracted from the module body so a
228
+ * test can drive the real env-var → {@link getMaterializationSchedulerConfig} →
229
+ * construct → `start()`/`unref()` → timer path an operator uses; the
230
+ * module-level singleton below is armed once at import and can't be re-created
231
+ * per test.
232
+ */
233
+ export function startMaterializationSchedulerFromEnv(
234
+ store: EnvironmentStore,
235
+ service: MaterializationService,
236
+ ): MaterializationScheduler | null {
237
+ const config = getMaterializationSchedulerConfig();
238
+ if (!config) return null;
239
+ const scheduler = new MaterializationScheduler(store, service, config);
240
+ scheduler.start();
241
+ return scheduler;
242
+ }
243
+
244
+ // Standalone materialization scheduler: opt-in via
245
+ // PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER (default off, so an orchestrated
246
+ // deployment — whose control plane drives materialization — never runs it). The
247
+ // sweep timer is `unref`'d, so it never keeps the process alive on shutdown.
248
+ startMaterializationSchedulerFromEnv(environmentStore, materializationService);
209
249
  const themeStore = new ThemeStore(environmentStore.storageManager, SERVER_ROOT);
210
250
  const themeController = new ThemeController(themeStore, SERVER_ROOT);
211
251
 
@@ -1319,6 +1359,31 @@ app.post(
1319
1359
  },
1320
1360
  );
1321
1361
 
1362
+ // Environment-scoped aggregate: every materialization across all packages in
1363
+ // the env, newest first. Nested under `/packages` as the collection-level
1364
+ // sibling of the per-package `/packages/:packageName/materializations` list.
1365
+ // MUST stay registered ahead of `/packages/:packageName` below so the literal
1366
+ // `materializations` segment wins the match; consequently `materializations` is
1367
+ // a reserved package name at this position (a package can never be named that).
1368
+ app.get(
1369
+ `${API_PREFIX}/environments/:environmentName/packages/materializations`,
1370
+ async (req, res) => {
1371
+ try {
1372
+ const limit = parseNonNegativeIntParam(req.query.limit);
1373
+ const offset = parseNonNegativeIntParam(req.query.offset);
1374
+ const builds =
1375
+ await materializationController.listEnvironmentMaterializations(
1376
+ req.params.environmentName,
1377
+ { limit, offset },
1378
+ );
1379
+ res.status(200).json(builds);
1380
+ } catch (error) {
1381
+ const { json, status } = internalErrorToHttpError(error as Error);
1382
+ res.status(status).json(json);
1383
+ }
1384
+ },
1385
+ );
1386
+
1322
1387
  app.get(
1323
1388
  `${API_PREFIX}/environments/:environmentName/packages/:packageName`,
1324
1389
  async (req, res) => {
@@ -1636,6 +1701,10 @@ app.post(
1636
1701
  );
1637
1702
 
1638
1703
  // ==================== MATERIALIZATION ROUTES ====================
1704
+ // The environment-scoped aggregate list (every materialization across all
1705
+ // packages) is registered up in the package routes as
1706
+ // `/packages/materializations`, ahead of `/packages/:packageName`, so the
1707
+ // literal wins the match — see that route for the ordering contract.
1639
1708
 
1640
1709
  app.post(
1641
1710
  `${API_PREFIX}/environments/:environmentName/packages/:packageName/materializations`,
@@ -1658,12 +1727,8 @@ app.get(
1658
1727
  `${API_PREFIX}/environments/:environmentName/packages/:packageName/materializations`,
1659
1728
  async (req, res) => {
1660
1729
  try {
1661
- const limit = req.query.limit
1662
- ? parseInt(req.query.limit as string, 10)
1663
- : undefined;
1664
- const offset = req.query.offset
1665
- ? parseInt(req.query.offset as string, 10)
1666
- : undefined;
1730
+ const limit = parseNonNegativeIntParam(req.query.limit);
1731
+ const offset = parseNonNegativeIntParam(req.query.offset);
1667
1732
  const builds = await materializationController.listMaterializations(
1668
1733
  req.params.environmentName,
1669
1734
  req.params.packageName,
@@ -3,8 +3,30 @@ import {
3
3
  collectAuthorizeExprs,
4
4
  isProbeTrue,
5
5
  parseAuthorizeAnnotation,
6
+ referencedGivenNames,
6
7
  } from "./authorize";
7
8
 
9
+ describe("referencedGivenNames", () => {
10
+ it("returns the $NAME tokens deduped in first-seen order", () => {
11
+ expect(
12
+ referencedGivenNames("$ROLE = 'admin' and $ROLE != $PRIOR"),
13
+ ).toEqual(["ROLE", "PRIOR"]);
14
+ });
15
+
16
+ it("ignores a $NAME inside a string literal (not a real reference)", () => {
17
+ // Otherwise a joined gate's referenced-given count is inflated and the
18
+ // full-coverage check wrongly denies a correctly-authorized request.
19
+ expect(referencedGivenNames("$ROLE = 'the $BOSS role'")).toEqual([
20
+ "ROLE",
21
+ ]);
22
+ expect(referencedGivenNames("'$A $B $C'")).toEqual([]);
23
+ expect(referencedGivenNames("$X = 'it\\'s $Y' or $Z = 1")).toEqual([
24
+ "X",
25
+ "Z",
26
+ ]);
27
+ });
28
+ });
29
+
8
30
  describe("isProbeTrue", () => {
9
31
  it("grants only on a genuine true / 1 / 'true'", () => {
10
32
  expect(isProbeTrue(true)).toBe(true);
@@ -27,6 +27,12 @@ const FILE_PREFIX = "##(authorize)";
27
27
  /** source name → effective authorize expressions (file-level then source-level). */
28
28
  export type AuthorizeMap = Map<string, string[]>;
29
29
 
30
+ /** A `given:` declaration to prepend to a probe so it compiles standalone. */
31
+ export interface ProbeGivenDecl {
32
+ name: string;
33
+ type: string;
34
+ }
35
+
30
36
  /**
31
37
  * Build the synthetic probe query that evaluates a source's authorize
32
38
  * expressions. Each expression becomes a boolean `select` column over a
@@ -37,18 +43,119 @@ export type AuthorizeMap = Map<string, string[]>;
37
43
  * compile errors); running it evaluates the gate. The reserved dummy column
38
44
  * name is deliberately obscure so a real authorize expression is unlikely to
39
45
  * collide with it — a bare field reference in an expression is meant to fail.
46
+ *
47
+ * `givenDecls`, when supplied, are prepended as the probe's OWN `given:`
48
+ * block, making the probe self-contained: it compiles against whatever
49
+ * givens it declares itself rather than depending on the ambient model's
50
+ * given namespace. This is what lets {@link evaluateAuthorize} gate a joined
51
+ * source whose givens live in a model that isn't the one the probe is
52
+ * compiled against (see the two-hop transitive-import case in
53
+ * `docs/authorize.md`). Compile-time validation (`validateAuthorizeProbes`)
54
+ * calls this with no decls, so it still validates against the ambient
55
+ * namespace of the model it's compiling in.
40
56
  */
41
- export function buildAuthorizeProbe(exprs: string[]): string {
57
+ export function buildAuthorizeProbe(
58
+ exprs: string[],
59
+ givenDecls: ProbeGivenDecl[] = [],
60
+ ): string {
42
61
  const selects = exprs
43
62
  .map((expr, i) => `__auth_${i} is (${expr})`)
44
63
  .join("\n ");
45
- return `run: duckdb.sql("SELECT 1 AS __authorize_probe_row") -> {
64
+ const givenBlock =
65
+ givenDecls.length > 0
66
+ ? `given:\n${givenDecls.map((g) => ` ${g.name} :: ${g.type}`).join("\n")}\n\n`
67
+ : "";
68
+ return `${givenBlock}run: duckdb.sql("SELECT 1 AS __authorize_probe_row") -> {
46
69
  select:
47
70
  ${selects}
48
71
  limit: 1
49
72
  }`;
50
73
  }
51
74
 
75
+ const GIVEN_REF_PATTERN = /\$([A-Za-z_][A-Za-z0-9_]*)/g;
76
+ // Malloy string literals are single-quoted; a `$NAME` inside one is literal
77
+ // text, not a given reference. Strip literals (honoring `\'` escapes) before
78
+ // scanning, so e.g. `$ROLE = 'the $BOSS role'` references only ROLE — otherwise
79
+ // a joined gate's referenced-count is inflated and the full-coverage check
80
+ // wrongly denies a correctly-authorized request.
81
+ const STRING_LITERAL_PATTERN = /'(?:\\.|[^'\\])*'/g;
82
+
83
+ /**
84
+ * Given names an authorize expression references (`$NAME` tokens), deduped,
85
+ * in first-seen order. Used to figure out which givens a self-contained
86
+ * probe needs to declare for a given expression.
87
+ */
88
+ export function referencedGivenNames(expr: string): string[] {
89
+ const scanned = expr.replace(STRING_LITERAL_PATTERN, "''");
90
+ const names: string[] = [];
91
+ const seen = new Set<string>();
92
+ for (const match of scanned.matchAll(GIVEN_REF_PATTERN)) {
93
+ const name = match[1];
94
+ if (!seen.has(name)) {
95
+ seen.add(name);
96
+ names.push(name);
97
+ }
98
+ }
99
+ return names;
100
+ }
101
+
102
+ /**
103
+ * Infer a Malloy given type from a caller-supplied JS value, so a
104
+ * self-contained probe can declare exactly the givens an expression
105
+ * references without depending on the ambient model's own given namespace —
106
+ * which a joined source pulled in from a different (possibly multi-hop
107
+ * transitively imported) file may not share. Returns `null` for a value with
108
+ * no sensible Malloy type (null, a plain object, an empty untyped array);
109
+ * callers skip declaring that given, which fails the probe closed —
110
+ * consistent with the existing "referenced given has no value" behavior.
111
+ */
112
+ function inferGivenType(value: GivenValue): string | null {
113
+ if (typeof value === "string") return "string";
114
+ if (typeof value === "number" || typeof value === "bigint") return "number";
115
+ if (typeof value === "boolean") return "boolean";
116
+ if (value instanceof Date) return "timestamp";
117
+ if (Array.isArray(value)) {
118
+ if (value.length === 0) return null;
119
+ const elementType = inferGivenType(value[0]);
120
+ return elementType ? `${elementType}[]` : null;
121
+ }
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * Build the self-contained `given:` declarations + bound values a probe
127
+ * needs for one expression, from whatever the caller supplied. A referenced
128
+ * name that's absent from `givens` (or whose value has no inferrable type) is
129
+ * simply left undeclared — the probe then fails to compile for that
130
+ * expression (an undeclared `$NAME`), which {@link evaluateAuthorize} already
131
+ * treats as "this disjunct can't be evaluated" and denies that branch.
132
+ *
133
+ * `declaredTypes`, when supplied, is preferred over inferring the type from
134
+ * the caller's JS value — the gate author's own `given:` declaration is the
135
+ * source of truth (e.g. `$LEVEL > 3` should compare numerically even if the
136
+ * caller sends `"5"` as a string). Only covers a name declared within one
137
+ * import hop of the entry model (the same reach as `this.givens` — see
138
+ * `docs/authorize.md`); a gate on a source reached through a deeper
139
+ * transitive import falls back to inferring from the value, same as before.
140
+ */
141
+ function bindProbeGivens(
142
+ expr: string,
143
+ givens: Record<string, GivenValue>,
144
+ declaredTypes?: Map<string, string>,
145
+ ): { decls: ProbeGivenDecl[]; bound: Record<string, GivenValue> } {
146
+ const decls: ProbeGivenDecl[] = [];
147
+ const bound: Record<string, GivenValue> = {};
148
+ for (const name of referencedGivenNames(expr)) {
149
+ if (!(name in givens)) continue;
150
+ const value = givens[name];
151
+ const type = declaredTypes?.get(name) ?? inferGivenType(value);
152
+ if (!type) continue;
153
+ decls.push({ name, type });
154
+ bound[name] = value;
155
+ }
156
+ return { decls, bound };
157
+ }
158
+
52
159
  /**
53
160
  * Strict, fail-closed truthiness for a probe result cell. DuckDB (the probe's
54
161
  * connection) returns a native boolean, but normalize defensively: only a real
@@ -74,6 +181,25 @@ interface AuthorizeProbeExecutor {
74
181
  };
75
182
  }
76
183
 
184
+ /**
185
+ * Run one probe (already-built query text) and report whether it evaluated
186
+ * true. Any throw (compile error, runtime "no value" for a referenced given,
187
+ * a missing/malformed result) propagates to the caller, which decides how to
188
+ * react — {@link evaluateAuthorize} treats it as "can't evaluate this way,
189
+ * try something else" rather than granting.
190
+ */
191
+ async function runProbe(
192
+ executor: AuthorizeProbeExecutor,
193
+ probeText: string,
194
+ givens: Record<string, GivenValue>,
195
+ ): Promise<boolean> {
196
+ const result = await executor
197
+ .loadQuery(probeText)
198
+ .run({ rowLimit: 1, givens });
199
+ const row = result?.data?.value?.[0];
200
+ return !!(row && isProbeTrue(row.__auth_0));
201
+ }
202
+
77
203
  /**
78
204
  * Evaluate a source's authorize disjunction against the supplied givens.
79
205
  * Returns true if ANY expression evaluates true (OR semantics).
@@ -88,31 +214,162 @@ interface AuthorizeProbeExecutor {
88
214
  * Per-branch failures are swallowed to false (fail closed at the branch level);
89
215
  * access is granted only if some branch genuinely returns true. Short-circuits
90
216
  * on the first true. Returns false (→ caller denies) if none grant.
217
+ *
218
+ * Each expression is tried TWICE if needed, in one of two orders depending on
219
+ * `selfContainedFirst`:
220
+ *
221
+ * - AMBIENT FIRST (`selfContainedFirst` false — the default, used only for
222
+ * the run target's OWN source gate): compile against `executor`'s own
223
+ * given namespace, passing the full supplied `givens` — the original
224
+ * approach, unchanged for the common case where `executor` (always the
225
+ * ENTRY model's materializer) already has the referenced givens in scope
226
+ * (same file, or a directly imported one — Malloy merges one level of
227
+ * import into a model's given namespace). Falls back to the
228
+ * self-contained probe below only if the ambient probe THROWS.
229
+ *
230
+ * - SELF-CONTAINED FIRST (`selfContainedFirst` true — used for every gate
231
+ * reached via a join/derivation/composite-member walk): declare just the
232
+ * givens this expression references ({@link bindProbeGivens}), preferring
233
+ * `declaredTypes` (the gate author's own declaration) over inferring from
234
+ * the caller-supplied value, so the probe compiles independently of
235
+ * `executor`'s namespace. This is what isolates a JOINED source's gate
236
+ * from the entry model's own ambient given namespace: if the entry model
237
+ * happens to declare its OWN given of the SAME NAME the joined gate
238
+ * references, an ambient-first probe would compile successfully against
239
+ * the ENTRY model's default/value instead of failing closed — silently
240
+ * granting access based on an unrelated given. An expression referencing
241
+ * NO givens at all (a constant/public gate, e.g. `#(authorize) "true"`)
242
+ * has nothing ambient to isolate from, so it's evaluated with a no-decls
243
+ * self-contained probe and returned directly — no fallback needed.
244
+ * Otherwise, the probe is attempted ONLY when EVERY referenced given got
245
+ * a decl — a probe that declares just SOME of them is not actually
246
+ * isolated: `executor.loadQuery` still compiles it against the ENTRY
247
+ * model's own materializer, so an undeclared referenced name resolves via
248
+ * the entry model's own ambient value/default for that name INSTEAD OF
249
+ * THROWING — reopening the collision hole for a partially supplied
250
+ * multi-given gate without ever reaching a catch block. So "not every
251
+ * referenced name could be bound" denies immediately, with NO probe
252
+ * attempted and NO ambient fallback. Once every referenced name IS
253
+ * declared, ambient is tried as a last resort only if that fully-declared
254
+ * probe itself throws for some OTHER reason — safe at that point since no
255
+ * referenced name is left for an entry default to decide.
256
+ *
257
+ * Either order still lets a gate on a source reached through a multi-hop
258
+ * transitive import evaluate correctly — Malloy does not flatten a `given:`
259
+ * declaration through more than one level of import, so the entry model's own
260
+ * namespace can be missing a given that's declared two-or-more imports away
261
+ * from a JOINED source's home file (see `docs/authorize.md`).
91
262
  */
92
263
  export async function evaluateAuthorize(
93
264
  executor: AuthorizeProbeExecutor,
94
265
  exprs: string[],
95
266
  givens: Record<string, GivenValue>,
267
+ declaredTypes?: Map<string, string>,
268
+ options?: { selfContainedFirst?: boolean },
96
269
  ): Promise<boolean> {
270
+ const selfContainedFirst = options?.selfContainedFirst ?? false;
97
271
  for (const expr of exprs) {
272
+ if (selfContainedFirst) {
273
+ if (
274
+ await evaluateSelfContainedFirst(
275
+ executor,
276
+ expr,
277
+ givens,
278
+ declaredTypes,
279
+ )
280
+ ) {
281
+ return true;
282
+ }
283
+ continue;
284
+ }
98
285
  try {
99
- const result = await executor
100
- .loadQuery(buildAuthorizeProbe([expr]))
101
- .run({ rowLimit: 1, givens });
102
- const row = result?.data?.value?.[0];
103
- if (row && isProbeTrue(row.__auth_0)) {
286
+ if (await runProbe(executor, buildAuthorizeProbe([expr]), givens)) {
104
287
  return true;
105
288
  }
289
+ continue;
106
290
  } catch {
107
- // This disjunct can't be evaluated (e.g. a referenced given has no
108
- // value) treat as not-granting and try the next. It does not fail
109
- // the whole request, which is what keeps OR semantics intact.
291
+ // Ambient compile/eval failed fall through to the self-contained
292
+ // retry below rather than immediately treating this disjunct as
293
+ // not-granting.
294
+ }
295
+ try {
296
+ const { decls, bound } = bindProbeGivens(expr, givens, declaredTypes);
297
+ if (decls.length === 0) continue; // nothing left to try — deny
298
+ if (
299
+ await runProbe(executor, buildAuthorizeProbe([expr], decls), bound)
300
+ ) {
301
+ return true;
302
+ }
303
+ } catch {
304
+ // Still can't be evaluated — deny this branch and try the next.
305
+ // Does not fail the whole request, which is what keeps OR
306
+ // semantics intact.
110
307
  continue;
111
308
  }
112
309
  }
113
310
  return false;
114
311
  }
115
312
 
313
+ /**
314
+ * One expression's self-contained-first evaluation (see
315
+ * {@link evaluateAuthorize}'s `selfContainedFirst` mode). A probe is only
316
+ * ever attempted once every given the expression references has been
317
+ * declared — a partially-declared probe still compiles against the ENTRY
318
+ * model's own materializer, so an undeclared name silently resolves via the
319
+ * entry's own ambient value/default instead of throwing, which is not
320
+ * isolation at all. Ambient is tried as a last resort only when the
321
+ * fully-declared self-contained probe itself throws for some other reason.
322
+ */
323
+ async function evaluateSelfContainedFirst(
324
+ executor: AuthorizeProbeExecutor,
325
+ expr: string,
326
+ givens: Record<string, GivenValue>,
327
+ declaredTypes?: Map<string, string>,
328
+ ): Promise<boolean> {
329
+ const referenced = referencedGivenNames(expr);
330
+ if (referenced.length === 0) {
331
+ // The expression references NO givens at all — a constant/public gate
332
+ // (e.g. `#(authorize) "true"`). There's nothing ambient to isolate
333
+ // from, so run the no-decls probe (still fully self-contained: it
334
+ // declares no givens and is handed none) and use its result directly.
335
+ // This is NOT the "unsatisfiable" case below — that's specifically
336
+ // about a referenced given the caller can't supply.
337
+ try {
338
+ return await runProbe(executor, buildAuthorizeProbe([expr]), {});
339
+ } catch {
340
+ return false;
341
+ }
342
+ }
343
+ const { decls, bound } = bindProbeGivens(expr, givens, declaredTypes);
344
+ if (decls.length !== referenced.length) {
345
+ // Not every referenced given could be bound — deny. A probe that
346
+ // declares only SOME of the referenced names is not actually isolated:
347
+ // `executor.loadQuery` compiles it against the ENTRY model's own
348
+ // materializer, so an undeclared referenced given resolves via the
349
+ // ENTRY model's own ambient value/default for that name instead of
350
+ // throwing — reopening the name-collision hole for a partially
351
+ // supplied multi-given gate. Only attempt a probe once every
352
+ // referenced name can be declared (and thus shadowed) ourselves.
353
+ return false;
354
+ }
355
+ try {
356
+ return await runProbe(
357
+ executor,
358
+ buildAuthorizeProbe([expr], decls),
359
+ bound,
360
+ );
361
+ } catch {
362
+ // Every referenced given was supplied and declared, so a throw here
363
+ // isn't the collision case — there's no unsupplied name left for an
364
+ // entry default to decide. Ambient is a safe last resort.
365
+ try {
366
+ return await runProbe(executor, buildAuthorizeProbe([expr]), givens);
367
+ } catch {
368
+ return false;
369
+ }
370
+ }
371
+ }
372
+
116
373
  /** A source plus its effective authorize expressions. */
117
374
  interface SourceWithAuthorize {
118
375
  name?: string;