History

Serve Microsoft Graph selections from precomputed spec slices

Rhys Sullivan committed 2 months ago

Commit aa423aa1 parent 000747410 files · +714 −3Browse files

Unified · Split

10 files
  1. .changeset/graph-spec-slices.md +5 −0
  2. .github/workflows/graph-slices.yml +47 −0
  3. .oxlintrc.jsonc +1 −0
  4. packages/plugins/openapi/scripts/generate-graph-slices.ts +109 −0
  5. packages/plugins/openapi/src/providers/microsoft/graph.ts +23 −3
  6. packages/plugins/openapi/src/providers/microsoft/slice-build.test.ts +104 −0
  7. packages/plugins/openapi/src/providers/microsoft/slice-build.ts +193 −0
  8. packages/plugins/openapi/src/providers/microsoft/slices.test.ts +70 −0
  9. packages/plugins/openapi/src/providers/microsoft/slices.ts +87 −0
  10. packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.test.ts +75 −0
.changeset/graph-spec-slices.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"@executor-js/plugin-openapi": patch
3+---
4+
5+Serve Microsoft Graph preset selections from precomputed slice release assets instead of the 43MB upstream monolith. The monolith fetch almost never survives a 128MB Workers isolate (production traces show one completion in 30 days), so covered selections — every catalog preset, plus any combination within the default bundle — now read a 4–19MB filtered document built offline by the graph-slices workflow, with the monolith path kept only as a fallback and for full-graph/custom-scope selections.
.github/workflows/graph-slices.yml+47−0
@@ -0,0 +1,47 @@
1+# Refresh the Microsoft Graph slice release assets.
2+#
3+# The Graph OpenAPI monolith (~43MB) cannot be processed inside a Workers
4+# isolate, so the runtime reads per-selection slices published on the
5+# `graph-slices` release tag (see packages/plugins/openapi/src/providers/
6+# microsoft/slices.ts). This workflow rebuilds the slices from the current
7+# upstream spec on a schedule and on demand.
8+name: Graph slices
9+
10+on:
11+ schedule:
12+ # Weekly; Microsoft's msgraph-metadata automation lands upstream refreshes
13+ # on a similar cadence. A failed run leaves the previous assets serving.
14+ - cron: "17 6 * * 1"
15+ workflow_dispatch: {}
16+
17+permissions:
18+ contents: write
19+
20+jobs:
21+ slices:
22+ runs-on: ubuntu-latest
23+ timeout-minutes: 20
24+ steps:
25+ - uses: actions/checkout@v4
26+
27+ - uses: oven-sh/setup-bun@v2
28+ with:
29+ bun-version: 1.3.11
30+
31+ - name: Install dependencies
32+ run: bun install --frozen-lockfile
33+
34+ - name: Generate slices
35+ working-directory: packages/plugins/openapi
36+ run: bun scripts/generate-graph-slices.ts --out "$RUNNER_TEMP/graph-slices"
37+
38+ - name: Publish to the graph-slices release
39+ env:
40+ GH_TOKEN: ${{ github.token }}
41+ run: |
42+ gh release view graph-slices --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 || \
43+ gh release create graph-slices --repo "$GITHUB_REPOSITORY" \
44+ --title "Microsoft Graph slices" --latest=false \
45+ --notes "Generated per-preset Microsoft Graph OpenAPI slices. Data release consumed by the openapi plugin's Microsoft adapter; refreshed by the graph-slices workflow."
46+ gh release upload graph-slices "$RUNNER_TEMP/graph-slices"/* \
47+ --repo "$GITHUB_REPOSITORY" --clobber
.oxlintrc.jsonc+1−0
@@ -64,6 +64,7 @@ · 63 unmodified lines
6464 "apps/desktop/src/main.ts",
6565 "scripts/**/*.{ts,js}",
6666 "apps/*/scripts/**/*.{ts,js}",
67+ "packages/*/*/scripts/**/*.{ts,js}",
6768 "packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}",
6869 ],
6970 "rules": {
packages/plugins/openapi/scripts/generate-graph-slices.ts+109−0
@@ -0,0 +1,109 @@
1+/**
2+ * Generate the Microsoft Graph slice release assets.
3+ *
4+ * bun scripts/generate-graph-slices.ts [--source <path-or-url>] [--out <dir>]
5+ *
6+ * Fetches (or reads) the Graph OpenAPI monolith, builds one slice per catalog
7+ * preset plus the default bundle via `slice-build.ts`, validates every slice
8+ * against the runtime's streamable profile, and writes `<asset>.yaml` files
9+ * plus `manifest.json` to the output directory. The graph-slices workflow runs
10+ * this and uploads the output to the `graph-slices` release tag; runtime
11+ * resolution lives in `src/providers/microsoft/slices.ts`.
12+ *
13+ * Offline-only: this whole-parses the 43MB source, which only works where
14+ * memory is free (CI runner / dev machine), never in a Workers isolate.
15+ */
16+import { createHash } from "node:crypto";
17+import { mkdir, readFile, writeFile } from "node:fs/promises";
18+import { join } from "node:path";
19+
20+import { structuralSplit } from "../src/sdk/split";
21+import {
22+ MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,
23+ MICROSOFT_GRAPH_OPENAPI_URL,
24+ microsoftGraphScopePresets,
25+} from "../src/providers/microsoft/presets";
26+import { MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET } from "../src/providers/microsoft/slices";
27+import {
28+ buildGraphSliceDocument,
29+ parseGraphSourceDocument,
30+} from "../src/providers/microsoft/slice-build";
31+
32+const argValue = (flag: string): string | undefined => {
33+ const index = process.argv.indexOf(flag);
34+ return index !== -1 ? process.argv[index + 1] : undefined;
35+};
36+
37+const source = argValue("--source") ?? MICROSOFT_GRAPH_OPENAPI_URL;
38+const outDir = argValue("--out") ?? "graph-slices-out";
39+
40+const readSource = async (): Promise<string> => {
41+ if (source.startsWith("http://") || source.startsWith("https://")) {
42+ const response = await fetch(source);
43+ if (!response.ok) {
44+ throw new Error(`Failed to fetch Graph source: HTTP ${response.status}`);
45+ }
46+ return response.text();
47+ }
48+ return readFile(source, "utf8");
49+};
50+
51+const sourceText = await readSource();
52+const sourceSha256 = createHash("sha256").update(sourceText).digest("hex");
53+const doc = parseGraphSourceDocument(sourceText);
54+if (!doc) {
55+ throw new Error("Microsoft Graph source did not parse to an object");
56+}
57+
58+const selections: readonly { readonly asset: string; readonly presetIds: readonly string[] }[] = [
59+ ...microsoftGraphScopePresets.map((preset) => ({ asset: preset.id, presetIds: [preset.id] })),
60+ {
61+ asset: MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET,
62+ presetIds: MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,
63+ },
64+];
65+
66+await mkdir(outDir, { recursive: true });
67+
68+const manifestAssets: Record<
69+ string,
70+ {
71+ readonly bytes: number;
72+ readonly paths: number;
73+ readonly operations: number;
74+ readonly schemas: number;
75+ }
76+> = {};
77+
78+for (const { asset, presetIds } of selections) {
79+ const slice = buildGraphSliceDocument(doc, presetIds);
80+ if (slice.operationCount === 0) {
81+ throw new Error(`Slice "${asset}" kept zero operations — preset filter or source drifted`);
82+ }
83+ const structure = structuralSplit(slice.specText);
84+ if (!structure) {
85+ throw new Error(`Slice "${asset}" is not in the streamable block-YAML profile`);
86+ }
87+ if (structure.pathItems.length !== slice.pathCount) {
88+ throw new Error(
89+ `Slice "${asset}" splitter sees ${structure.pathItems.length} path-items, expected ${slice.pathCount}`,
90+ );
91+ }
92+ await writeFile(join(outDir, `${asset}.yaml`), slice.specText);
93+ manifestAssets[asset] = {
94+ bytes: Buffer.byteLength(slice.specText),
95+ paths: slice.pathCount,
96+ operations: slice.operationCount,
97+ schemas: slice.schemaCount,
98+ };
99+ console.log(
100+ `${asset}: ${(Buffer.byteLength(slice.specText) / 1024 / 1024).toFixed(2)}MB, ` +
101+ `${slice.pathCount} paths, ${slice.operationCount} operations, ${slice.schemaCount} schemas`,
102+ );
103+}
104+
105+await writeFile(
106+ join(outDir, "manifest.json"),
107+ `${JSON.stringify({ source, sourceSha256, generatedAt: new Date().toISOString(), assets: manifestAssets }, null, 2)}\n`,
108+);
109+console.log(`wrote ${selections.length} slices + manifest.json to ${outDir}`);
packages/plugins/openapi/src/providers/microsoft/graph.ts+23−3
@@ -15,6 +15,7 @@ import { · 14 unmodified lines
1515 } from "../../sdk/split";
1616 import type { Authentication } from "../../sdk/types";
1717
18+import { fetchMicrosoftGraphSlice, microsoftGraphSliceAssetForSelection } from "./slices";
1819 import {
1920 MICROSOFT_AUTHORIZATION_URL,
2021 MICROSOFT_AUTH_TEMPLATE_SLUG,
@@ -759,9 +760,28 @@ export const buildMicrosoftGraphOpenApiSpec = ( · 738 unmodified lines
759760 ): Effect.Effect<MicrosoftGraphSpecBuild, OpenApiParseError> =>
760761 Effect.gen(function* () {
761762 const selection = yield* validateSelectionUrls(normalizeSelection(input), urlPolicy);
762− const sourceText = yield* fetchMicrosoftGraphOpenApiSpec(selection.specUrl).pipe(
763− Effect.provide(httpClientLayer),
764− );
763+ // Covered selections read a precomputed slice (sub-MB) instead of the 43MB
764+ // monolith: in production, the monolith fetch alone almost never survives
765+ // the 128MB isolate (once in the 30 days before 2026-08-26). Slices apply
766+ // only to the pinned Microsoft URL — an override (local Graph emulators)
767+ // serves its own document. A missing/failed slice (asset not yet published,
768+ // release unreachable) falls back to the monolith path, which is the prior
769+ // behavior for the selections a slice would have covered.
770+ const sliceAsset =
771+ selection.specUrl === MICROSOFT_GRAPH_OPENAPI_URL
772+ ? microsoftGraphSliceAssetForSelection(selection)
773+ : null;
774+ const sourceText =
775+ sliceAsset !== null
776+ ? yield* fetchMicrosoftGraphSlice(sliceAsset).pipe(
777+ Effect.catchTag("OpenApiParseError", () =>
778+ fetchMicrosoftGraphOpenApiSpec(selection.specUrl),
779+ ),
780+ Effect.provide(httpClientLayer),
781+ )
782+ : yield* fetchMicrosoftGraphOpenApiSpec(selection.specUrl).pipe(
783+ Effect.provide(httpClientLayer),
784+ );
765785
766786 // Structural split is the only entry point: parsing the whole 37MB tree
767787 // OOMs the 128MB Workers isolate (measured: HTTP 503). No fallback. A spec
packages/plugins/openapi/src/providers/microsoft/slice-build.test.ts+104−0
@@ -0,0 +1,104 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { structuralSplit } from "../../sdk/split";
4+import { buildGraphSliceDocument, parseGraphSourceDocument } from "./slice-build";
5+
6+// Graph-shaped source: a mail path, an unrelated path, and a schema chain
7+// where only part is reachable from the mail selection.
8+const source = `openapi: 3.0.4
9+info:
10+ title: Microsoft Graph Fixture
11+ version: v1.0
12+servers:
13+ - url: https://graph.microsoft.com/v1.0
14+paths:
15+ /me/messages:
16+ get:
17+ operationId: me.ListMessages
18+ security:
19+ - azureAdDelegated:
20+ - Mail.ReadWrite
21+ parameters:
22+ - $ref: '#/components/parameters/Top'
23+ responses:
24+ "200":
25+ description: OK
26+ content:
27+ application/json:
28+ schema:
29+ $ref: '#/components/schemas/microsoft.graph.messageCollection'
30+ /irrelevant:
31+ get:
32+ operationId: irrelevant.Get
33+ responses:
34+ "200":
35+ description: OK
36+ content:
37+ application/json:
38+ schema:
39+ $ref: '#/components/schemas/microsoft.graph.unrelated'
40+components:
41+ parameters:
42+ Top:
43+ name: $top
44+ in: query
45+ schema:
46+ type: integer
47+ securitySchemes:
48+ azureAdDelegated:
49+ type: oauth2
50+ flows:
51+ authorizationCode:
52+ authorizationUrl: https://login.microsoftonline.com/common/oauth2/v2.0/authorize
53+ tokenUrl: https://login.microsoftonline.com/common/oauth2/v2.0/token
54+ scopes:
55+ Mail.ReadWrite: Read and write mail
56+ schemas:
57+ microsoft.graph.messageCollection:
58+ type: object
59+ properties:
60+ value:
61+ type: array
62+ items:
63+ $ref: '#/components/schemas/microsoft.graph.message'
64+ microsoft.graph.message:
65+ type: object
66+ properties:
67+ id:
68+ type: string
69+ microsoft.graph.unrelated:
70+ type: object
71+ properties:
72+ name:
73+ type: string
74+`;
75+
76+describe("buildGraphSliceDocument", () => {
77+ it("keeps the selection's paths and prunes components to the reachable closure", () => {
78+ const doc = parseGraphSourceDocument(source);
79+ expect(doc).not.toBeNull();
80+ const slice = buildGraphSliceDocument(doc!, ["mail"]);
81+
82+ expect(slice.pathCount).toBe(1);
83+ expect(slice.operationCount).toBe(1);
84+ expect(slice.specText).toContain("/me/messages");
85+ expect(slice.specText).not.toContain("/irrelevant");
86+ expect(slice.specText).toContain("microsoft.graph.messageCollection");
87+ expect(slice.specText).toContain("microsoft.graph.message");
88+ expect(slice.specText).not.toContain("microsoft.graph.unrelated");
89+ // Referenced small components survive; securitySchemes always survive.
90+ expect(slice.specText).toContain("$top");
91+ expect(slice.specText).toContain("azureAdDelegated");
92+ });
93+
94+ it("emits the streamable block-YAML profile the runtime splitter accepts", () => {
95+ const doc = parseGraphSourceDocument(source);
96+ expect(doc).not.toBeNull();
97+ const slice = buildGraphSliceDocument(doc!, ["mail"]);
98+
99+ const structure = structuralSplit(slice.specText);
100+ expect(structure).not.toBeNull();
101+ expect(structure!.pathItems).toHaveLength(slice.pathCount);
102+ expect(structure!.schemas).toHaveLength(slice.schemaCount);
103+ });
104+});
packages/plugins/openapi/src/providers/microsoft/slice-build.ts+193−0
@@ -0,0 +1,193 @@
1+import { JSON_SCHEMA, dump as dumpYamlDocument, load as parseYamlDocument } from "js-yaml";
2+
3+import { microsoftGraphKeepPathItem } from "./graph";
4+import {
5+ microsoftGraphExactPathsForPresetIds,
6+ microsoftGraphPathPrefixesForPresetIds,
7+ microsoftGraphTagPrefixesForPresetIds,
8+} from "./presets";
9+
10+/**
11+ * Offline Microsoft Graph slice construction. NOT part of the runtime import
12+ * graph: the 43MB Graph source cannot be held in a 128MB Workers isolate (its
13+ * fetch alone has completed once in the last 30 days of production traces), so
14+ * slices are built where memory is free — the graph-slices workflow / a dev
15+ * machine — published as release assets, and fetched per selection at runtime
16+ * by `slices.ts`. Import this module only from scripts and tests.
17+ */
18+
19+/** `components` subkeys retained whole in a slice — mirrors the runtime
20+ * splitter's `SMALL_COMPONENT_SECTIONS` (`sdk/split.ts`), which keeps these
21+ * resident for `$ref` resolution. `schemas` is pruned to the transitive
22+ * closure instead; `examples` is dropped. */
23+const SMALL_COMPONENT_SECTIONS = [
24+ "parameters",
25+ "requestBodies",
26+ "responses",
27+ "headers",
28+ "links",
29+ "securitySchemes",
30+] as const;
31+
32+const COMPONENT_REF_PREFIX = "#/components/";
33+
34+const isRecord = (value: unknown): value is Record<string, unknown> =>
35+ value !== null && typeof value === "object" && !Array.isArray(value);
36+
37+const decodeRefSegment = (segment: string): string =>
38+ segment.replace(/~1/g, "/").replace(/~0/g, "~");
39+
40+interface ComponentRef {
41+ readonly section: string;
42+ readonly name: string;
43+}
44+
45+const collectComponentRefs = (value: unknown, into: (ref: ComponentRef) => void): void => {
46+ if (typeof value === "string") {
47+ if (value.startsWith(COMPONENT_REF_PREFIX)) {
48+ const rest = value.slice(COMPONENT_REF_PREFIX.length);
49+ const slash = rest.indexOf("/");
50+ if (slash > 0) {
51+ const section = rest.slice(0, slash);
52+ const name = decodeRefSegment(rest.slice(slash + 1));
53+ if (name.length > 0) into({ section, name });
54+ }
55+ }
56+ return;
57+ }
58+ if (Array.isArray(value)) {
59+ for (const item of value) collectComponentRefs(item, into);
60+ return;
61+ }
62+ if (isRecord(value)) {
63+ for (const item of Object.values(value)) collectComponentRefs(item, into);
64+ }
65+};
66+
67+/**
68+ * Transitive `#/components/<section>/<name>` closure over every component
69+ * section, seeded from `roots` (the kept path-items). Only components actually
70+ * reachable from a kept operation survive — Graph's ~8k schemas and its
71+ * catch-all responses/parameters sections otherwise drag nearly the whole
72+ * component graph into every slice.
73+ */
74+const componentClosure = (
75+ components: Record<string, unknown>,
76+ roots: readonly unknown[],
77+): Record<string, Record<string, unknown>> => {
78+ const kept: Record<string, Record<string, unknown>> = {};
79+ const queue: ComponentRef[] = [];
80+ const seen = new Set<string>();
81+ const enqueue = (ref: ComponentRef): void => {
82+ const key = `${ref.section}/${ref.name}`;
83+ if (seen.has(key)) return;
84+ seen.add(key);
85+ queue.push(ref);
86+ };
87+
88+ for (const root of roots) collectComponentRefs(root, enqueue);
89+ for (let i = 0; i < queue.length; i += 1) {
90+ const { section, name } = queue[i]!;
91+ const sectionValues = components[section];
92+ if (!isRecord(sectionValues)) continue;
93+ const component = sectionValues[name];
94+ if (component === undefined) continue;
95+ (kept[section] ??= {})[name] = component;
96+ collectComponentRefs(component, enqueue);
97+ }
98+ return kept;
99+};
100+
101+export interface GraphSliceBuild {
102+ /** The slice as streamable block YAML (the same profile `structuralSplit`
103+ * accepts, so the runtime pipeline treats a slice exactly like a source). */
104+ readonly specText: string;
105+ readonly pathCount: number;
106+ readonly operationCount: number;
107+ readonly schemaCount: number;
108+}
109+
110+const HTTP_METHODS = new Set(["delete", "get", "head", "options", "patch", "post", "put", "trace"]);
111+
112+const countOperations = (paths: Record<string, unknown>): number => {
113+ let count = 0;
114+ for (const pathItem of Object.values(paths)) {
115+ if (!isRecord(pathItem)) continue;
116+ for (const key of Object.keys(pathItem)) {
117+ if (HTTP_METHODS.has(key.toLowerCase())) count += 1;
118+ }
119+ }
120+ return count;
121+};
122+
123+/**
124+ * Build one preset selection's slice from the parsed Graph document: keep the
125+ * selection's path-items (via the same `microsoftGraphKeepPathItem` filter the
126+ * runtime applies), retain the small component sections whole, and prune
127+ * `components.schemas` to the transitive `$ref` closure of everything kept.
128+ */
129+export const buildGraphSliceDocument = (
130+ doc: Record<string, unknown>,
131+ presetIds: readonly string[],
132+): GraphSliceBuild => {
133+ const keepPathItem = microsoftGraphKeepPathItem({
134+ coversFullGraph: false,
135+ presetIds,
136+ customScopes: [],
137+ exactPaths: microsoftGraphExactPathsForPresetIds(presetIds),
138+ pathPrefixes: microsoftGraphPathPrefixesForPresetIds(presetIds),
139+ tagPrefixes: microsoftGraphTagPrefixesForPresetIds(presetIds),
140+ });
141+
142+ const sourcePaths = isRecord(doc.paths) ? doc.paths : {};
143+ const paths: Record<string, unknown> = {};
144+ for (const [path, pathItem] of Object.entries(sourcePaths)) {
145+ if (!isRecord(pathItem)) continue;
146+ const kept = keepPathItem(path, pathItem);
147+ if (kept) paths[path] = kept;
148+ }
149+
150+ const sourceComponents = isRecord(doc.components) ? doc.components : {};
151+ const closure = componentClosure(sourceComponents, [paths]);
152+ const components: Record<string, unknown> = {};
153+ for (const section of SMALL_COMPONENT_SECTIONS) {
154+ if (closure[section]) components[section] = closure[section];
155+ }
156+ // securitySchemes are never `$ref`'d from operations by name in Graph
157+ // (operations reference them via `security` entries), so retain the section
158+ // whole — it is tiny and the runtime reads OAuth endpoints from it.
159+ if (isRecord(sourceComponents.securitySchemes)) {
160+ components.securitySchemes = sourceComponents.securitySchemes;
161+ }
162+ const schemas = closure.schemas ?? {};
163+ components.schemas = schemas;
164+
165+ const slice: Record<string, unknown> = {
166+ ...(doc.openapi !== undefined ? { openapi: doc.openapi } : {}),
167+ ...(doc.info !== undefined ? { info: doc.info } : {}),
168+ ...(doc.servers !== undefined ? { servers: doc.servers } : {}),
169+ ...(doc.security !== undefined ? { security: doc.security } : {}),
170+ paths,
171+ components,
172+ };
173+
174+ // noRefs duplicates shared subtrees instead of emitting YAML anchors, which
175+ // the streamable block profile forbids; lineWidth -1 keeps scalars on one
176+ // line so no wrapped line can be mistaken for structure.
177+ const specText = dumpYamlDocument(slice, { noRefs: true, lineWidth: -1, schema: JSON_SCHEMA });
178+
179+ return {
180+ specText,
181+ pathCount: Object.keys(paths).length,
182+ operationCount: countOperations(paths),
183+ schemaCount: Object.keys(schemas).length,
184+ };
185+};
186+
187+/** Parse Graph source YAML for slice building, or null when the source does
188+ * not parse to an object. Offline-only: this is the whole-document parse the
189+ * runtime can never do. */
190+export const parseGraphSourceDocument = (sourceText: string): Record<string, unknown> | null => {
191+ const parsed = parseYamlDocument(sourceText, { json: true, schema: JSON_SCHEMA });
192+ return isRecord(parsed) ? parsed : null;
193+};
packages/plugins/openapi/src/providers/microsoft/slices.test.ts+70−0
@@ -0,0 +1,70 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { MICROSOFT_GRAPH_ALL_PRESET_IDS, MICROSOFT_GRAPH_DEFAULT_PRESET_IDS } from "./presets";
4+import {
5+ MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET,
6+ microsoftGraphSliceAssetForSelection,
7+} from "./slices";
8+
9+describe("microsoftGraphSliceAssetForSelection", () => {
10+ it("maps a single catalog preset to its asset", () => {
11+ expect(
12+ microsoftGraphSliceAssetForSelection({
13+ coversFullGraph: false,
14+ presetIds: ["mail"],
15+ customScopes: [],
16+ }),
17+ ).toBe("mail");
18+ });
19+
20+ it("maps the default bundle in any order to the default asset", () => {
21+ expect(
22+ microsoftGraphSliceAssetForSelection({
23+ coversFullGraph: false,
24+ presetIds: [...MICROSOFT_GRAPH_DEFAULT_PRESET_IDS].reverse(),
25+ customScopes: [],
26+ }),
27+ ).toBe(MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET);
28+ });
29+
30+ it("serves combinations within the default bundle from the default slice", () => {
31+ expect(
32+ microsoftGraphSliceAssetForSelection({
33+ coversFullGraph: false,
34+ presetIds: ["mail", "calendar"],
35+ customScopes: [],
36+ }),
37+ ).toBe(MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET);
38+ });
39+
40+ it("needs the monolith for full-graph, custom scopes, unknown presets, and combinations outside the default bundle", () => {
41+ expect(
42+ microsoftGraphSliceAssetForSelection({
43+ coversFullGraph: true,
44+ presetIds: [...MICROSOFT_GRAPH_ALL_PRESET_IDS],
45+ customScopes: [],
46+ }),
47+ ).toBeNull();
48+ expect(
49+ microsoftGraphSliceAssetForSelection({
50+ coversFullGraph: false,
51+ presetIds: ["mail"],
52+ customScopes: ["Chat.Read"],
53+ }),
54+ ).toBeNull();
55+ expect(
56+ microsoftGraphSliceAssetForSelection({
57+ coversFullGraph: false,
58+ presetIds: ["not-a-preset"],
59+ customScopes: [],
60+ }),
61+ ).toBeNull();
62+ expect(
63+ microsoftGraphSliceAssetForSelection({
64+ coversFullGraph: false,
65+ presetIds: ["mail", "users"],
66+ customScopes: [],
67+ }),
68+ ).toBeNull();
69+ });
70+});
packages/plugins/openapi/src/providers/microsoft/slices.ts+87−0
@@ -0,0 +1,87 @@
1+import { Effect } from "effect";
2+import { HttpClient, HttpClientRequest } from "effect/unstable/http";
3+
4+import { OpenApiParseError } from "../../sdk/errors";
5+
6+import { MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, microsoftGraphPresetForId } from "./presets";
7+
8+/**
9+ * Runtime access to precomputed Microsoft Graph slices.
10+ *
11+ * The 43MB Graph monolith cannot be processed in a 128MB Workers isolate — in
12+ * production its fetch alone completed once in the 30 days before 2026-08-26;
13+ * every other preview/add died mid-download with an empty 503. Slices are
14+ * built offline (`slice-build.ts`, refreshed by the graph-slices workflow),
15+ * published as release assets, and fetched here per selection: the isolate
16+ * only ever holds the sub-megabyte filtered document for the selection.
17+ */
18+
19+export const MICROSOFT_GRAPH_SLICE_RELEASE_TAG = "graph-slices";
20+
21+export const MICROSOFT_GRAPH_SLICE_BASE_URL = `https://github.com/UsefulSoftwareCo/executor/releases/download/${MICROSOFT_GRAPH_SLICE_RELEASE_TAG}`;
22+
23+/** Asset name for the default catalog bundle (`MICROSOFT_GRAPH_DEFAULT_PRESET_IDS`). */
24+export const MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET = "default";
25+
26+/**
27+ * The published asset covering a selection, or null when the selection needs
28+ * the monolith. A slice may be a superset of the selection: the runtime always
29+ * applies the selection's `keepPathItem` filter to whatever source it reads, so
30+ * any combination within the default bundle can be served from the default
31+ * slice and narrowed in-band. The monolith remains necessary for full-graph
32+ * coverage, custom scopes (scope matching walks operations outside any
33+ * preset's paths), and combinations reaching outside the default bundle
34+ * (precomputing every combination is combinatorial).
35+ */
36+export const microsoftGraphSliceAssetForSelection = (selection: {
37+ readonly coversFullGraph: boolean;
38+ readonly presetIds: readonly string[];
39+ readonly customScopes: readonly string[];
40+}): string | null => {
41+ if (selection.coversFullGraph) return null;
42+ if (selection.customScopes.length > 0) return null;
43+ if (selection.presetIds.length === 0) return null;
44+ if (selection.presetIds.some((presetId) => !microsoftGraphPresetForId(presetId))) return null;
45+ if (selection.presetIds.length === 1) return selection.presetIds[0]!;
46+ const defaultIds = new Set(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS);
47+ if (selection.presetIds.every((presetId) => defaultIds.has(presetId))) {
48+ return MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET;
49+ }
50+ return null;
51+};
52+
53+export const microsoftGraphSliceUrl = (asset: string): string =>
54+ `${MICROSOFT_GRAPH_SLICE_BASE_URL}/${encodeURIComponent(asset)}.yaml`;
55+
56+export const fetchMicrosoftGraphSlice = Effect.fn("Microsoft.fetchGraphSlice")(function* (
57+ asset: string,
58+) {
59+ const client = yield* HttpClient.HttpClient;
60+ const response = yield* client
61+ .execute(
62+ HttpClientRequest.get(microsoftGraphSliceUrl(asset)).pipe(
63+ HttpClientRequest.setHeader("Accept", "application/yaml, text/yaml, */*"),
64+ ),
65+ )
66+ .pipe(
67+ Effect.mapError(
68+ () =>
69+ new OpenApiParseError({
70+ message: `Failed to fetch Microsoft Graph slice: ${asset}`,
71+ }),
72+ ),
73+ );
74+ if (response.status < 200 || response.status >= 300) {
75+ return yield* new OpenApiParseError({
76+ message: `Failed to fetch Microsoft Graph slice ${asset}: HTTP ${response.status}`,
77+ });
78+ }
79+ return yield* response.text.pipe(
80+ Effect.mapError(
81+ () =>
82+ new OpenApiParseError({
83+ message: `Failed to read Microsoft Graph slice body: ${asset}`,
84+ }),
85+ ),
86+ );
87+});
packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.test.ts+75−0
@@ -6,6 +6,7 @@ import { structuralSplit } from "@executor-js/plugin-openapi"; · 5 unmodified lines
66 import { previewSpecTextStreaming } from "../../sdk/preview";
77 import { microsoftGraphAdapter } from "./spec-format-adapter";
88 import { MICROSOFT_GRAPH_OPENAPI_URL } from "./presets";
9+import { microsoftGraphSliceUrl } from "./slices";
910
1011 const graphFixture = `
1112 openapi: 3.0.4
@@ -92,6 +93,80 @@ it.effect("uses catalog URL fragments to select one Graph workload", () => · 80 unmodified lines
9293 }),
9394 );
9495
96+// Distinct content at the slice URL so tests can tell which source was read.
97+const sliceFixture = `openapi: 3.0.4
98+info:
99+ title: Microsoft Graph Slice Fixture
100+ version: v1.0
101+servers:
102+ - url: https://graph.microsoft.com/v1.0
103+paths:
104+ /me:
105+ get:
106+ operationId: me.GetUser
107+ security:
108+ - azureAdDelegated:
109+ - User.Read
110+ responses:
111+ "200":
112+ description: OK
113+components:
114+ securitySchemes:
115+ azureAdDelegated:
116+ type: oauth2
117+ flows:
118+ authorizationCode:
119+ authorizationUrl: https://login.microsoftonline.com/common/oauth2/v2.0/authorize
120+ tokenUrl: https://login.microsoftonline.com/common/oauth2/v2.0/token
121+ scopes:
122+ User.Read: Read user profile
123+`;
124+
125+const sliceAwareHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
126+ HttpClient.make((request: HttpClientRequest.HttpClientRequest) => {
127+ const body =
128+ request.url === microsoftGraphSliceUrl("profile")
129+ ? sliceFixture
130+ : request.url === MICROSOFT_GRAPH_OPENAPI_URL
131+ ? graphFixture
132+ : null;
133+ return Effect.succeed(
134+ HttpClientResponse.fromWeb(
135+ request,
136+ new Response(body ?? "not found", { status: body === null ? 404 : 200 }),
137+ ),
138+ );
139+ }),
140+);
141+
142+it.effect("reads the published slice for a covered selection", () =>
143+ Effect.gen(function* () {
144+ const converted = yield* microsoftGraphAdapter.fetch({
145+ urls: [`${MICROSOFT_GRAPH_OPENAPI_URL}#preset=profile`],
146+ httpClientLayer: sliceAwareHttpClientLayer,
147+ });
148+
149+ expect(converted.specText).toBe(sliceFixture);
150+ // The catalog URL (with fragment stripped) stays canonical so refresh
151+ // re-resolves through the adapter, not the slice hosting.
152+ expect(converted.specUrl).toBe(MICROSOFT_GRAPH_OPENAPI_URL);
153+ }),
154+);
155+
156+it.effect("falls back to the monolith when the slice asset is unavailable", () =>
157+ Effect.gen(function* () {
158+ // graphHttpClientLayer 404s everything except the monolith URL, including
159+ // the slice URL — the existing selection tests above exercise this same
160+ // fallback implicitly.
161+ const converted = yield* microsoftGraphAdapter.fetch({
162+ urls: [`${MICROSOFT_GRAPH_OPENAPI_URL}#preset=profile`],
163+ httpClientLayer: graphHttpClientLayer,
164+ });
165+
166+ expect(converted.specText).toBe(graphFixture);
167+ }),
168+);
169+
95170 it.effect("stream-previews a Graph selection without a whole-document parse", () =>
96171 Effect.gen(function* () {
97172 const converted = yield* microsoftGraphAdapter.fetch({