Unified · Split
| @@ -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. | |
| @@ -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 | |
| @@ -64,6 +64,7 @@ · 63 unmodified lines | ||
| 64 | 64 | "apps/desktop/src/main.ts", |
| 65 | 65 | "scripts/**/*.{ts,js}", |
| 66 | 66 | "apps/*/scripts/**/*.{ts,js}", |
| 67 | + "packages/*/*/scripts/**/*.{ts,js}", | |
| 67 | 68 | "packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}", |
| 68 | 69 | ], |
| 69 | 70 | "rules": { |
| @@ -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}`); | |
| @@ -15,6 +15,7 @@ import { · 14 unmodified lines | ||
| 15 | 15 | } from "../../sdk/split"; |
| 16 | 16 | import type { Authentication } from "../../sdk/types"; |
| 17 | 17 | |
| 18 | +import { fetchMicrosoftGraphSlice, microsoftGraphSliceAssetForSelection } from "./slices"; | |
| 18 | 19 | import { |
| 19 | 20 | MICROSOFT_AUTHORIZATION_URL, |
| 20 | 21 | MICROSOFT_AUTH_TEMPLATE_SLUG, |
| @@ -759,9 +760,28 @@ export const buildMicrosoftGraphOpenApiSpec = ( · 738 unmodified lines | ||
| 759 | 760 | ): Effect.Effect<MicrosoftGraphSpecBuild, OpenApiParseError> => |
| 760 | 761 | Effect.gen(function* () { |
| 761 | 762 | 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 | + ); | |
| 765 | 785 | |
| 766 | 786 | // Structural split is the only entry point: parsing the whole 37MB tree |
| 767 | 787 | // OOMs the 128MB Workers isolate (measured: HTTP 503). No fallback. A spec |
| @@ -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 | +}); | |
| @@ -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 | +}; | |
| @@ -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 | +}); | |
| @@ -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 | +}); | |
| @@ -6,6 +6,7 @@ import { structuralSplit } from "@executor-js/plugin-openapi"; · 5 unmodified lines | ||
| 6 | 6 | import { previewSpecTextStreaming } from "../../sdk/preview"; |
| 7 | 7 | import { microsoftGraphAdapter } from "./spec-format-adapter"; |
| 8 | 8 | import { MICROSOFT_GRAPH_OPENAPI_URL } from "./presets"; |
| 9 | +import { microsoftGraphSliceUrl } from "./slices"; | |
| 9 | 10 | |
| 10 | 11 | const graphFixture = ` |
| 11 | 12 | openapi: 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. | |
| 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 | + | |
| 95 | 170 | it.effect("stream-previews a Graph selection without a whole-document parse", () => |
| 96 | 171 | Effect.gen(function* () { |
| 97 | 172 | const converted = yield* microsoftGraphAdapter.fetch({ |