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
5Serve 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.
8name: Graph slices
9
10on:
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
17permissions:
18 contents: write
19
20jobs:
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
64 "apps/desktop/src/main.ts",64 "apps/desktop/src/main.ts",
65 "scripts/**/*.{ts,js}",65 "scripts/**/*.{ts,js}",
66 "apps/*/scripts/**/*.{ts,js}",66 "apps/*/scripts/**/*.{ts,js}",
67 "packages/*/*/scripts/**/*.{ts,js}",
67 "packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}",68 "packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}",
68 ],69 ],
69 "rules": {70 "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 */
16import { createHash } from "node:crypto";
17import { mkdir, readFile, writeFile } from "node:fs/promises";
18import { join } from "node:path";
19
20import { structuralSplit } from "../src/sdk/split";
21import {
22 MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,
23 MICROSOFT_GRAPH_OPENAPI_URL,
24 microsoftGraphScopePresets,
25} from "../src/providers/microsoft/presets";
26import { MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET } from "../src/providers/microsoft/slices";
27import {
28 buildGraphSliceDocument,
29 parseGraphSourceDocument,
30} from "../src/providers/microsoft/slice-build";
31
32const argValue = (flag: string): string | undefined => {
33 const index = process.argv.indexOf(flag);
34 return index !== -1 ? process.argv[index + 1] : undefined;
35};
36
37const source = argValue("--source") ?? MICROSOFT_GRAPH_OPENAPI_URL;
38const outDir = argValue("--out") ?? "graph-slices-out";
39
40const 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
51const sourceText = await readSource();
52const sourceSha256 = createHash("sha256").update(sourceText).digest("hex");
53const doc = parseGraphSourceDocument(sourceText);
54if (!doc) {
55 throw new Error("Microsoft Graph source did not parse to an object");
56}
57
58const 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
66await mkdir(outDir, { recursive: true });
67
68const manifestAssets: Record<
69 string,
70 {
71 readonly bytes: number;
72 readonly paths: number;
73 readonly operations: number;
74 readonly schemas: number;
75 }
76> = {};
77
78for (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
105await writeFile(
106 join(outDir, "manifest.json"),
107 `${JSON.stringify({ source, sourceSha256, generatedAt: new Date().toISOString(), assets: manifestAssets }, null, 2)}\n`,
108);
109console.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
15} from "../../sdk/split";15} from "../../sdk/split";
16import type { Authentication } from "../../sdk/types";16import type { Authentication } from "../../sdk/types";
17 17
18import { fetchMicrosoftGraphSlice, microsoftGraphSliceAssetForSelection } from "./slices";
18import {19import {
19 MICROSOFT_AUTHORIZATION_URL,20 MICROSOFT_AUTHORIZATION_URL,
20 MICROSOFT_AUTH_TEMPLATE_SLUG,21 MICROSOFT_AUTH_TEMPLATE_SLUG,
@@ -759,9 +760,28 @@ export const buildMicrosoftGraphOpenApiSpec = ( · 738 unmodified lines
759): Effect.Effect<MicrosoftGraphSpecBuild, OpenApiParseError> =>760): Effect.Effect<MicrosoftGraphSpecBuild, OpenApiParseError> =>
760 Effect.gen(function* () {761 Effect.gen(function* () {
761 const selection = yield* validateSelectionUrls(normalizeSelection(input), urlPolicy);762 const selection = yield* validateSelectionUrls(normalizeSelection(input), urlPolicy);
762 const sourceText = yield* fetchMicrosoftGraphOpenApiSpec(selection.specUrl).pipe(763 // Covered selections read a precomputed slice (sub-MB) instead of the 43MB
763 Effect.provide(httpClientLayer),764 // monolith: in production, the monolith fetch alone almost never survives
764 );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 );
765 785
766 // Structural split is the only entry point: parsing the whole 37MB tree786 // Structural split is the only entry point: parsing the whole 37MB tree
767 // OOMs the 128MB Workers isolate (measured: HTTP 503). No fallback. A spec787 // 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 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { structuralSplit } from "../../sdk/split";
4import { 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.
8const source = `openapi: 3.0.4
9info:
10 title: Microsoft Graph Fixture
11 version: v1.0
12servers:
13 - url: https://graph.microsoft.com/v1.0
14paths:
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'
40components:
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
76describe("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 @@
1import { JSON_SCHEMA, dump as dumpYamlDocument, load as parseYamlDocument } from "js-yaml";
2
3import { microsoftGraphKeepPathItem } from "./graph";
4import {
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. */
23const SMALL_COMPONENT_SECTIONS = [
24 "parameters",
25 "requestBodies",
26 "responses",
27 "headers",
28 "links",
29 "securitySchemes",
30] as const;
31
32const COMPONENT_REF_PREFIX = "#/components/";
33
34const isRecord = (value: unknown): value is Record<string, unknown> =>
35 value !== null && typeof value === "object" && !Array.isArray(value);
36
37const decodeRefSegment = (segment: string): string =>
38 segment.replace(/~1/g, "/").replace(/~0/g, "~");
39
40interface ComponentRef {
41 readonly section: string;
42 readonly name: string;
43}
44
45const 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 */
74const 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
101export 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
110const HTTP_METHODS = new Set(["delete", "get", "head", "options", "patch", "post", "put", "trace"]);
111
112const 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 */
129export 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. */
190export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { MICROSOFT_GRAPH_ALL_PRESET_IDS, MICROSOFT_GRAPH_DEFAULT_PRESET_IDS } from "./presets";
4import {
5 MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET,
6 microsoftGraphSliceAssetForSelection,
7} from "./slices";
8
9describe("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 @@
1import { Effect } from "effect";
2import { HttpClient, HttpClientRequest } from "effect/unstable/http";
3
4import { OpenApiParseError } from "../../sdk/errors";
5
6import { 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
19export const MICROSOFT_GRAPH_SLICE_RELEASE_TAG = "graph-slices";
20
21export 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`). */
24export 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 */
36export 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
53export const microsoftGraphSliceUrl = (asset: string): string =>
54 `${MICROSOFT_GRAPH_SLICE_BASE_URL}/${encodeURIComponent(asset)}.yaml`;
55
56export 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
6import { previewSpecTextStreaming } from "../../sdk/preview";6import { previewSpecTextStreaming } from "../../sdk/preview";
7import { microsoftGraphAdapter } from "./spec-format-adapter";7import { microsoftGraphAdapter } from "./spec-format-adapter";
8import { MICROSOFT_GRAPH_OPENAPI_URL } from "./presets";8import { MICROSOFT_GRAPH_OPENAPI_URL } from "./presets";
9import { microsoftGraphSliceUrl } from "./slices";
9 10
10const graphFixture = `11const graphFixture = `
11openapi: 3.0.412openapi: 3.0.4
@@ -92,6 +93,80 @@ it.effect("uses catalog URL fragments to select one Graph workload", () => · 80 unmodified lines
92 }),93 }),
93);94);
94 95
96// Distinct content at the slice URL so tests can tell which source was read.
97const sliceFixture = `openapi: 3.0.4
98info:
99 title: Microsoft Graph Slice Fixture
100 version: v1.0
101servers:
102 - url: https://graph.microsoft.com/v1.0
103paths:
104 /me:
105 get:
106 operationId: me.GetUser
107 security:
108 - azureAdDelegated:
109 - User.Read
110 responses:
111 "200":
112 description: OK
113components:
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
125const 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
142it.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
156it.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
95it.effect("stream-previews a Graph selection without a whole-document parse", () =>170it.effect("stream-previews a Graph selection without a whole-document parse", () =>
96 Effect.gen(function* () {171 Effect.gen(function* () {
97 const converted = yield* microsoftGraphAdapter.fetch({172 const converted = yield* microsoftGraphAdapter.fetch({