From e3b3845ab9e04d059466682b44d4ce1c62d14dd3 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Fri, 31 Jul 2026 22:01:58 +0200 Subject: [PATCH 1/6] Modernize generator architecture and runtime safety --- .github/workflows/ci.yml | 33 +- .gitignore | 1 + CHANGELOG.md | 12 + Makefile | 8 +- cli/Cli.res | 53 +- cli/ErrorPrinter.res | 10 +- cli/Lsp.res | 42 +- cli/LspCompleteGraphQL.res | 14 +- cli/Utils.res | 54 +- docs/docs/architecture-modernization-plan.md | 936 +++++++++++++++++++ docs/docs/architecture.md | 124 +++ docs/docs/multiple-schemas.md | 3 + package-lock.json | 736 ++++++++++++++- package.json | 24 +- resgraph.schema.json | 100 ++ scripts/test-package.mjs | 178 ++++ scripts/verify-release.mjs | 26 + src/ml/ArchitectureTests.ml | 135 +++ src/ml/Cli.ml | 88 +- src/ml/GenerateSchemaDirect.ml | 115 +-- src/ml/GenerateSchemaTypePrinters.ml | 8 +- src/ml/GenerateSchemaUtils.ml | 61 +- src/ml/GenerateSchemaValidation.ml | 75 ++ src/ml/GenerationContext.ml | 43 + src/ml/GenerationContext.mli | 10 + src/ml/dune | 25 +- src/res/DataLoader.mjs | 32 + src/res/DataLoader.res | 41 + src/res/DataLoader.resi | 22 +- src/res/GraphQLYoga.res | 8 +- src/res/ResGraph.mjs | 57 +- src/res/ResGraph.res | 114 ++- src/res/ResGraph__ExecuteRuntime.mjs | 53 ++ src/res/stableStringify.mjs | 5 +- tests/Makefile | 4 +- tests/multi-schema-test.sh | 4 +- tests/runtime-bindings.mjs | 79 ++ tests/test.sh | 56 ++ vscode-extension/package-lock.json | 4 +- vscode-extension/package.json | 9 +- vscode-extension/src/extension.ts | 42 +- vscode-extension/{src => }/tsconfig.json | 2 +- 42 files changed, 3179 insertions(+), 267 deletions(-) create mode 100644 docs/docs/architecture-modernization-plan.md create mode 100644 docs/docs/architecture.md create mode 100644 resgraph.schema.json create mode 100644 scripts/test-package.mjs create mode 100644 scripts/verify-release.mjs create mode 100644 src/ml/ArchitectureTests.ml create mode 100644 src/ml/GenerationContext.ml create mode 100644 src/ml/GenerationContext.mli create mode 100644 src/res/ResGraph__ExecuteRuntime.mjs create mode 100644 tests/runtime-bindings.mjs rename vscode-extension/{src => }/tsconfig.json (92%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da4afbb4..0c0f4f1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,13 +47,13 @@ jobs: git config --global core.autocrlf false git config --global core.eol lf - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Cache OCaml's opam - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.opam - key: ${{matrix.os}}-rescript-vscode-v4 + key: ${{ runner.os }}-${{ runner.arch }}-ocaml-5.3.0-${{ hashFiles('dune-project', 'resgraph.opam') }} - name: Use OCaml uses: ocaml/setup-ocaml@v3 @@ -81,6 +81,13 @@ jobs: if: matrix.os == 'ubuntu-22.04' run: opam exec -- make checkformat + - name: Typecheck and bundle VS Code extension + if: matrix.os == 'ubuntu-22.04' + working-directory: vscode-extension + run: | + npm ci + npm run build + - name: Verify generated outputs are clean run: | status=$(git status --porcelain) @@ -112,7 +119,9 @@ jobs: id-token: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Use Node.js uses: actions/setup-node@v4 @@ -156,16 +165,16 @@ jobs: run: rm binary.tar working-directory: ./bin - - name: Store short commit SHA for filename - id: vars - env: - COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - run: echo "::set-output name=sha_short::${COMMIT_SHA:0:7}" + - name: Test packed package as a consumer + run: npm run test:package - - name: Store tag name - id: tag_name + - name: Verify release metadata and main ancestry if: startsWith(github.ref, 'refs/tags/') - run: echo ::set-output name=tag::${GITHUB_REF#refs/*/} + run: | + node scripts/verify-release.mjs "$GITHUB_REF_NAME" + git fetch origin main + git merge-base --is-ancestor HEAD origin/main + - name: Package release run: npm pack diff --git a/.gitignore b/.gitignore index 3ef38bff..b5549f6d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ tests/lib tests/**/*.mjs !tests/runtime-interface-returns.mjs +!tests/runtime-bindings.mjs tests/node_modules tests/.bsb.lock _build diff --git a/CHANGELOG.md b/CHANGELOG.md index 005273a2..e808727b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,18 @@ # ResGraph Changelog ## Unreleased +- Validate operations before execution, support synchronous GraphQL.js results, + reject malformed variables, and avoid redundant query hashing. +- Add correct keyed DataLoader priming, per-entry `loadMany` results, and + order-preserving structural cache keys while retaining existing APIs. +- Preserve last-known-good generated schemas on validation failures and use + atomic writes for generated files and native state. +- Add a packed-package consumer test, native architecture fixtures, strict + integration failure propagation, release guards, and VS Code extension checks. +- Extract the native generator into a wrapped engine library with a reusable + generation context; batch schemas by compiler root to share CMT summaries + while retaining per-schema results; and publish architecture and + configuration-schema docs. ## 1.3.0 diff --git a/Makefile b/Makefile index ef2753d2..dae95f4d 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,11 @@ SHELL = /bin/bash build-resgraph-binary: + rm -f bin/dev/resgraph.exe + dune build --profile release + cp _build/install/default/bin/resgraph bin/dev/resgraph.exe + +build-resgraph-binary-dev: rm -f bin/dev/resgraph.exe dune build cp _build/install/default/bin/resgraph bin/dev/resgraph.exe @@ -25,11 +30,10 @@ clean: rm -f bin/dev/resgraph.exe dune clean make -C tests clean - make -C reanalyze clean checkformat: dune build @fmt .DEFAULT_GOAL := build -.PHONY: build-resgraph-binary build-tests dce clean format test +.PHONY: build-resgraph-binary build-resgraph-binary-dev build-tests dce clean format test diff --git a/cli/Cli.res b/cli/Cli.res index 31421588..13174688 100644 --- a/cli/Cli.res +++ b/cli/Cli.res @@ -32,7 +32,7 @@ let printAuthorizationBaselineWarning = (authorization: option { let buildSchemas = (config: Utils.config, schemas: array) => { let showSchemaName = !config.legacy || schemas->Array.length > 1 let hadError = ref(false) + let schemasByCompilerRoot: Dict.t> = Dict.make() schemas->Array.forEach(schema => { + let compilerRoot = + schema.projectRoot->Utils.findCompilerRoot->Option.getOr(schema.projectRoot)->Utils.canonicalPath + switch schemasByCompilerRoot->Dict.get(compilerRoot) { + | Some(group) => group->Array.push(schema) + | None => schemasByCompilerRoot->Dict.set(compilerRoot, [schema]) + } + }) + + schemasByCompilerRoot->Dict.toArray->Array.forEach(((_compilerRoot, group)) => { let timeStart = performance->now try { - switch Utils.callPrivateCli(GenerateSchema(schema)) { - | Completion(_) | Hover(_) | Definition(_) | FindDefinition(_) | NotInitialized => () - | Success(_) => - printBuildTime(schema, performance->now -. timeStart, ~showSchemaName) - printAuthorizationBaselineWarning(schema.authorization) - | Error({errors}) => - if showSchemaName { - Console.error(`[${schema.name}] Schema generation failed.`) - } - ErrorPrinter.printErrors(errors) - hadError := true + let results = Utils.callPrivateCliBatch(group) + if results->Array.length !== group->Array.length { + panic("Native batch response did not match the requested schema count.") } + results->Array.forEachWithIndex((result, index) => { + let schema = group[index]->Option.getOrThrow(~message="Missing schema for batch result.") + switch result { + | Completion(_) | Hover(_) | Definition(_) | FindDefinition(_) | NotInitialized => () + | Success(_) => + printBuildTime(schema, performance->now -. timeStart, ~showSchemaName) + printAuthorizationBaselineWarning(schema.authorization) + | Error({errors}) => + if showSchemaName { + Console.error(`[${schema.name}] Schema generation failed.`) + } + ErrorPrinter.printErrors(errors) + hadError := true + } + }) } catch { | Exn.Error(error) => - Console.error(`[${schema.name}] Generator process failed.`) + group->Array.forEach(schema => + Console.error(`[${schema.name}] Generator process failed.`) + ) Console.error(error) hadError := true | _ => - Console.error(`[${schema.name}] Generator process failed.`) + group->Array.forEach(schema => + Console.error(`[${schema.name}] Generator process failed.`) + ) hadError := true } }) @@ -316,9 +337,11 @@ try { Process.process->Process.exitWithCode(1) } | list{"help"} => Console.log(helpText) + | list{"tools"} => Console.log(toolsHelpText) | value => - Console.log("Invalid command: " ++ value->List.toArray->Array.join(" ")) + Console.error("Invalid command: " ++ value->List.toArray->Array.join(" ")) Console.log(helpText) + Process.process->Process.exitWithCode(1) } } catch { | Exn.Error(error) => diff --git a/cli/ErrorPrinter.res b/cli/ErrorPrinter.res index cc73d54e..63e87623 100644 --- a/cli/ErrorPrinter.res +++ b/cli/ErrorPrinter.res @@ -33,8 +33,8 @@ let prettyPrintDiagnostic = (~lines, ~diagnostic: Utils.generateError) => { `${colors.red("Error in file:")} ${colors.blueBright( diagnostic.file, - )}:${fileLocText}`->Console.log - Console.log("\n") + )}:${fileLocText}`->Console.error + Console.error("\n") lines->Array.forEachWithIndex((line, index) => { if index > diagnostic.range.start.line - 5 && index < diagnostic.range.end_.line + 5 { let highlightOnThisLine = @@ -60,18 +60,18 @@ let prettyPrintDiagnostic = (~lines, ~diagnostic: Utils.generateError) => { ) ++ line->String.slice(~start=highlightEndOffset) - Console.log( + Console.error( ` ${modifiers.bold.red( Int.toString(index + 1), )} ${colors.blackBright(`┆`)} ${lineText}`, ) } else { - Console.log(` ${Int.toString(index + 1)} ${colors.blackBright(`┆`)} ${line}`) + Console.error(` ${Int.toString(index + 1)} ${colors.blackBright(`┆`)} ${line}`) } } }) - Console.log("\n " ++ diagnostic.message) + Console.error("\n " ++ diagnostic.message) } let printErrors = (errors: array) => { diff --git a/cli/Lsp.res b/cli/Lsp.res index c0728823..a86faa81 100644 --- a/cli/Lsp.res +++ b/cli/Lsp.res @@ -1,6 +1,16 @@ // This file holds the actual language server implementation. @module("url") external fileURLToPath: string => string = "fileURLToPath" +type fileUrl = {href: string} + +@module("url") external pathToFileURL: string => fileUrl = "pathToFileURL" + +let ensureFileUri = path => + if path->String.startsWith("file://") { + path + } else { + pathToFileURL(path).href + } let initialized = ref(false) let shutdownRequestAlreadyReceived = ref(false) @@ -371,7 +381,7 @@ let start = (~mode, ~configFilePath) => { ->Dict.toArray ->Array.map(((file, errors)) => { PublishDiagnostics({ - uri: file, + uri: file->ensureFileUri, diagnostics: errors->Array.map(error => { let diagnostic: LspProtocol.diagnostic = { range: error.range, @@ -383,14 +393,14 @@ let start = (~mode, ~configFilePath) => { }) ->Message.Notification.asMessage ->send - file + file->ensureFileUri }) filesWithDiagnostics := currentFilesWithDiagnostics filesWithDiagnosticsAtLastPublish->Array.forEach(fileName => { if !(currentFilesWithDiagnostics->Array.includes(fileName)) { - PublishDiagnostics({uri: fileName, diagnostics: []}) + PublishDiagnostics({uri: fileName->ensureFileUri, diagnostics: []}) ->Message.Notification.asMessage ->send } @@ -405,7 +415,9 @@ let start = (~mode, ~configFilePath) => { | Utils.GeneratorResult(res) => currentResults->Dict.set(schema.name, res) publishDiagnostics() - | Utils.GeneratorProcessFailure => () + | Utils.GeneratorProcessFailure => + currentResults->Dict.delete(schema.name) + publishDiagnostics() }, ~config=schema, ) @@ -414,13 +426,13 @@ let start = (~mode, ~configFilePath) => { let openedFile = (uri, text) => { log(`opened ${uri}`) switch uri->Path.extname { - | ".res" => resFilesCache->Dict.set(uri, text) + | ".res" | ".resi" | ".graphql" => resFilesCache->Dict.set(uri, text) | _ => () } } let updateOpenedFile = (uri, text) => { - if uri->Path.extname === ".res" { + if [".res", ".resi", ".graphql"]->Array.includes(uri->Path.extname) { switch resFilesCache->Dict.get(uri)->Option.isSome { | true => resFilesCache->Dict.set(uri, text) | false => () @@ -527,6 +539,7 @@ let start = (~mode, ~configFilePath) => { ->Option.flatMap(schema => schema.stateName) let result = switch LspCompleteGraphQL.hoverAtPos( ~path=filePath, + ~text=?resFilesCache->Dict.get(params.textDocument.uri), ~pos=params.position, ~stateName, ) { @@ -558,6 +571,7 @@ let start = (~mode, ~configFilePath) => { ->Option.flatMap(schema => schema.stateName) let result = switch LspCompleteGraphQL.definitionAtPos( ~path=filePath, + ~text=?resFilesCache->Dict.get(params.textDocument.uri), ~pos=params.position, ~stateName, ) { @@ -583,18 +597,18 @@ let start = (~mode, ~configFilePath) => { ->send | Some(code) => let filePath = params.textDocument.uri->fileURLToPath - let tmpname = Utils.createFileInTempDir() - Fs.writeFileSyncWith(tmpname, Buffer.fromString(code), {encoding: "utf-8"}) let stateName = config ->Utils.schemaForFile(filePath) ->Option.flatMap(schema => schema.stateName) - let result = switch Utils.callPrivateCli( - Completion({filePath, position: params.position, tmpname, ?stateName}), - ) { - | Completion({items}) => Message.Result.fromCompletionItems(items) - | _ => Message.Result.null() - } + let result = Utils.withTemporaryFile(~contents=code, tmpname => + switch Utils.callPrivateCli( + Completion({filePath, position: params.position, tmpname, ?stateName}), + ) { + | Completion({items}) => Message.Result.fromCompletionItems(items) + | _ => Message.Result.null() + } + ) Message.Response.make(~id=msg->Message.getId, ~result, ()) ->Message.Response.asMessage ->send diff --git a/cli/LspCompleteGraphQL.res b/cli/LspCompleteGraphQL.res index f7c93573..f51d0004 100644 --- a/cli/LspCompleteGraphQL.res +++ b/cli/LspCompleteGraphQL.res @@ -131,9 +131,12 @@ let findLookupValue = token => { } } -let hoverAtPos = (~path, ~pos: LspProtocol.loc, ~stateName) => { +let hoverAtPos = (~path, ~text=?, ~pos: LspProtocol.loc, ~stateName) => { try { - let fileContents = Fs.readFileSync(path)->Buffer.toStringWithEncoding(StringEncoding.utf8) + let fileContents = switch text { + | Some(text) => text + | None => Fs.readFileSync(path)->Buffer.toStringWithEncoding(StringEncoding.utf8) + } switch getTokenAtPosition( ~queryText=fileContents, @@ -160,9 +163,12 @@ let hoverAtPos = (~path, ~pos: LspProtocol.loc, ~stateName) => { } } -let definitionAtPos = (~path, ~pos: LspProtocol.loc, ~stateName) => { +let definitionAtPos = (~path, ~text=?, ~pos: LspProtocol.loc, ~stateName) => { try { - let fileContents = Fs.readFileSync(path)->Buffer.toStringWithEncoding(StringEncoding.utf8) + let fileContents = switch text { + | Some(text) => text + | None => Fs.readFileSync(path)->Buffer.toStringWithEncoding(StringEncoding.utf8) + } switch getTokenAtPosition( ~queryText=fileContents, diff --git a/cli/Utils.res b/cli/Utils.res index 964cee45..caf0262f 100644 --- a/cli/Utils.res +++ b/cli/Utils.res @@ -178,6 +178,7 @@ type callResult = external toCallResult: string => callResult = "JSON.parse" +external toCallResults: string => array = "JSON.parse" external infinity: int = "Infinity" let devBinLocation = "../bin/dev/resgraph.exe" @@ -187,9 +188,8 @@ let hasDevBin = Lazy.make(() => (devBinLocation->makeUrl(currentFileUrl)).pathna @module("node:os") external arch: unit => string = "arch" -let callPrivateCli = command => { +let privateCliPath = () => { let hasDevBin = hasDevBin->Lazy.get - let binLocation = if hasDevBin { devBinLocation } else { @@ -199,13 +199,29 @@ let callPrivateCli = command => { | (platform, _) => platform } ++ "/resgraph.exe" } - (binLocation->makeUrl(currentFileUrl)).pathname +} + +let callPrivateCli = command => { + privateCliPath() ->ChildProcess.execFileSyncWith(command->privateCliCallToArgs, {maxBuffer: infinity}) ->Buffer.toString ->toCallResult } +let callPrivateCliBatch = (schemas: array) => { + let calls = schemas->Array.flatMap(schema => { + let args = GenerateSchema(schema)->privateCliCallToArgs + [args->Array.length->Int.toString, ...args] + }) + let args = ["generate-schemas-v1", ...calls] + + privateCliPath() + ->ChildProcess.execFileSyncWith(args, {maxBuffer: infinity}) + ->Buffer.toString + ->toCallResults +} + let formatFindDefinitionText = (item: findDefinitionItem) => { let {path, kind, file, range: {start, end_}} = item @@ -321,6 +337,9 @@ let setupWatcher = (~onResult, ~onStartRebuild, ~config: schemaConfig) => { let compilerWatcher = watcher ->watch(compilerLogPath) + ->Watcher.onAdd(compilerLogPath => { + generateSchema->runIfCompilerDone(~compilerLogPath, ~lastCompletedBuild) + }) ->Watcher.onChange(compilerLogPath => { generateSchema->runIfCompilerDone(~compilerLogPath, ~lastCompletedBuild) }) @@ -342,6 +361,35 @@ let createFileInTempDir = (~extension="") => { Path.join([Os.tmpdir(), tempFileName]) } +let removeFileIfExists = path => { + if Fs.existsSync(path) { + try { + Fs.unlinkSync(path) + } catch { + | _ => () + } + } +} + +let rethrow: 'error => 'value = %raw(`error => { throw error }`) + +let withTemporaryFile = (~contents, callback) => { + let path = createFileInTempDir() + Fs.writeFileSyncWith(path, Buffer.fromString(contents), {encoding: "utf-8"}) + try { + let result = callback(path) + removeFileIfExists(path) + result + } catch { + | Exn.Error(error) => + removeFileIfExists(path) + rethrow(error) + | _ => + removeFileIfExists(path) + panic("Unknown failure while using a ResGraph temporary file.") + } +} + let parseOptionalString = (dict, key) => switch dict->Dict.get(key) { | None => Some(None) diff --git a/docs/docs/architecture-modernization-plan.md b/docs/docs/architecture-modernization-plan.md new file mode 100644 index 00000000..90aa8b99 --- /dev/null +++ b/docs/docs/architecture-modernization-plan.md @@ -0,0 +1,936 @@ +# ResGraph Architecture Modernization Plan + +Status: Active; first safety and boundary milestone implemented +Reviewed revision: `dc1647b3f73a829b76aaf4399769ff18492a602b` (`v1.3.0`) +Review date: 2026-07-31 + +## Executive summary + +ResGraph's fundamental design is sound and should be retained: + +- ReScript compiler artifacts are the source of schema information. +- Native OCaml performs compiler-aware discovery and schema generation. +- ReScript/Node owns workspace orchestration, watch behavior, and editor transport. +- The generated schema uses `graphql-js` and exposes a small public ReScript surface. +- Each configured schema has independent outputs, diagnostics, and cache state. + +The main architectural problem is concentration and coupling. Compiler ingestion, +declaration discovery, type resolution, validation, code generation, +authorization, artifact ownership, persistence, and editor support currently flow +through a few large modules and shared mutable or global state. This makes new +features expensive to add, makes some invalid states possible, and causes schemas +in the same repository to repeat expensive project discovery and CMT work. + +The recommended direction is an incremental redesign around: + +1. A compiler-root-level project index shared by every schema in that root. +2. A typed declaration index and canonical, immutable schema IR. +3. Explicit validation, resolver-planning, and authorization-planning passes. +4. ReScript, SDL, authorization, and tooling emitters that consume only frozen IR. +5. Transactional, manifest-owned generated artifacts. +6. A batch multi-schema engine coordinated by the ReScript CLI. +7. Compatibility, correctness, packaging, and performance gates for every stage. + +This is not a proposal for a wholesale rewrite. The existing implementation +should be moved behind explicit boundaries, characterized, and replaced one phase +at a time. Mutable builders and hash tables should remain where they are the +fastest implementation; immutability is required at phase boundaries, not as an +ideological constraint inside every algorithm. + +## Goals + +- Retain every currently supported feature and public compatibility surface. +- Make it straightforward to add GraphQL features without editing several + unrelated string printers and mutable registries. +- Improve multi-schema cold and watch performance by sharing compiler work. +- Preserve or improve single-schema cold, warm, and runtime performance. +- Make invalid schema states unrepresentable where practical and diagnose the + remainder before generated GraphQL code is loaded. +- Make generation interruption- and failure-safe. +- Establish explicit ownership for generated files, caches, and editor indexes. +- Isolate ReScript compiler internals behind a narrow adapter. +- Make tests, release artifacts, documentation, and performance measurements + reliable enough to support continuous development. + +## Non-goals + +- Rewriting the native generator in ReScript. +- Replacing the current code generator in one large change. +- Immediately splitting ResGraph into several npm packages. +- Adding a general plugin framework before a second real extension requires one. +- Parallelizing schema construction while generator state remains global. +- Adding a persistent native daemon before measurements show that process startup + is a meaningful editor bottleneck. +- Trading predictable performance for a maximally pure internal implementation. + +## Baseline and review validation + +The review was performed in a clean worktree created from the latest +`origin/main` at the revision above. No production code was changed during the +review. + +The following baseline checks were performed: + +- Root ReScript compilation and CLI bundling succeeded. +- Cache, runtime, interface-validation, multi-schema, and authorization fixtures + passed with the published v1.3.0 Linux native binary built from the same + revision. +- A synchronous `ResGraph.Execute` query reproduced + `execute(...).then is not a function`, confirming the `PromiseOrValue` binding + issue. +- A clean packed-package consumer reproduced missing or accidental dependency + behavior for package-root, DataLoader, and GraphQL imports. +- The tracked worktree remained clean after validation. + +The native binary was not rebuilt locally because the review environment had no +selected opam switch. Before implementation starts, CI or a configured native +development environment should record a freshly built baseline from this exact +revision. + +The current integration runner is not a fully trustworthy gate: +[`tests/test.sh`](../../tests/test.sh) does not enable strict shell failure handling, +so a failed runtime or interface-validation suite can be hidden by a later +successful command. Repairing this is the first prerequisite for structural work. + +## Current architecture and primary constraints + +The current native flow is approximately: + +```text +CLI positional arguments + | + v +cache check and project/package discovery + | + v +source scan and CMT/CMTI loading + | + v +recursive type discovery + mutable declaration registration + | + v +interface inference/inheritance + validation + | + v +authorization planning + | + v +cleanup + interface/state/SDL/schema/auth/cache writes +``` + +The concentration is visible in the largest native modules: + +- [`GenerateSchema.ml`](../../src/ml/GenerateSchema.ml): type mapping, recursive + discovery, materialization, and resolver traversal. +- [`GenerateSchemaUtils.ml`](../../src/ml/GenerateSchemaUtils.ml): registration, + source parsing, interface handling, validation helpers, conversions, + diagnostics, state persistence, and filesystem I/O. +- [`GenerateSchemaTypePrinters.ml`](../../src/ml/GenerateSchemaTypePrinters.ml): + runtime construction, generated helpers, interface modules, output cleanup, and + writes. +- [`GenerateSchemaAuthorization.ml`](../../src/ml/GenerateSchemaAuthorization.ml): + semantic planning, signature analysis, code emission, and filesystem handling. +- [`GenerateSchemaDirect.ml`](../../src/ml/GenerateSchemaDirect.ml): orchestration, + cache policy, global hook installation, error branches, and output sequencing. + +There is one monolithic native executable stanza and effectively no internal +`.mli` boundaries. The large mutable `schemaState` contains declarations, +work-in-progress relationships, diagnostics, source data, and emission metadata. + +The current ReScript/Node flow is approximately: + +```text +resgraph.json + | + v +unchecked/partially normalized config + | + v +one synchronous native process per schema or editor operation + | + v +Node and native code independently reason about artifact ownership +``` + +This repeats project work for schemas sharing a compiler root and duplicates +invariants across the process boundary. + +## Immediate correctness and safety concerns + +These issues should be fixed or characterized before the core refactor. + +### Test runner can report false green + +The root shell runner does not use `set -euo pipefail`. It also mutates fixtures, +configuration, symlinks, dependencies, caches, and generated output without a +single robust cleanup trap. Every suite must independently propagate failure, and +all mutations must be restored on success, failure, and interruption. + +### Artifact writes are non-transactional + +Generated source, signatures, interface helpers, tooling state, SDL, +authorization files, and cache entries are written in sequence. A watcher or LSP +request can observe a mixed generation, and an exception can leave a new state +file paired with old source. + +On schema diagnostics, generation can replace the last-known-good schema with an +invalid `Obj.magic` placeholder. Legacy cleanup also deletes files based on name +patterns without consistently proving generated ownership. + +### Schema registration is order-dependent + +Declaration kinds use independent hash tables. Duplicate same-kind declarations +are often first- or last-wins, while cross-kind GraphQL name collisions can +survive until GraphQL.js processes the emitted schema. + +`addInputUnion` currently checks the regular-union table instead of the +input-union table. A regular union can suppress an input union with the same +internal identity, while duplicate input unions are not detected correctly. + +### Input and output type validity is not represented in the model + +One `graphqlType` representation covers input types, output types, context and +resolve-info injection, interface typename injection, and synthetic sentinels. +Every consumer must remember which constructors are legal in its context. Some +invalid input/output placements and input-union member shapes can therefore reach +the generated schema instead of producing native, source-located diagnostics. + +### Discovery and materialization are interleaved + +Type lookup can resolve aliases, synthesize declarations, register authorization, +mutate schema state, add diagnostics, and recurse into child fields. A second +traversal contains similar object, interface, input, enum, union, and scalar +construction paths. Recursive types depend on incidental insertion order rather +than explicit visiting states. + +### Runtime bindings overpromise safety + +- `DataLoader.prime` and `primeWithPromise` omit the key argument. +- `DataLoader.loadMany` does not represent per-entry errors. +- The deep cache-key serializer sorts arrays, causing order-sensitive keys to + collide. +- `ResGraph.Execute` lets callers select result types without a decoder or other + proof. +- GraphQL execution is bound as always asynchronous even though GraphQL.js returns + `PromiseOrValue`. +- Malformed variables JSON is silently treated as no variables. +- Query caching hashes the query twice on misses and has no size bound. +- Yoga exposes incompatible plugin abstract types. +- Input-union conversion silently selects a member and relies on an Envelop plugin + for zero-or-one enforcement. + +### Persisted editor state is coupled to internal OCaml records + +The generator marshals much of its evolving mutable schema representation. A +manually maintained integer version does not make `Marshal` safe across record or +variant layout changes. Editor operations need a much smaller, explicit data +model. + +### LSP behavior has correctness gaps + +- Diagnostics use filesystem paths where LSP requires file URIs. +- Open `.resi` documents are not retained even though the completer supports them. +- GraphQL hover and definition ignore unsaved text. +- Temporary ReScript completion files are predictable and are not removed. +- Native process failures can leave stale diagnostics. +- Global server state prevents isolated tests and multiple server instances. +- Synchronous native calls block JSON-RPC handling. +- The extension uses only the first workspace folder and starts `npx resgraph`, + which can select or download a version other than the workspace dependency. + +### Packaging and release behavior is not tested as consumed + +Tests compile against repository source rather than a packed tarball. A fresh +consumer currently sees a nonexistent package `main`, missing direct/peer +dependencies, and version resolution outside an explicitly tested compatibility +range. Release binaries are built through the default Dune profile even though a +release-optimized profile exists. + +The package license metadata says ISC while the license file contains MIT text. +Vendored compiler code also needs an explicit provenance and notices document. + +## Target architecture + +```text +ReScript / Node ++----------------------------------------------------------------+ +| Config.Raw -> Decode -> Normalize/Validate -> WorkspacePlan | +| | | +| BuildCoordinator -> CompilerWatcher -> EngineClient | ++-------------------------------------------+--------------------+ + | versioned JSON + v +Native OCaml ++----------------------------------------------------------------+ +| ProjectIndex + SummaryStore, shared per compiler root | +| | | +| v | +| DeclarationIndex -> per-schema Builder/Worklist | +| | | +| v | +| immutable SchemaIR | +| / | \ | +| ResolverPlan validation passes AuthorizationPlan | +| \ | / | +| ReScript / SDL / authorization / editor-index emitters | +| | | +| v | +| ArtifactPlan -> atomic transaction | ++----------------------------------------------------------------+ +``` + +### Dependency rule + +Dependencies flow downward. Compiler types such as `Types.type_expr`, `Path.t`, +`Ctype`, and `Btype` must not escape the compiler adapter into schema IR, +validation, emitters, protocol DTOs, or tooling indexes. + +### Native libraries + +Split the executable into wrapped libraries with explicit `.mli` files: + +#### `resgraph_base` + +- `GraphqlName` +- `PathIdentity` +- `SourceLocation` +- `Diagnostic` +- shared error/result types +- protocol data transfer objects + +#### `resgraph_compiler` + +- `CompilerAdapter` +- `ProjectDiscovery` +- `ProjectIndex` +- `CmtReader` +- immutable `ModuleSummary` +- `SummaryStore` +- `TypeResolver` +- source index needed for supported legacy record-spread syntax + +This is the only library that knows about the vendored ReScript compiler. + +#### `resgraph_schema` + +- collected declarations and provenance +- global GraphQL name registry +- typed builder/worklist +- canonical `SchemaIr` +- `InterfaceGraph` +- validation rule set +- `ResolverPlan` +- `AuthorizationPlan` +- value representation and codec planning + +#### `resgraph_emit` + +- ReScript emitter +- SDL emitter +- authorization emitter +- compact `EditorIndex` emitter +- `OutputPlan` +- atomic `ArtifactTransaction` + +#### `resgraph_service` + +- `WorkspaceSession` +- multi-schema build coordination +- invalidation and cache coordination +- profiling counters +- per-schema result isolation + +#### `resgraph_cli` + +A thin executable responsible for protocol decoding, dispatch, structured error +encoding, and process exit status. + +### ReScript/Node modules + +Split the current broad utility and LSP modules into: + +- `Config.Raw` +- `Config.Decode` +- `Config.Normalized` +- `Config.Validate` +- `PathIdentity` +- `SchemaRouting` +- `WorkspacePlan` +- `EngineProtocol` +- `EngineClient` +- `BuildCoordinator` +- `CompilerWatcher` +- LSP transport, session state, and individual handlers + +Legacy configuration should be adapted into the normalized model at one boundary +and remain supported for the documented compatibility window. + +## Canonical schema model + +### Separate GraphQL positions from ReScript representation + +Introduce distinct types along these lines: + +```text +InputType +OutputType +ResolverArgument = + | Argument(InputType) + | Context + | ResolveInfo + | InterfaceTypename + +ValueRepresentation = + | Plain + | Option + | Nullable + | CustomCodec +``` + +GraphQL nullability belongs to the GraphQL type tree. ReScript `option` versus +`Nullable` belongs to conversion metadata. Context and resolve-info injection do +not belong to either GraphQL input or output types. + +### Collect before materializing + +Generation should be divided into explicit phases: + +1. Discover packages, selected files, and CMT/CMTI inputs. +2. Summarize compiler data into immutable module summaries. +3. Collect every annotated declaration and resolver without traversing its full + type graph. +4. Register GraphQL names globally, recording kind and source provenance. +5. Materialize reachable declarations through a worklist with `Unseen`, + `Visiting`, and `Complete` states. +6. Derive interface closure, inherited fields, resolver plans, authorization + plans, and codecs. +7. Validate the complete graph. +8. Freeze maps and arrays in canonical deterministic order. +9. Emit all requested outputs from frozen IR. +10. Commit the artifact transaction and then persist cache/editor indexes. + +Registering placeholders before traversing children makes recursive and mutually +recursive declarations deliberate rather than dependent on incidental table +insertion. + +### Centralize GraphQL semantics + +Provide one implementation for: + +- type equality and nullability +- input/output position validity +- subtype and interface compatibility +- GraphQL name validation +- SDL type rendering +- runtime GraphQL type folding +- interface ancestry and concrete implementor closure +- union and input-union membership +- oneOf constraints +- recursive non-null input-object cycle validation + +Emitters should not independently rediscover these rules. + +### Extending ResGraph + +A typical new GraphQL feature should require: + +1. Compiler-adapter recognition. +2. A declaration or canonical IR representation. +3. A validation rule where applicable. +4. Resolver, authorization, or codec planning where applicable. +5. Changes only to relevant emitters. +6. Unit, runtime, compatibility, and performance fixtures. + +Use ordinary pass and emitter interfaces. Introduce a generalized extension API +only when a concrete second implementation demonstrates the required abstraction. + +The canonical IR also establishes a clean future input for an operation compiler, +`graphql-ppx` integration, or Sury/JSON Schema operation-codec generation without +forking GraphQL type rules. + +## Artifact ownership and transactions + +Introduce one versioned artifact manifest. Every entry contains: + +- canonical path +- artifact kind +- owning schema identity +- content digest +- format version + +The transaction is: + +1. Render all requested artifacts in memory or bounded staging buffers. +2. Validate the complete result and ownership plan. +3. Write changed files to same-directory temporary siblings. +4. Rename each staged file atomically. +5. Remove only files proven owned by the prior manifest and carrying the expected + generated marker. +6. Commit the new manifest last. +7. Persist the cache and tooling index only after source output succeeds. + +On a failed rebuild, preserve the last-known-good artifact set. A first-ever +invalid build may create a clearly marked compile-safe bootstrap stub if needed, +but the command must still fail and CI must not silently execute a stale schema. + +Only a verified missing-file error should be ignored. Permission, symlink, +rename, read, write, digest, and manifest failures must be structured diagnostics. + +## Multi-schema build model + +Normalize all configuration into a `WorkspacePlan` and group schemas by canonical +ReScript compiler root. + +For each root: + +1. Watch the compiler build marker once. +2. Check all selected schema caches. +3. If every schema hits, return without loading CMTs. +4. Build one project/package index for all misses. +5. Scan the union of selected source roots once. +6. Load and summarize each CMT/CMTI at most once. +7. Construct each schema independently over shared immutable summaries. +8. Return a result per schema so diagnostics and failure isolation are retained. +9. Commit successful schema transactions independently. + +Initially build schemas sequentially over shared summaries to cap peak memory. +Distinct compiler roots may use bounded parallelism after global state has been +removed. Watch mode should allow at most one active generation and one coalesced +trailing rebuild per root. + +## Cache model + +Keep per-schema cache decisions while sharing summary and filesystem-signature +work within a compiler-root batch. + +Each schema cache should include: + +- normalized schema configuration +- tool and compiler-adapter build identifiers +- selected source roots and membership signatures, so added files invalidate +- source, CMT, and CMTI files actually needed for type and policy resolution +- relevant project and dependency configuration +- transitive module resolution closure +- authorization policies and required baseline inputs +- exact generated output digests + +Cache missing module/CMT lookups as well as successful ones during a build. +Memoize path identity and filesystem signatures across schema decisions. + +Authorization should be represented as ordinary inputs and outputs so required or +baseline authorization no longer disables caching wholesale. + +## Generated runtime and FFI + +Representative generated schemas repeat raw JavaScript helpers, unsafe recursive +type cells, property closures, and absent optional configuration fields. + +Introduce a private `ResGraph__GeneratedRuntime` containing: + +- opaque, one-shot recursive GraphQL type cells +- source representation unwrapping +- input conversion helpers +- abstract type discrimination +- shared property resolution +- contextual invariant failures + +Use GraphQL.js's default field resolver wherever it has exactly the required +semantics. Distinguish default properties, renamed or representation-aware +properties, and true function resolvers in `ResolverPlan`. + +Replace open-object FFI with typed records and dictionaries where the upstream +shape is stable. Keep unsafe casts private, narrow, and explicitly named. Preserve +the generated public `.resi` surface, especially `let schema`, while changing +implementation details. + +Do not change `CodeWriter` or property-access strategies based on intuition alone; +benchmark construction, compilation, heap use, and query throughput first. + +## Public runtime evolution + +Add a safe execution API that: + +- parses and validates operations by default +- normalizes GraphQL `PromiseOrValue` through `Promise.resolve` +- returns a fixed JSON GraphQL envelope or requires an explicit decoder +- reports malformed variables as a domain error +- uses a bounded query cache keyed directly by query string + +Keep the current generic execution surface as deprecated or explicitly `Unsafe` +until a major release permits removal. + +Correct the DataLoader bindings and expose per-entry `loadMany` failures as +`result`. Make identity keys the normal low-cost API and offer explicit deep/JSON +key behavior separately. Preserve array order in canonical serialization. + +Unify Yoga plugin types, consolidate GraphQL error bindings, expand safe +`ResolveInfo` accessors, and retain one clearly named unsafe escape hatch rather +than encouraging arbitrary application casts. + +## LSP and extension plan + +### Correctness first + +- Convert filesystem paths to real file URIs. +- Retain unsaved `.res`, `.resi`, and GraphQL document content. +- Clean temporary files in guaranteed finalizers, or parse buffers directly. +- Clear or replace diagnostics deterministically after process failures. +- Attribute and deduplicate shared-source diagnostics by schema. +- Accept numeric and string JSON-RPC IDs. +- Contain handler exceptions and return proper protocol errors. + +### Structure + +- Move global state into `LspServer.t`. +- Separate transport, document storage, schema routing, diagnostics, completion, + hover, and definition handlers. +- Remove inherited protocol surface that ResGraph does not support. +- Replace raw full-schema persistence with a compact versioned `EditorIndex`. +- Use asynchronous engine calls and support cancellation. + +### Later, measurement-gated improvements + +- Persistent native editor worker with indexes invalidated by compiler markers. +- Configuration reload and watcher reconciliation. +- True multi-root workspace support. +- Deterministic workspace-local CLI resolution. +- Extension/engine version and protocol handshake. + +The current tiny warm native process measurement is approximately 5 ms per hover +invocation. Persistent-worker complexity should be gated on representative large +projects and p50/p95 editor measurements. + +## Test strategy + +### Native unit tests + +Add table-driven tests for: + +- GraphQL names and duplicate registration +- input/output type construction and nullability +- recursive builder visiting states +- interface ancestry, inheritance, and cycles +- union and input-union membership +- config decoding and normalization +- source and path identity +- cache decisions and dependency closure +- authorization planning +- artifact plans and ownership migration +- diagnostic structure and ordering +- string, description, deprecation, and SDL escaping + +### Runtime behavior + +Exercise generated schemas rather than only inspecting emitted strings: + +- synchronous and asynchronous queries +- mutations +- subscriptions and cancellation +- objects, interfaces, unions, recursive types, and abstract resolution +- input objects and every zero/one/multiple input-union shape +- custom scalar parse/serialize and `specifiedBy` +- connections and pagination +- authorization allow/deny, sync/async policy, ordering, and error propagation +- DataLoader batching, cache identity, `prime`, `loadMany`, and failures +- Execute parse, validation, variables, caching, data, and errors +- Yoga plugin integration + +### Differential compatibility + +While replacing the builder or emitters, compare old and new paths using: + +- normalized diagnostics +- generated public module/signature inventory +- byte-level output where the stage promises no output changes +- intentionally reviewed focused goldens where formatting changes +- normalized SDL +- normalized GraphQL introspection +- black-box operation results + +### Filesystem and cache fault tests + +- interrupted and failed writes +- permission failures +- truncated cache/state/manifest files +- unsupported format versions +- concurrent invocation +- output tampering +- symlink retargeting and aliases +- added, removed, renamed, and moved source files +- schema ownership transfer +- generated-looking user files +- missing output directories +- configuration and authorization baseline changes + +### LSP protocol tests + +Replace fixed timers with a client that waits for response IDs and notifications. +Cover initialization ordering, diagnostics, completion, hover, definition, +unsaved buffers, `.res`, `.resi`, GraphQL files, shared-source routing, numeric +IDs, cancellation, process failure, rebuilds, and config reload. + +### Package consumer tests + +Pack the exact release candidate into a fresh temporary consumer with no access to +the repository's `node_modules`, then: + +- install it using the supported package manager +- compile a ReScript consumer +- run `resgraph build` +- import and execute the generated schema +- exercise DataLoader +- exercise optional Yoga integration when installed +- verify expected compiler metadata and native platform binary +- reject unsupported platform/architecture combinations explicitly + +Run against the minimum supported and newest supported dependency versions. + +## Performance plan + +Add `--profile-json` or an equivalent structured profiler reporting: + +- project discovery duration +- source scan duration and file count +- CMT read/summarize duration and count +- declaration collection and materialization duration +- validation and planning duration +- each emitter's duration +- cache hit/miss reason and validation cost +- filesystem signature and write counts +- peak live/heap words or RSS + +Benchmark: + +- 1, 5, and 20 schemas sharing one compiler root +- 1, 5, and 20 schemas across separate roots +- small and representative large projects +- cold build +- warm no-op cache hit +- common-source change +- schema-local change +- dependency change +- authorization policy/baseline change +- generated schema compilation and construction +- representative query throughput +- LSP completion and hover p50/p95 while idle and rebuilding + +Initially record results without blocking changes. Once environmental variance is +understood, set regression budgets. Single-schema cold and warm behavior should +not regress by more than roughly 5-10% without an explicit, reviewed reason. +Multi-schema builds should demonstrate a material improvement in process count, +CMT reads, filesystem work, and wall time. Peak RSS must remain bounded; prefer +sequential schema construction over shared summaries if parallel construction +causes excessive memory growth. + +## Compatibility contract + +Every stage must preserve or explicitly migrate: + +- GraphQL semantics and normalized introspection +- queries, mutations, subscriptions, scalars, objects, interfaces, unions, input + unions, connections, and authorization +- generated public module names and signatures +- existing interface helper modules +- `let schema` in generated `.resi` files +- legacy and named-schema configuration +- CLI commands and meaningful exit status +- first-schema default routing behavior +- multi-schema routing and per-schema failure isolation +- output paths and generated ownership +- authorization manifests and baselines +- named-schema cache/state locations or a documented migration +- completion, hover, definition, and diagnostics +- supported ReScript, Node, GraphQL, operating-system, and architecture ranges + +Use adapters and deprecation periods for public API changes. Internal cache or +tooling formats may invalidate and rebuild when their version changes, but failure +must be explicit and safe. + +## Delivery stages + +### Stage 0: trustworthy baseline + +- Repair strict test propagation and cleanup. +- Add missing runtime, native unit, package-consumer, and LSP protocol coverage. +- Record generated ABI, introspection, feature, and performance baselines. +- Add the structured phase profiler. + +Exit criteria: every current feature has an executable characterization test, the +packed artifact is tested, and failures cannot be hidden by shell sequencing. + +### Stage 1: correctness and release fixes + +- Fix Execute `PromiseOrValue`, validation, variables, and cache behavior. +- Fix DataLoader, Yoga plugin types, input-union registration/enforcement, and + duplicate GraphQL names. +- Fix LSP URI, `.resi`, temp-file, unsaved-buffer, and stale-diagnostic behavior. +- Fix dependency/peer declarations, package entry points, license metadata, CLI + status/version output, and release-profile binary production. + +Exit criteria: all known P0 correctness issues have regression tests and packed +consumers use only declared dependencies. + +### Stage 2: infrastructure boundaries + +- Extract native libraries and `.mli` interfaces. +- Introduce normalized config, `WorkspacePlan`, `PathIdentity`, typed errors, + structured protocol DTOs, and `GenerationContext`. +- Remove global package/resolver hooks and deep exits. +- Preserve generated output byte-for-byte. + +Exit criteria: compiler internals are isolated, generation is callable as a +library, and the old generator runs behind the new boundaries. + +### Stage 3: canonical IR and passes + +- Add declaration collection and the explicit worklist builder. +- Split input, output, resolver argument, and value-representation types. +- Add global name registration and centralized GraphQL semantics. +- Extract interface, validation, resolver, and authorization passes. +- Dual-run old and new implementations in tests. + +Exit criteria: diagnostics, public ABI, introspection, execution, and measured +performance meet the compatibility gates. + +### Stage 4: emitter and artifact separation + +- Make every emitter consume frozen IR only. +- Introduce `OutputPlan`, atomic writes, exact manifest ownership, and last-known- + good behavior. +- Replace internal-state `Marshal` with a versioned `EditorIndex`. +- Centralize escaping and generated runtime helpers. + +Exit criteria: fault injection cannot produce partial mixed generations or remove +unowned files. + +### Stage 5: shared multi-schema engine and scoped cache + +- Add batch requests grouped by compiler root. +- Share discovery, source indexing, summaries, resolution, and signatures. +- Retain per-schema result and artifact isolation. +- Track schema-local dependency closure. +- Enable caching for authorization modes. +- Coalesce watch rebuilds. + +Exit criteria: single-schema performance stays within budget and multi-schema +benchmarks demonstrate material improvement. + +### Stage 6: measured hot-path and editor optimization + +- Optimize file discovery, interface sets, diagnostic deduplication, authorization + signature reuse, and sorting-on-freeze. +- Specialize generated representation unwrapping and property resolution only + where benchmarks show benefit. +- Add a persistent native editor worker only if representative measurements + justify it. +- Add config reload and multi-root extension support. + +Exit criteria: each optimization has a targeted benchmark and retains behavioral +parity. + +### Stage 7: public API evolution and project hygiene + +- Add safe Execute and ResolveInfo APIs and deprecate unsafe surfaces. +- Publish config JSON Schema and compatibility matrices. +- Complete architecture, contributor, generated-artifact, performance, and release + documentation. +- Test documentation snippets and the example project. +- Reconsider platform-specific binary packages only if measured installation size + justifies the release complexity. + +## Recommended pull-request sequence + +Keep changes reviewable and preserve a working main branch throughout: + +1. Strict tests, cleanup traps, and baseline measurements. +2. Runtime and LSP correctness fixes plus packed-package consumer tests. +3. Artifact ownership manifest and transactional writes. +4. Native library boundaries, explicit errors, and `GenerationContext`. +5. Declaration index, global name registry, and typed IR in dual-run mode. +6. Validation/resolver/authorization passes and emitter migration. +7. Stable editor index and removal of raw internal-state persistence. +8. Compiler-root batching and schema-local cache invalidation. +9. Generated-runtime and measured hot-path improvements. +10. Public safe APIs, documentation, and removal of deprecated internals when the + compatibility window permits it. + +Each pull request should state which invariant it establishes, include its own +characterization or regression tests, and attach relevant profiler output. + +## Documentation required for continuous extension + +Add and maintain: + +- system and data-flow architecture +- canonical IR invariants +- compiler-adapter and supported ReScript-version policy +- generated-artifact ownership and transaction rules +- cache correctness and invalidation invariants +- CLI/native protocol specification +- public and generated ABI compatibility policy +- performance methodology and baseline storage +- an "adding a GraphQL feature" checklist +- contributor setup and deterministic root task surface +- release and vendored-compiler update runbooks +- short architecture decision records for lasting choices + +Getting Started should double as a packed-package consumer fixture. Selected code +snippets should compile in CI, broken documentation links should fail the docs +build, and the checked-in example must be regenerated and tested. + +## Risks and mitigations + +### Generated output is effectively a public API + +Use byte-level fixtures where output must remain unchanged, focused reviewed +goldens where formatting intentionally changes, and public module/signature +inventory checks everywhere. + +### Compiler internals are version-sensitive + +Confine all compiler-specific values and operations to `resgraph_compiler`. Test +each supported ReScript version through the adapter contract. + +### Narrower cache inputs can become unsound + +Track selected source membership plus actual transitive resolution dependencies. +Add invalidation fuzzing and retain output digest verification. + +### Batching can raise memory use + +Share immutable summaries, but build and freeze schemas sequentially initially. +Introduce bounded parallelism only after profiling. + +### Artifact migration is destructive if ownership is wrong + +Introduce exact manifest ownership before changing layouts. Verify markers and +digests, test symlinks and user-owned collisions, and preserve last-known-good +outputs. + +### Runtime representations depend on ReScript output details + +Guard representation-aware optimizations with black-box object, interface, union, +input-union, and recursive-value execution tests. + +### A broad rewrite could obscure performance regressions + +Move existing code behind interfaces first, dual-run new phases, and remove old +paths only after compatibility and profiling gates pass. + +## First implementation milestone + +The first milestone should combine immediate safety with prerequisites for later +work: + +1. Make the test suite and packed-package validation trustworthy. +2. Record feature, ABI, introspection, and performance baselines. +3. Fix known Execute, DataLoader, Yoga, input-union, name-registry, LSP, and + packaging correctness issues. +4. Introduce transactional artifact ownership and last-known-good behavior. +5. Extract native library boundaries and explicit `GenerationContext` while + retaining the existing generator behind them. + +Only after that milestone should the canonical IR replace the existing builder. +Compiler-root batching should follow the IR and context work, because those +boundaries allow CMT summaries to be shared safely and make multi-schema +performance improvements straightforward rather than entangled with global state. diff --git a/docs/docs/architecture.md b/docs/docs/architecture.md new file mode 100644 index 00000000..4604745f --- /dev/null +++ b/docs/docs/architecture.md @@ -0,0 +1,124 @@ +--- +sidebar_position: 30 +--- + +# Architecture and contributor guide + +ResGraph deliberately splits work between ReScript/Node and native OCaml. The +Node CLI owns configuration, workspace routing, watch coordination, editor +transport, and generated-file ownership. The native engine owns compiler +metadata, GraphQL declaration discovery, semantic validation, authorization +planning, and schema emission. + +```text +resgraph.json + | + v +Node config normalization and schema routing + | + v +native generation context and compiler summaries + | + v +schema discovery -> validation/planning -> emitters + | + v +generated ReScript, SDL, authorization, cache, and editor artifacts +``` + +The native executable is intentionally thin. Generator code lives in the +wrapped `resgraph_engine` library, and `GenerationContext` is the explicit home +for reusable CMT and module-summary caches. Normal builds group schemas by +canonical compiler root and send one length-prefixed batch request per root. +Schemas build sequentially with aligned results: compiler summaries are shared, +while mutable schema state, artifacts, diagnostics, and failures remain isolated. + +## Invariants + +- Every configured schema has independent output, cache, diagnostics, + authorization, and editor-state identity. +- Source inclusion is explicit for named schemas. Generated output folders are + excluded from subsequent discovery. +- Failed validation preserves the last successfully generated schema. A + compile-safe bootstrap is created only when no prior schema exists. +- Generated-looking filenames are not proof of ownership. Cleanup requires the + ResGraph generated marker or the configuration ownership manifest. +- Changed native artifacts are written to same-directory temporary siblings and + atomically renamed. Cache and editor state are persisted only after source + output succeeds. +- GraphQL operations executed through `ResGraph.Execute` are validated before + execution and normalized to promises whether GraphQL.js completes + synchronously or asynchronously. +- Compiler-specific values stay inside the native engine. Public runtime APIs + expose ReScript types, GraphQL values, JSON, results, and promises. + +## Adding a GraphQL feature + +Keep recognition, semantics, and printing separate. A feature normally needs: + +1. Attribute or compiler-type recognition in the native discovery layer. +2. An explicit representation in `GenerateSchemaTypes` (and eventually the + canonical schema IR described in the modernization plan). +3. Source-located validation in `GenerateSchemaValidation`. +4. Resolver, conversion, or authorization planning where applicable. +5. Changes only to the relevant ReScript and SDL emitters. +6. A native unit fixture plus a generated-schema runtime fixture. +7. Compatibility checks for generated signatures, normalized SDL or + introspection, and operation results. + +Do not add a printer-only special case for a semantic rule. If both SDL and +runtime code need to understand it, give it one representation and one +validation rule first. + +## Generated artifacts and persistence + +Named-schema ownership is recorded in +`lib/resgraph/.configured-schemas.json`. Only marker-owned files from a previous +configuration are eligible for cleanup. Native authorization manifests have +their own marker and failure status. + +The current editor index is a versioned native state file. Treat its contents as +private and disposable: incompatible versions must ask the user to rebuild. A +future migration will replace the broad marshalled state with a compact, +versioned DTO; no external tool should read the existing format. + +Incremental cache correctness takes priority over hit rate. A cache key includes +configuration, compiler/runtime selection, project and dependency inputs, +compiled metadata, and output integrity. New dependencies must first be tracked +conservatively; narrowing invalidation requires a fault-oriented regression +test. + +## Native protocol + +The Node CLI invokes the native executable with private positional commands. +Stdout is reserved for one JSON response and stderr for operational failures or +diagnostic presentation. Existing command shapes are compatibility surfaces for +the published Node CLI even though they are not a user-facing API. + +When evolving this protocol, introduce a versioned request/response DTO, retain +per-schema results, and update package-consumer and LSP tests together. Avoid +adding process exits below the CLI boundary. + +## Performance work + +Measure before changing representation or adding a persistent process. Relevant +scenarios are cold generation, warm cache hits, shared-source changes, and 1, 5, +and 20 schemas per compiler root. Record CMT reads, source scans, writes, wall +time, and peak memory. Generated-schema construction and representative query +throughput are separate benchmarks from generator speed. + +Single-schema behavior is the compatibility baseline. Multi-schema optimization +should reduce process count and repeated CMT work while building schemas +sequentially over shared summaries; bounded parallelism remains measurement +gated. + +## Local validation + +From the repository root, use `make test` for the native build and integration +fixtures. Also run `npm test`, `npm run test:package`, and `npm run build` when +changing public runtime bindings or packaging. The packed-package test installs +the tarball into a clean consumer, compiles a schema, runs a query, and exercises +DataLoader. + +The full staged roadmap, compatibility gates, and rationale live in the +[architecture modernization plan](architecture-modernization-plan). diff --git a/docs/docs/multiple-schemas.md b/docs/docs/multiple-schemas.md index 488106ff..9d684c0b 100644 --- a/docs/docs/multiple-schemas.md +++ b/docs/docs/multiple-schemas.md @@ -5,6 +5,9 @@ sidebar_position: 2 # Multiple schemas A repository can build several independent ResGraph schemas from one `resgraph.json`. Schemas may belong to the same ReScript package or to different packages in a monorepo. +Editors can opt into validation and completion by adding +`"$schema": "./node_modules/resgraph/resgraph.schema.json"` to the config. + ## Configuration diff --git a/package-lock.json b/package-lock.json index 430a2d7e..a8b9afc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "resgraph", "version": "1.3.0", - "license": "ISC", + "license": "MIT", "dependencies": { "@glennsl/rescript-fetch": "^0.2.2", "chokidar": "^3.5.3", @@ -19,17 +19,96 @@ "resgraph": "dist/Cli.mjs" }, "devDependencies": { + "@envelop/extended-validation": "^5.1.3", "chalk": "^5.2.0", "dataloader": "^2.2.2", "esbuild": "^0.23.1", + "graphql": "^16.8.1", + "graphql-yoga": "^5.3.0", "rescript": "^12.0.0" }, "engines": { "node": ">=20.11.0" }, "peerDependencies": { - "@glennsl/rescript-fetch": ">=0.2.0", + "@envelop/extended-validation": "^5.1.3", + "dataloader": "^2.2.2", + "graphql": "^16.8.1", + "graphql-yoga": "^5.3.0", "rescript": ">=12.0.0" + }, + "peerDependenciesMeta": { + "@envelop/extended-validation": { + "optional": true + }, + "dataloader": { + "optional": true + }, + "graphql-yoga": { + "optional": true + } + } + }, + "node_modules/@envelop/core": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.5.1.tgz", + "integrity": "sha512-3DQg8sFskDo386TkL5j12jyRAdip/8yzK3x7YGbZBgobZ4aKXrvDU0GppU0SnmrpQnNaiTUsxBs9LKkwQ/eyvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@envelop/instrumentation": "^1.0.0", + "@envelop/types": "^5.2.1", + "@whatwg-node/promise-helpers": "^1.2.4", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@envelop/extended-validation": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@envelop/extended-validation/-/extended-validation-5.1.3.tgz", + "integrity": "sha512-mp7CCN+KNp375FEFN3gRdl7M8rqdmqOUfUIEHkP6O7injeuxd86nbFuS4f/uJGcTubFOaCs1IKIFBcuYZk33iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^10.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@envelop/core": "^5.2.3", + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@envelop/instrumentation": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@envelop/instrumentation/-/instrumentation-1.0.0.tgz", + "integrity": "sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.2.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@envelop/types": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@envelop/types/-/types-5.2.1.tgz", + "integrity": "sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -416,11 +495,210 @@ "node": ">=18" } }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "dev": true, + "license": "MIT" + }, "node_modules/@glennsl/rescript-fetch": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@glennsl/rescript-fetch/-/rescript-fetch-0.2.2.tgz", "integrity": "sha512-DcbcbZ/7qctHf5nMerfaZfvHCtJOggy22Wb8vRqzE+OZipFu1VDNwcrWq5w89vAi8rsFD3L2EfzxM6JjNX37Tg==" }, + "node_modules/@graphql-tools/executor": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor/-/executor-1.5.7.tgz", + "integrity": "sha512-UcXVClkBml+qyGEsQxfEaAkboqySVGFUd9ivn7pDc9jZSckgF6zL21cNxuRH5ZA2exneV3PTtcc0I0rDOdE0Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^11.2.2", + "@graphql-typed-document-node/core": "^3.2.0", + "@repeaterjs/repeater": "^3.1.0", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/executor/node_modules/@graphql-tools/utils": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.2.2.tgz", + "integrity": "sha512-Do2A/t6ayqOma8m7PvpYMsCBswL+NB35BQSng4GWSBqafL2/BnfAZy/NSENPwV721Rm2fG0BHETFC0+FJJZmLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/merge": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.2.2.tgz", + "integrity": "sha512-DSLLAztOIQId7QE3m8Ehk5lV+0pjxNSSRDHPzlYQ9E4KJ9AoUMBprC4C+eX3v4srh05S2ujm5/veqAr5yEWFSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^11.2.2", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/merge/node_modules/@graphql-tools/utils": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.2.2.tgz", + "integrity": "sha512-Do2A/t6ayqOma8m7PvpYMsCBswL+NB35BQSng4GWSBqafL2/BnfAZy/NSENPwV721Rm2fG0BHETFC0+FJJZmLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/schema": { + "version": "10.0.38", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.38.tgz", + "integrity": "sha512-Kckk2/vm+rELJ7ijvFaAn9ouWSVUTD0D4SJIvYF4rKeuQHAfCzE/jzMFonZVpTXleqJjPXLZjVbuaMorw7A5Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-tools/merge": "^9.2.2", + "@graphql-tools/utils": "^11.2.2", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/schema/node_modules/@graphql-tools/utils": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.2.2.tgz", + "integrity": "sha512-Do2A/t6ayqOma8m7PvpYMsCBswL+NB35BQSng4GWSBqafL2/BnfAZy/NSENPwV721Rm2fG0BHETFC0+FJJZmLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/utils": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-10.11.0.tgz", + "integrity": "sha512-iBFR9GXIs0gCD+yc3hoNswViL1O5josI33dUqiNStFI/MHLCEPduasceAcazRH77YONKNiviHBV8f7OgcT4o2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-yoga/logger": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@graphql-yoga/logger/-/logger-2.0.1.tgz", + "integrity": "sha512-Nv0BoDGLMg9QBKy9cIswQ3/6aKaKjlTh87x3GiBg2Z4RrjyrM48DvOOK0pJh1C1At+b0mUIM67cwZcFTDLN4sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@graphql-yoga/subscription": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@graphql-yoga/subscription/-/subscription-5.0.5.tgz", + "integrity": "sha512-oCMWOqFs6QV96/NZRt/ZhTQvzjkGB4YohBOpKM4jH/lDT4qb7Lex/aGCxpi/JD9njw3zBBtMqxbaC22+tFHVvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-yoga/typed-event-target": "^3.0.2", + "@repeaterjs/repeater": "^3.0.4", + "@whatwg-node/events": "^0.1.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@graphql-yoga/typed-event-target": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@graphql-yoga/typed-event-target/-/typed-event-target-3.0.2.tgz", + "integrity": "sha512-ZpJxMqB+Qfe3rp6uszCQoag4nSw42icURnBRfFYSOmTgEeOe4rD0vYlbA8spvCu2TlCesNTlEN9BLWtQqLxabA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@repeaterjs/repeater": "^3.0.4", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@repeaterjs/repeater": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.1.0.tgz", + "integrity": "sha512-TaoVksZRSx2KWYYpyLQtMQXXeS98VsgZImzW65xmiVgbYhXLk+aEsmzPLirqVuE4/XuUapH2iMtxUzaBNDzdSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@rescript/darwin-arm64": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/@rescript/darwin-arm64/-/darwin-arm64-12.0.0.tgz", @@ -507,6 +785,93 @@ "node": ">=20.11.0" } }, + "node_modules/@whatwg-node/disposablestack": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz", + "integrity": "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/events": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/events/-/events-0.1.2.tgz", + "integrity": "sha512-ApcWxkrs1WmEMS2CaLLFUEem/49erT3sxIVjpzU5f6zmVcnijtDSrhoK2zVobOIikZJdH63jdAXOrvjf6eOUNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/fetch": { + "version": "0.10.13", + "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.13.tgz", + "integrity": "sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@whatwg-node/node-fetch": "^0.8.3", + "urlpattern-polyfill": "^10.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/node-fetch": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.8.6.tgz", + "integrity": "sha512-BDMdYFcerLQkwA2RTldxOqRCs6ZQD1S7UgP3pUdGUkcbgTrP/V5ko77ZkCww9DHmC4lpoYuwigGfQYj285gMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.1.1", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@whatwg-node/server": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/server/-/server-0.11.0.tgz", + "integrity": "sha512-VSdkwnJRr8Yv9UgB2aXB3VUPWwd6Oqnn0hycFwhg9pZgWxJXb7JmhsiXe9tmpMwjHFxli12PGcz9aI63YYloGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@envelop/instrumentation": "^1.0.0", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/fetch": "^0.10.13", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -576,6 +941,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/cross-inspect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", + "integrity": "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/dataloader": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.2.tgz", @@ -657,10 +1035,10 @@ } }, "node_modules/graphql": { - "version": "16.6.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.6.0.tgz", - "integrity": "sha512-KPIBPDlW7NxrbT/eh4qPXz5FiFdL5UbaA0XUNz2Rp3Z3hqBSkbj0GVjwFDztsWVauZUWsbKHgMg++sk8UX0bkw==", - "peer": true, + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "license": "MIT", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -680,6 +1058,33 @@ "graphql": "^15.5.0 || ^16.0.0" } }, + "node_modules/graphql-yoga": { + "version": "5.21.2", + "resolved": "https://registry.npmjs.org/graphql-yoga/-/graphql-yoga-5.21.2.tgz", + "integrity": "sha512-IIRF/3xtjj2D6caAWL9177hQ8tV3mWB3hve1GRnz7njPhQ3iY1jFtSp98fNGv0yV9kaPh9kKQ8JWdJZnedVmDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@envelop/core": "^5.5.1", + "@envelop/instrumentation": "^1.0.0", + "@graphql-tools/executor": "^1.5.0", + "@graphql-tools/schema": "^10.0.11", + "@graphql-tools/utils": "^10.11.0", + "@graphql-yoga/logger": "^2.0.1", + "@graphql-yoga/subscription": "^5.0.5", + "@whatwg-node/fetch": "^0.10.6", + "@whatwg-node/promise-helpers": "^1.3.2", + "@whatwg-node/server": "^0.11.0", + "lru-cache": "^10.0.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "graphql": "^15.2.0 || ^16.0.0" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -718,6 +1123,13 @@ "node": ">=0.12.0" } }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -806,6 +1218,20 @@ "node": ">=8.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/urlpattern-polyfill": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", + "dev": true, + "license": "MIT" + }, "node_modules/vscode-jsonrpc": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-5.0.1.tgz", @@ -821,6 +1247,48 @@ } }, "dependencies": { + "@envelop/core": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.5.1.tgz", + "integrity": "sha512-3DQg8sFskDo386TkL5j12jyRAdip/8yzK3x7YGbZBgobZ4aKXrvDU0GppU0SnmrpQnNaiTUsxBs9LKkwQ/eyvw==", + "dev": true, + "requires": { + "@envelop/instrumentation": "^1.0.0", + "@envelop/types": "^5.2.1", + "@whatwg-node/promise-helpers": "^1.2.4", + "tslib": "^2.5.0" + } + }, + "@envelop/extended-validation": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@envelop/extended-validation/-/extended-validation-5.1.3.tgz", + "integrity": "sha512-mp7CCN+KNp375FEFN3gRdl7M8rqdmqOUfUIEHkP6O7injeuxd86nbFuS4f/uJGcTubFOaCs1IKIFBcuYZk33iQ==", + "dev": true, + "requires": { + "@graphql-tools/utils": "^10.0.0", + "tslib": "^2.5.0" + } + }, + "@envelop/instrumentation": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@envelop/instrumentation/-/instrumentation-1.0.0.tgz", + "integrity": "sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==", + "dev": true, + "requires": { + "@whatwg-node/promise-helpers": "^1.2.1", + "tslib": "^2.5.0" + } + }, + "@envelop/types": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@envelop/types/-/types-5.2.1.tgz", + "integrity": "sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==", + "dev": true, + "requires": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.5.0" + } + }, "@esbuild/aix-ppc64": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", @@ -989,11 +1457,150 @@ "dev": true, "optional": true }, + "@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "dev": true + }, "@glennsl/rescript-fetch": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@glennsl/rescript-fetch/-/rescript-fetch-0.2.2.tgz", "integrity": "sha512-DcbcbZ/7qctHf5nMerfaZfvHCtJOggy22Wb8vRqzE+OZipFu1VDNwcrWq5w89vAi8rsFD3L2EfzxM6JjNX37Tg==" }, + "@graphql-tools/executor": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/@graphql-tools/executor/-/executor-1.5.7.tgz", + "integrity": "sha512-UcXVClkBml+qyGEsQxfEaAkboqySVGFUd9ivn7pDc9jZSckgF6zL21cNxuRH5ZA2exneV3PTtcc0I0rDOdE0Tg==", + "dev": true, + "requires": { + "@graphql-tools/utils": "^11.2.2", + "@graphql-typed-document-node/core": "^3.2.0", + "@repeaterjs/repeater": "^3.1.0", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.4.0" + }, + "dependencies": { + "@graphql-tools/utils": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.2.2.tgz", + "integrity": "sha512-Do2A/t6ayqOma8m7PvpYMsCBswL+NB35BQSng4GWSBqafL2/BnfAZy/NSENPwV721Rm2fG0BHETFC0+FJJZmLw==", + "dev": true, + "requires": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + } + } + } + }, + "@graphql-tools/merge": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.2.2.tgz", + "integrity": "sha512-DSLLAztOIQId7QE3m8Ehk5lV+0pjxNSSRDHPzlYQ9E4KJ9AoUMBprC4C+eX3v4srh05S2ujm5/veqAr5yEWFSQ==", + "dev": true, + "requires": { + "@graphql-tools/utils": "^11.2.2", + "tslib": "^2.4.0" + }, + "dependencies": { + "@graphql-tools/utils": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.2.2.tgz", + "integrity": "sha512-Do2A/t6ayqOma8m7PvpYMsCBswL+NB35BQSng4GWSBqafL2/BnfAZy/NSENPwV721Rm2fG0BHETFC0+FJJZmLw==", + "dev": true, + "requires": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + } + } + } + }, + "@graphql-tools/schema": { + "version": "10.0.38", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.38.tgz", + "integrity": "sha512-Kckk2/vm+rELJ7ijvFaAn9ouWSVUTD0D4SJIvYF4rKeuQHAfCzE/jzMFonZVpTXleqJjPXLZjVbuaMorw7A5Og==", + "dev": true, + "requires": { + "@graphql-tools/merge": "^9.2.2", + "@graphql-tools/utils": "^11.2.2", + "tslib": "^2.4.0" + }, + "dependencies": { + "@graphql-tools/utils": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.2.2.tgz", + "integrity": "sha512-Do2A/t6ayqOma8m7PvpYMsCBswL+NB35BQSng4GWSBqafL2/BnfAZy/NSENPwV721Rm2fG0BHETFC0+FJJZmLw==", + "dev": true, + "requires": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + } + } + } + }, + "@graphql-tools/utils": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-10.11.0.tgz", + "integrity": "sha512-iBFR9GXIs0gCD+yc3hoNswViL1O5josI33dUqiNStFI/MHLCEPduasceAcazRH77YONKNiviHBV8f7OgcT4o2Q==", + "dev": true, + "requires": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + } + }, + "@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "dev": true, + "requires": {} + }, + "@graphql-yoga/logger": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@graphql-yoga/logger/-/logger-2.0.1.tgz", + "integrity": "sha512-Nv0BoDGLMg9QBKy9cIswQ3/6aKaKjlTh87x3GiBg2Z4RrjyrM48DvOOK0pJh1C1At+b0mUIM67cwZcFTDLN4sA==", + "dev": true, + "requires": { + "tslib": "^2.8.1" + } + }, + "@graphql-yoga/subscription": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@graphql-yoga/subscription/-/subscription-5.0.5.tgz", + "integrity": "sha512-oCMWOqFs6QV96/NZRt/ZhTQvzjkGB4YohBOpKM4jH/lDT4qb7Lex/aGCxpi/JD9njw3zBBtMqxbaC22+tFHVvw==", + "dev": true, + "requires": { + "@graphql-yoga/typed-event-target": "^3.0.2", + "@repeaterjs/repeater": "^3.0.4", + "@whatwg-node/events": "^0.1.0", + "tslib": "^2.8.1" + } + }, + "@graphql-yoga/typed-event-target": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@graphql-yoga/typed-event-target/-/typed-event-target-3.0.2.tgz", + "integrity": "sha512-ZpJxMqB+Qfe3rp6uszCQoag4nSw42icURnBRfFYSOmTgEeOe4rD0vYlbA8spvCu2TlCesNTlEN9BLWtQqLxabA==", + "dev": true, + "requires": { + "@repeaterjs/repeater": "^3.0.4", + "tslib": "^2.8.1" + } + }, + "@repeaterjs/repeater": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.1.0.tgz", + "integrity": "sha512-TaoVksZRSx2KWYYpyLQtMQXXeS98VsgZImzW65xmiVgbYhXLk+aEsmzPLirqVuE4/XuUapH2iMtxUzaBNDzdSQ==", + "dev": true + }, "@rescript/darwin-arm64": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/@rescript/darwin-arm64/-/darwin-arm64-12.0.0.tgz", @@ -1035,6 +1642,69 @@ "dev": true, "optional": true }, + "@whatwg-node/disposablestack": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz", + "integrity": "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==", + "dev": true, + "requires": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.6.3" + } + }, + "@whatwg-node/events": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/events/-/events-0.1.2.tgz", + "integrity": "sha512-ApcWxkrs1WmEMS2CaLLFUEem/49erT3sxIVjpzU5f6zmVcnijtDSrhoK2zVobOIikZJdH63jdAXOrvjf6eOUNQ==", + "dev": true, + "requires": { + "tslib": "^2.6.3" + } + }, + "@whatwg-node/fetch": { + "version": "0.10.13", + "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.13.tgz", + "integrity": "sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==", + "dev": true, + "requires": { + "@whatwg-node/node-fetch": "^0.8.3", + "urlpattern-polyfill": "^10.0.0" + } + }, + "@whatwg-node/node-fetch": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.8.6.tgz", + "integrity": "sha512-BDMdYFcerLQkwA2RTldxOqRCs6ZQD1S7UgP3pUdGUkcbgTrP/V5ko77ZkCww9DHmC4lpoYuwigGfQYj285gMvA==", + "dev": true, + "requires": { + "@fastify/busboy": "^3.1.1", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.6.3" + } + }, + "@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", + "dev": true, + "requires": { + "tslib": "^2.6.3" + } + }, + "@whatwg-node/server": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/server/-/server-0.11.0.tgz", + "integrity": "sha512-VSdkwnJRr8Yv9UgB2aXB3VUPWwd6Oqnn0hycFwhg9pZgWxJXb7JmhsiXe9tmpMwjHFxli12PGcz9aI63YYloGQ==", + "dev": true, + "requires": { + "@envelop/instrumentation": "^1.0.0", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/fetch": "^0.10.13", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.6.3" + } + }, "anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -1078,6 +1748,15 @@ "readdirp": "~3.6.0" } }, + "cross-inspect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", + "integrity": "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==", + "dev": true, + "requires": { + "tslib": "^2.4.0" + } + }, "dataloader": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.2.tgz", @@ -1139,10 +1818,9 @@ } }, "graphql": { - "version": "16.6.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.6.0.tgz", - "integrity": "sha512-KPIBPDlW7NxrbT/eh4qPXz5FiFdL5UbaA0XUNz2Rp3Z3hqBSkbj0GVjwFDztsWVauZUWsbKHgMg++sk8UX0bkw==", - "peer": true + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==" }, "graphql-language-service": { "version": "5.1.6", @@ -1153,6 +1831,26 @@ "vscode-languageserver-types": "^3.17.1" } }, + "graphql-yoga": { + "version": "5.21.2", + "resolved": "https://registry.npmjs.org/graphql-yoga/-/graphql-yoga-5.21.2.tgz", + "integrity": "sha512-IIRF/3xtjj2D6caAWL9177hQ8tV3mWB3hve1GRnz7njPhQ3iY1jFtSp98fNGv0yV9kaPh9kKQ8JWdJZnedVmDw==", + "dev": true, + "requires": { + "@envelop/core": "^5.5.1", + "@envelop/instrumentation": "^1.0.0", + "@graphql-tools/executor": "^1.5.0", + "@graphql-tools/schema": "^10.0.11", + "@graphql-tools/utils": "^10.11.0", + "@graphql-yoga/logger": "^2.0.1", + "@graphql-yoga/subscription": "^5.0.5", + "@whatwg-node/fetch": "^0.10.6", + "@whatwg-node/promise-helpers": "^1.3.2", + "@whatwg-node/server": "^0.11.0", + "lru-cache": "^10.0.0", + "tslib": "^2.8.1" + } + }, "is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -1179,6 +1877,12 @@ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" }, + "lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -1229,6 +1933,18 @@ "is-number": "^7.0.0" } }, + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true + }, + "urlpattern-polyfill": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", + "dev": true + }, "vscode-jsonrpc": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-5.0.1.tgz", diff --git a/package.json b/package.json index e17127ef..10f8f91a 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,14 @@ "name": "resgraph", "version": "1.3.0", "description": "Build GraphQL servers in ReScript.", - "main": "index.js", "scripts": { - "prepublish": "npm run build", + "prepack": "npm run build", "build": "rescript clean && npm run res:build && esbuild --external:vscode-jsonrpc --external:chokidar --platform=node --format=esm --bundle cli/Cli.mjs --outfile=dist/Cli.mjs --minify --tree-shaking", "res:build": "rescript", - "res:watch": "rescript -w" + "res:watch": "rescript -w", + "test": "npm run test:runtime", + "test:runtime": "npm run res:build && node tests/runtime-bindings.mjs", + "test:package": "node scripts/test-package.mjs" }, "bin": { "resgraph": "dist/Cli.mjs" @@ -18,7 +20,7 @@ }, "keywords": [], "author": "", - "license": "ISC", + "license": "MIT", "bugs": { "url": "https://github.com/zth/resgraph/issues" }, @@ -28,7 +30,15 @@ }, "peerDependencies": { "rescript": ">=12.0.0", - "@glennsl/rescript-fetch": ">=0.2.0" + "graphql": "^16.8.1", + "dataloader": "^2.2.2", + "graphql-yoga": "^5.3.0", + "@envelop/extended-validation": "^5.1.3" + }, + "peerDependenciesMeta": { + "dataloader": {"optional": true}, + "graphql-yoga": {"optional": true}, + "@envelop/extended-validation": {"optional": true} }, "dependencies": { "chokidar": "^3.5.3", @@ -40,7 +50,10 @@ "devDependencies": { "dataloader": "^2.2.2", "rescript": "^12.0.0", + "@envelop/extended-validation": "^5.1.3", "chalk": "^5.2.0", + "graphql": "^16.8.1", + "graphql-yoga": "^5.3.0", "esbuild": "^0.23.1" }, "files": [ @@ -50,6 +63,7 @@ "cli/*", "dist/Cli.mjs", "rescript.json", + "resgraph.schema.json", "package.json", "LICENSE", "README.md", diff --git a/resgraph.schema.json b/resgraph.schema.json new file mode 100644 index 00000000..cad0fa95 --- /dev/null +++ b/resgraph.schema.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/zth/resgraph/main/resgraph.schema.json", + "title": "ResGraph configuration", + "description": "Configuration for one legacy schema or several named ResGraph schemas.", + "type": "object", + "oneOf": [ + {"$ref": "#/$defs/legacyConfig"}, + {"$ref": "#/$defs/namedConfig"} + ], + "$defs": { + "authorization": { + "type": "object", + "required": ["mode"], + "properties": { + "mode": {"const": "required"}, + "onForbidden": {"type": "string", "minLength": 1}, + "manifestPath": {"type": "string", "minLength": 1}, + "baselinePath": {"type": "string", "minLength": 1} + }, + "additionalProperties": false + }, + "schemaOptions": { + "type": "object", + "required": ["outputFolder"], + "properties": { + "projectRoot": {"type": "string", "minLength": 1, "default": "."}, + "include": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true, + "default": [] + }, + "exclude": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true, + "default": [] + }, + "outputFolder": {"type": "string", "minLength": 1}, + "moduleName": { + "type": "string", + "pattern": "^[A-Z][A-Za-z0-9_]*$" + }, + "contextType": { + "type": "string", + "pattern": "^[A-Z][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)+$", + "default": "ResGraphContext.context" + }, + "dumpSchemaSdl": {"type": "boolean", "default": false}, + "authorization": {"$ref": "#/$defs/authorization"} + }, + "additionalProperties": false + }, + "legacyConfig": { + "type": "object", + "required": ["src", "outputFolder"], + "properties": { + "$schema": {"type": "string"}, + "src": {"type": "string", "minLength": 1}, + "outputFolder": {"type": "string", "minLength": 1}, + "include": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + }, + "exclude": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + }, + "contextType": { + "type": "string", + "pattern": "^[A-Z][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)+$" + }, + "dumpSchemaSdl": {"type": "boolean", "default": false}, + "authorization": {"$ref": "#/$defs/authorization"} + }, + "additionalProperties": false + }, + "namedConfig": { + "type": "object", + "required": ["schemas"], + "properties": { + "$schema": {"type": "string"}, + "defaultSchema": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]+$" + }, + "schemas": { + "type": "object", + "minProperties": 1, + "propertyNames": {"pattern": "^[A-Za-z0-9_-]+$"}, + "additionalProperties": {"$ref": "#/$defs/schemaOptions"} + } + }, + "additionalProperties": false + } + } +} diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs new file mode 100644 index 00000000..999e18ac --- /dev/null +++ b/scripts/test-package.mjs @@ -0,0 +1,178 @@ +import assert from "node:assert/strict"; +import {access, chmod, copyFile, mkdir, mkdtemp, rm, writeFile} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import path from "node:path"; +import {fileURLToPath} from "node:url"; +import {spawnSync} from "node:child_process"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const temporaryRoot = await mkdtemp(path.join(tmpdir(), "resgraph-package-")); +const consumerRoot = path.join(temporaryRoot, "consumer"); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? repositoryRoot, + encoding: "utf8", + env: process.env, + stdio: options.capture ? "pipe" : "inherit", + }); + if (result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n"); + throw new Error(`${command} ${args.join(" ")} failed with status ${result.status}.\n${output}`); + } + return result.stdout; +} + +async function exists(filePath) { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +const platformFolder = + process.platform === "darwin" + ? process.arch === "arm64" + ? "darwinarm64" + : "darwin" + : process.platform === "linux" && process.arch === "x64" + ? "linux" + : undefined; + +if (platformFolder === undefined) { + throw new Error(`Packed-package fixture does not support ${process.platform}/${process.arch}.`); +} + +const packagedBinaryDirectory = path.join(repositoryRoot, "bin", platformFolder); +const packagedBinary = path.join(packagedBinaryDirectory, "resgraph.exe"); +const developmentBinary = path.join(repositoryRoot, "bin", "dev", "resgraph.exe"); +let copiedDevelopmentBinary = false; + +try { + if (!(await exists(packagedBinary))) { + if (!(await exists(developmentBinary))) { + throw new Error("Build bin/dev/resgraph.exe before running the package fixture."); + } + await mkdir(packagedBinaryDirectory, {recursive: true}); + await copyFile(developmentBinary, packagedBinary); + await chmod(packagedBinary, 0o755); + copiedDevelopmentBinary = true; + } + + const packOutput = run( + "npm", + ["pack", "--json", "--pack-destination", temporaryRoot], + {capture: true}, + ); + const packJsonStart = packOutput.lastIndexOf("[\n {"); + if (packJsonStart < 0) { + throw new Error(`Could not find npm pack JSON in output:\n${packOutput}`); + } + const [{filename}] = JSON.parse(packOutput.slice(packJsonStart)); + const tarballPath = path.join(temporaryRoot, filename); + + await mkdir(path.join(consumerRoot, "src", "generated"), {recursive: true}); + await writeFile( + path.join(consumerRoot, "package.json"), + JSON.stringify({name: "resgraph-package-consumer", private: true, type: "module"}, null, 2) + "\n", + ); + + run( + "npm", + [ + "install", + "--ignore-scripts", + tarballPath, + "rescript@12.0.0", + "graphql@16.14.2", + "@glennsl/rescript-fetch@0.2.2", + "dataloader@2.2.2", + ], + {cwd: consumerRoot}, + ); + + await writeFile( + path.join(consumerRoot, "rescript.json"), + JSON.stringify( + { + name: "resgraph-package-consumer", + uncurried: true, + sources: [{dir: "src", subdirs: true}], + "package-specs": {module: "esmodule", "in-source": true}, + suffix: ".mjs", + dependencies: ["resgraph"], + }, + null, + 2, + ) + "\n", + ); + await writeFile( + path.join(consumerRoot, "resgraph.json"), + JSON.stringify( + { + src: "./src", + outputFolder: "./src/generated", + dumpSchemaSdl: true, + }, + null, + 2, + ) + "\n", + ); + await writeFile( + path.join(consumerRoot, "src", "Query.res"), + `@gql.type +type query + +@gql.field +let greeting = (_: query): string => "hello from package" +`, + ); + await writeFile(path.join(consumerRoot, "src", "ResGraphContext.res"), "type context = unit\n"); + + run(path.join(consumerRoot, "node_modules", ".bin", "rescript"), [], {cwd: consumerRoot}); + run(path.join(consumerRoot, "node_modules", ".bin", "resgraph"), ["build"], { + cwd: consumerRoot, + }); + run(path.join(consumerRoot, "node_modules", ".bin", "rescript"), [], {cwd: consumerRoot}); + + await writeFile( + path.join(consumerRoot, "verify.mjs"), + `import assert from "node:assert/strict"; +import * as DataLoader from "resgraph/src/res/DataLoader.mjs"; +import {Execute} from "resgraph/src/res/ResGraph.mjs"; +import {schema} from "./src/generated/ResGraphSchema.mjs"; + +const result = await Execute.executeToJson(schema, "{ greeting }", undefined); +assert.equal(result.data.greeting, "hello from package"); + +const loader = DataLoader.makeSingle(async key => "loaded:" + key); +DataLoader.primeAt(loader, "key", "primed"); +assert.equal(await DataLoader.load(loader, "key"), "primed"); +`, + ); + run("node", ["verify.mjs"], {cwd: consumerRoot}); + + const installedPackage = JSON.parse( + run( + "node", + ["-e", "process.stdout.write(JSON.stringify(require('./node_modules/resgraph/package.json')))"], + {cwd: consumerRoot, capture: true}, + ), + ); + assert.equal(installedPackage.license, "MIT"); + assert.equal(installedPackage.main, undefined); + assert.equal( + await exists(path.join(consumerRoot, "node_modules", "resgraph", "resgraph.schema.json")), + true, + ); + + console.log("Packed-package consumer fixture passed."); +} finally { + await rm(temporaryRoot, {recursive: true, force: true}); + if (copiedDevelopmentBinary) { + await rm(packagedBinary, {force: true}); + await rm(packagedBinaryDirectory, {recursive: true, force: true}); + } +} diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs new file mode 100644 index 00000000..1d33a5c2 --- /dev/null +++ b/scripts/verify-release.mjs @@ -0,0 +1,26 @@ +import {readFile} from "node:fs/promises"; +import path from "node:path"; +import {fileURLToPath} from "node:url"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const requestedTag = process.argv[2]; + +if (requestedTag === undefined) { + throw new Error("Usage: node scripts/verify-release.mjs "); +} + +const version = requestedTag.startsWith("v") ? requestedTag.slice(1) : requestedTag; +const packageJson = JSON.parse(await readFile(path.join(repositoryRoot, "package.json"), "utf8")); +const changelog = await readFile(path.join(repositoryRoot, "CHANGELOG.md"), "utf8"); + +if (packageJson.version !== version) { + throw new Error( + `Release tag ${requestedTag} does not match package version ${packageJson.version}.`, + ); +} + +if (!changelog.includes(`## ${version}\n`)) { + throw new Error(`CHANGELOG.md does not contain a section for ${version}.`); +} + +console.log(`Release metadata is consistent for ${requestedTag}.`); diff --git a/src/ml/ArchitectureTests.ml b/src/ml/ArchitectureTests.ml new file mode 100644 index 00000000..f6ce948e --- /dev/null +++ b/src/ml/ArchitectureTests.ml @@ -0,0 +1,135 @@ +open Resgraph_engine +open GenerateSchemaTypes + +let fail message = + prerr_endline message; + exit 1 + +let assertTrue condition message = if not condition then fail message + +let makeSchemaState () : schemaState = + { + contextTypePath = ["ResGraphContext"; "context"]; + rootFileUri = Uri.fromPath "/tmp/resgraph-architecture-test"; + types = Hashtbl.create 1; + inputObjects = Hashtbl.create 1; + inputUnions = Hashtbl.create 1; + enums = Hashtbl.create 1; + unions = Hashtbl.create 1; + interfaces = Hashtbl.create 1; + scalars = Hashtbl.create 1; + processedFiles = Hashtbl.create 1; + authorizationConfig = + { + mode = AuthorizationOptional; + onForbidden = None; + manifestPath = None; + baselinePath = None; + }; + authorizationDeclarations = Hashtbl.create 1; + authorizationPlans = Hashtbl.create 1; + resolverOutcomes = Hashtbl.create 1; + authorizationExemptions = Hashtbl.create 1; + authorizationGaps = []; + query = None; + subscription = None; + mutation = None; + diagnostics = []; + } + +let testInputUnionRegistry () = + let schemaState = makeSchemaState () in + let fileUri = Uri.fromPath "/tmp/Schema.res" in + Hashtbl.add schemaState.unions "shared" + { + typeSource = Variant; + id = "shared"; + displayName = "OutputChoice"; + description = None; + types = []; + typeLocation = Synthetic {fileName = "Schema"; fileUri; modulePath = []}; + }; + GenerateSchemaUtils.addInputUnion "shared" ~debug:false ~schemaState + ~makeInputUnion:(fun () -> + { + id = "shared"; + displayName = "InputChoice"; + members = []; + description = None; + typeLocation = + { + fileName = "Schema"; + fileUri; + modulePath = []; + typeName = "shared"; + loc = Location.none; + }; + }); + assertTrue + (Hashtbl.mem schemaState.inputUnions "shared") + "A regular union must not suppress an input union with the same internal \ + id." + +let testAtomicWrite () = + let path = Filename.temp_file "resgraph-atomic-write-" ".txt" in + Fun.protect + ~finally:(fun () -> try Sys.remove path with Sys_error _ -> ()) + (fun () -> + GenerateSchemaUtils.writeAtomically path "first"; + GenerateSchemaUtils.writeIfHasChanges path "second"; + match Files.readFile path with + | Some "second" -> () + | Some contents -> + fail ("Atomic write returned unexpected contents: " ^ contents) + | None -> fail "Atomic write did not leave the target file.") + +let testWriteFailureIsRaised () = + let missingParent = Filename.temp_file "resgraph-missing-parent-" "" in + Sys.remove missingParent; + let path = Filename.concat missingParent "artifact.res" in + match GenerateSchemaUtils.writeIfHasChanges path "contents" with + | () -> + fail "A generated artifact write failure must be raised to the caller." + | exception (Sys_error _ | Unix.Unix_error _) -> () + | exception exn -> raise exn + +let testCrossKindGraphqlNameCollision () = + let schemaState = makeSchemaState () in + let fileUri = Uri.fromPath "/tmp/Schema.res" in + let typeLocation = + { + fileName = "Schema"; + fileUri; + modulePath = []; + typeName = "choice"; + loc = Location.none; + } + in + Hashtbl.add schemaState.enums "choice" + { + id = "choice"; + displayName = "Choice"; + values = []; + description = None; + typeLocation = Concrete typeLocation; + }; + Hashtbl.add schemaState.inputObjects "choiceInput" + { + id = "choiceInput"; + displayName = "Choice"; + fields = []; + description = None; + typeLocation = Some typeLocation; + syntheticTypeLocation = None; + }; + GenerateSchemaValidation.validateTypeNameUniqueness schemaState; + assertTrue + (List.length schemaState.diagnostics = 1) + "Cross-kind GraphQL type names must be rejected before emission." + +let () = + testInputUnionRegistry (); + testAtomicWrite (); + testWriteFailureIsRaised (); + testCrossKindGraphqlNameCollision (); + print_endline "Native architecture fixtures passed." diff --git a/src/ml/Cli.ml b/src/ml/Cli.ml index 3bb54963..8018f235 100644 --- a/src/ml/Cli.ml +++ b/src/ml/Cli.ml @@ -1,9 +1,11 @@ +open Resgraph_engine let help = {| **Private CLI For ResGraph** Commands: generate-schema [options] + generate-schemas-v1 completion hover [schema] hover-graphql [schema] @@ -106,14 +108,67 @@ let valid_generate_options options = && matches "^[A-Z][A-Za-z0-9_]*\\(\\.[A-Za-z_][A-Za-z0-9_]*\\)+$" options.contextType -let run_generate ~sourceFolder ~outputFolder ~writeSdlFile options = - GenerateSchemaDirect.generateSchemaDirect ~writeStateFile:true ~sourceFolder - ~debug:false ~outputFolder ~writeSdlFile ~printToStdOut:true - ~schemaName:options.schemaName ~moduleName:options.moduleName - ~contextType:options.contextType +let run_generate ?generationContext ~sourceFolder ~outputFolder ~writeSdlFile + options = + GenerateSchemaDirect.generateSchemaDirect ?generationContext + ~writeStateFile:true ~sourceFolder ~debug:false ~outputFolder ~writeSdlFile + ~printToStdOut:true ~schemaName:options.schemaName + ~moduleName:options.moduleName ~contextType:options.contextType ~includePaths:(List.rev options.includePaths) ~excludePaths:(List.rev options.excludePaths) - ~authorizationConfig:options.authorizationConfig + ~authorizationConfig:options.authorizationConfig () + +let parse_generate_call = function + | "generate-schema" :: sourceFolder :: outputFolder :: writeSdl :: rest -> ( + match + Option.bind (parse_authorization_options default_generate_options rest) + (fun (options, rest) -> parse_generate_options options rest) + with + | Some options when valid_generate_options options -> + Some (sourceFolder, outputFolder, writeSdl = "true", options) + | Some _ | None -> None) + | _ -> None + +let rec take_arguments count acc args = + if count = 0 then Some (List.rev acc, args) + else + match args with + | [] -> None + | arg :: rest -> take_arguments (count - 1) (arg :: acc) rest + +let rec parse_batch_calls acc = function + | [] -> Some (List.rev acc) + | length :: rest -> ( + match int_of_string_opt length with + | Some length when length > 0 -> ( + match take_arguments length [] rest with + | Some (call, remaining) -> ( + match parse_generate_call call with + | Some parsed -> parse_batch_calls (parsed :: acc) remaining + | None -> None) + | None -> None) + | Some _ | None -> None) + +let run_batch calls = + let generationContext = GenerationContext.create () in + print_string "["; + calls + |> List.iteri + (fun index (sourceFolder, outputFolder, writeSdlFile, options) -> + if index > 0 then print_string ","; + try + run_generate ~generationContext ~sourceFolder ~outputFolder + ~writeSdlFile options + with exn -> + Printf.printf "{\"status\":\"Error\",\"errors\":[%s]}" + (GenerateSchemaUtils.printDiagnostic + { + loc = Location.none; + fileUri = Uri.fromPath sourceFolder; + message = + "Schema generation failed: " ^ Printexc.to_string exn; + })); + print_string "]" let schema_name = function | [] -> Some None @@ -122,15 +177,16 @@ let schema_name = function let main () = match Array.to_list Sys.argv with - | _ :: "generate-schema" :: sourceFolder :: outputFolder :: writeSdl :: rest - -> ( - match - Option.bind (parse_authorization_options default_generate_options rest) - (fun (options, rest) -> parse_generate_options options rest) - with - | Some options when valid_generate_options options -> - run_generate ~sourceFolder ~outputFolder ~writeSdlFile:(writeSdl = "true") - options + | _ :: "generate-schema" :: rest -> ( + match parse_generate_call ("generate-schema" :: rest) with + | Some (sourceFolder, outputFolder, writeSdlFile, options) -> + run_generate ~sourceFolder ~outputFolder ~writeSdlFile options + | None -> + prerr_endline help; + exit 1) + | _ :: "generate-schemas-v1" :: rest -> ( + match parse_batch_calls [] rest with + | Some calls when calls <> [] -> run_batch calls | Some _ | None -> prerr_endline help; exit 1) @@ -179,4 +235,4 @@ let main () = exit 1 ;; -main () +try main () with Sys_error _ | Unix.Unix_error _ -> exit 1 diff --git a/src/ml/GenerateSchemaDirect.ml b/src/ml/GenerateSchemaDirect.ml index 33ab94b8..4aceff8a 100644 --- a/src/ml/GenerateSchemaDirect.ml +++ b/src/ml/GenerateSchemaDirect.ml @@ -10,7 +10,7 @@ type loaded = { type collect_error = {file: string; message: string} -let load_cmt ~package ~moduleName ~sourcePath = +let load_cmt ~context ~package ~moduleName ~sourcePath = match Hashtbl.find_opt package.pathsForModule moduleName with | None -> Error @@ -22,7 +22,7 @@ let load_cmt ~package ~moduleName ~sourcePath = | Some paths -> ( let uri = Uri.fromPath sourcePath in let cmtPath = SharedTypes.getCmtPath ~uri paths in - match CmtDirect.of_path ~moduleName ~path:cmtPath with + match GenerationContext.loadCmt context ~moduleName ~path:cmtPath with | None -> Error {file = cmtPath; message = "Unable to read cmt/cmt[i] file for module."} @@ -46,7 +46,7 @@ let source_is_selected ~includePaths ~excludePaths sourcePath = in included && not excluded -let collect_gql_cmts ~sourceFolder ~includePaths ~excludePaths = +let collect_gql_cmts ~context ~sourceFolder ~includePaths ~excludePaths = let includePaths = List.map canonicalize_path includePaths in let excludePaths = List.map canonicalize_path excludePaths in match Packages.getPackage ~uri:(Uri.fromPath sourceFolder) with @@ -82,7 +82,7 @@ let collect_gql_cmts ~sourceFolder ~includePaths ~excludePaths = BuildSystem.namespacedName package.namespace (FindFiles.getName sourcePath) in - match load_cmt ~package ~moduleName ~sourcePath with + match load_cmt ~context ~package ~moduleName ~sourcePath with | Error err -> errs := err :: !errs | Ok (cmtPath, cmt) -> loaded := {moduleName; sourcePath; cmtPath; cmt} :: !loaded) @@ -94,31 +94,12 @@ let print_collect_errors errs = errs |> List.iter (fun {file; message} -> prerr_endline (file ^ ": " ^ message)) -let with_hooks ~package ~preloaded f = - let cache : (string, SharedTypes.File.t) Hashtbl.t = Hashtbl.create 100 in - (* seed cache with already loaded summaries *) +let with_hooks ~context ~package ~preloaded f = preloaded - |> List.iter (fun (moduleName, file) -> Hashtbl.replace cache moduleName file); - let load_and_cache moduleName = - match Hashtbl.find_opt package.pathsForModule moduleName with - | None -> None - | Some paths -> ( - let uri = SharedTypes.getUri paths in - let cmtPath = SharedTypes.getCmtPath ~uri paths in - match CmtDirect.of_path ~moduleName ~path:cmtPath with - | None -> None - | Some cmt -> - let file = - CmtSummarize.file_from_cmt_infos ~moduleName ~uri - (CmtDirect.infos cmt) - in - Hashtbl.replace cache moduleName file; - Some file) - in + |> List.iter (fun (moduleName, file) -> + GenerationContext.seedSummary context ~moduleName file); let loader ~moduleName = - match Hashtbl.find_opt cache moduleName with - | Some file -> Some file - | None -> load_and_cache moduleName + GenerationContext.loadSummary context ~package ~moduleName in let digHook = DirectReferences.digConstructor ~loader in References.setDigConstructorHook digHook; @@ -131,9 +112,13 @@ let with_hooks ~package ~preloaded f = References.clearDigConstructorHook (); res -let generateSchemaDirect ~printToStdOut ~writeStateFile ~sourceFolder ~debug - ~outputFolder ~writeSdlFile ~schemaName ~moduleName ~contextType - ~includePaths ~excludePaths ~authorizationConfig = +let generateSchemaDirect ?generationContext ~printToStdOut ~writeStateFile + ~sourceFolder ~debug ~outputFolder ~writeSdlFile ~schemaName ~moduleName + ~contextType ~includePaths ~excludePaths ~authorizationConfig () = + let generationContext = + Option.value generationContext ~default:(GenerationContext.create ()) + in + let moduleOutputPaths = [ outputFolder ^ "/" ^ moduleName ^ ".res"; @@ -157,7 +142,9 @@ let generateSchemaDirect ~printToStdOut ~writeStateFile ~sourceFolder ~debug Printf.printf "{\"status\": \"Success\", \"ok\": true}") else let collection = - try collect_gql_cmts ~sourceFolder ~includePaths ~excludePaths + try + collect_gql_cmts ~context:generationContext ~sourceFolder ~includePaths + ~excludePaths with exn -> GenerateSchemaAuthorization.prepareManifest ~outputFolder ~writeSdlFile ~additionalOutputPaths:moduleOutputPaths authorizationConfig; @@ -167,8 +154,21 @@ let generateSchemaDirect ~printToStdOut ~writeStateFile ~sourceFolder ~debug | Error errs -> GenerateSchemaAuthorization.prepareManifest ~outputFolder ~writeSdlFile ~additionalOutputPaths:moduleOutputPaths authorizationConfig; - print_collect_errors errs; - exit 1 + if printToStdOut then + Printf.printf + "{\n\ + \ \"status\": \"Error\",\n\ + \ \"errors\": \n\ + \ [\n\ + \ %s\n\ + \ ]\n\ + }" + (errs + |> List.map (fun {file; message} -> + GenerateSchemaUtils.printDiagnostic + {loc = Location.none; fileUri = Uri.fromPath file; message}) + |> String.concat ",\n") + else print_collect_errors errs | Ok (package, loaded) -> let additionalOutputPaths = moduleOutputPaths @@ -192,7 +192,8 @@ let generateSchemaDirect ~printToStdOut ~writeStateFile ~sourceFolder ~debug (l.moduleName, file)) in ignore - (with_hooks ~package ~preloaded (fun ~loader -> + (with_hooks ~context:generationContext ~package ~preloaded + (fun ~loader -> let projectConfigPath = let rescriptJson = package.rootPath ^ "/rescript.json" in if Files.exists rescriptJson then rescriptJson @@ -252,14 +253,6 @@ let generateSchemaDirect ~printToStdOut ~writeStateFile ~sourceFolder ~debug Option.map (fun _ -> moduleName) schemaName in - (match schemaName with - | Some _ -> - GenerateSchemaTypePrinters.cleanNamedSchemaFiles ~outputFolder - ~moduleName; - if not writeSdlFile then - GenerateSchemaTypePrinters.cleanNamedSchemaSdl ~outputFolder - | None -> ()); - if schemaState.diagnostics |> List.length > 0 then ( if printToStdOut then Printf.printf @@ -275,15 +268,29 @@ let generateSchemaDirect ~printToStdOut ~writeStateFile ~sourceFolder ~debug GenerateSchemaUtils.printDiagnostic diagnostic) |> String.concat ",\n"); - (* Write an empty schema just to avoid type errors in the generated code. *) - GenerateSchemaUtils.writeIfHasChanges schemaOutputPath - (Printf.sprintf - "let schema: ResGraph.schema<%s> = \ - ResGraph__GraphQLJs.GraphQLSchemaType.make(Obj.magic())\n" - contextType - |> markNamedSchemaFile); - GenerateSchemaUtils.writeIfHasChanges resiOutputPath resiContent) + (* Preserve prior successful artifacts; bootstrap only on the first build. *) + if not (Sys.file_exists schemaOutputPath) then + GenerateSchemaUtils.writeIfHasChanges schemaOutputPath + (Printf.sprintf + "let schema: ResGraph.schema<%s> = \ + ResGraph__GraphQLJs.GraphQLSchemaType.make(Obj.magic())\n" + contextType + |> markNamedSchemaFile); + if not (Sys.file_exists resiOutputPath) then + GenerateSchemaUtils.writeIfHasChanges resiOutputPath + resiContent) else + let () = + match schemaName with + | Some _ -> + GenerateSchemaTypePrinters.cleanNamedSchemaFiles + ~outputFolder ~moduleName; + if not writeSdlFile then + GenerateSchemaTypePrinters.cleanNamedSchemaSdl + ~outputFolder + | None -> () + in + let schemaCode = GenerateSchemaTypePrinters.printSchemaJsFile schemaState processedSchema ~interfaceModulePrefix @@ -295,10 +302,6 @@ let generateSchemaDirect ~printToStdOut ~writeStateFile ~sourceFolder ~debug GenerateSchemaTypePrinters.printInterfaceFiles schemaState ~processedSchema ~outputFolder ~interfaceModulePrefix; - if writeStateFile then - GenerateSchemaUtils.writeStateFile ?schemaName ~package - ~schemaState ~processedSchema (); - (if writeSdlFile then let sdl = GenerateSchemaSDL.printSchemaSDL schemaState in let sdl = @@ -313,6 +316,10 @@ let generateSchemaDirect ~printToStdOut ~writeStateFile ~sourceFolder ~debug GenerateSchemaUtils.writeIfHasChanges resiOutputPath resiContent; GenerateSchemaAuthorization.writeBaseline schemaState; + + if writeStateFile then + GenerateSchemaUtils.writeStateFile ?schemaName ~package + ~schemaState ~processedSchema (); GenerateSchemaAuthorization.writeManifest ~package schemaState; if cacheEnabled then diff --git a/src/ml/GenerateSchemaTypePrinters.ml b/src/ml/GenerateSchemaTypePrinters.ml index 3c4baeed..b3d6abc0 100644 --- a/src/ml/GenerateSchemaTypePrinters.ml +++ b/src/ml/GenerateSchemaTypePrinters.ml @@ -581,6 +581,11 @@ let printInterfaceFiles (schemaState : schemaState) ~processedSchema writeIfHasChanges interfaceFileOutputLoc (getIntfAssets intf ~processedSchema)) +let isOwnedGeneratedInterface path = + match Files.readFile path with + | Some contents -> String.starts_with contents ~prefix:"/* @generated */" + | None -> false + let cleanInterfaceFiles (schemaState : schemaState) ~outputFolder ~interfaceModulePrefix = let validNames = @@ -601,7 +606,8 @@ let cleanInterfaceFiles (schemaState : schemaState) ~outputFolder && String.starts_with (Filename.basename fileName) ~prefix:generatedPrefix - && not (List.mem fileName validNames)) + && (not (List.mem fileName validNames)) + && isOwnedGeneratedInterface (Filename.concat outputFolder fileName)) in filesToRemove |> List.iter (fun fileName -> diff --git a/src/ml/GenerateSchemaUtils.ml b/src/ml/GenerateSchemaUtils.ml index dcdba004..21e4e694 100644 --- a/src/ml/GenerateSchemaUtils.ml +++ b/src/ml/GenerateSchemaUtils.ml @@ -460,7 +460,7 @@ let addUnion id ~(makeUnion : unit -> gqlUnion) ~debug ~schemaState = let addInputUnion id ~(makeInputUnion : unit -> gqlInputUnionType) ~debug ~schemaState = - if Hashtbl.mem schemaState.unions id then () + if Hashtbl.mem schemaState.inputUnions id then () else ( if debug then Printf.printf "Adding input union %s\n" id; Hashtbl.replace schemaState.inputUnions id (makeInputUnion ())) @@ -1260,6 +1260,20 @@ let isFileContentsTheSame filePath s = with Sys_error _ -> false let gqlRegexp = Str.regexp_string "@gql." +let removeTemporaryFile path = try Sys.remove path with Sys_error _ -> () + +let writeAtomically path contents = + let temporaryPath = Printf.sprintf "%s.%d.tmp" path (Unix.getpid ()) in + let outputChannel = open_out_bin temporaryPath in + try + output_string outputChannel contents; + flush outputChannel; + close_out outputChannel; + Unix.rename temporaryPath path + with exn -> + close_out_noerr outputChannel; + removeTemporaryFile temporaryPath; + raise exn let hasGqlAttribute str = try @@ -1285,17 +1299,14 @@ let fileHasGqlAttribute filePath = let writeIfHasChanges path contents = if isFileContentsTheSame path contents then () else - try - let oc = open_out path in - - output_string oc contents; - close_out oc - with Sys_error _ -> - Printf.printf - "Something went wrong trying to write to \"%s\". Make sure the \ - directory actually exists." - path; - exit 1 + try writeAtomically path contents + with (Sys_error _ | Unix.Unix_error _) as exn -> + prerr_endline + (Printf.sprintf + "Something went wrong trying to write to \"%s\". Make sure the \ + directory actually exists." + path); + raise exn type persistedSchemaState = { version: int; @@ -1334,18 +1345,20 @@ let writeStateFile ?schemaName ~package ~schemaState ~processedSchema () = (match schemaName with | None -> () | Some _ -> ensureStateDirectory package); - let ch = open_out_bin (getStateFilePath ?schemaName package) in - output_string ch stateFileMagic; - (match schemaName with - | None -> - Marshal.to_channel ch - {version = stateFileVersion; schemaState; processedSchema} - [Compat_32] - | Some schemaName -> - Marshal.to_channel ch - {version = stateFileVersion; schemaName; schemaState; processedSchema} - [Compat_32]); - close_out ch + let payload = + match schemaName with + | None -> + Marshal.to_string + {version = stateFileVersion; schemaState; processedSchema} + [Compat_32] + | Some schemaName -> + Marshal.to_string + {version = stateFileVersion; schemaName; schemaState; processedSchema} + [Compat_32] + in + writeAtomically + (getStateFilePath ?schemaName package) + (stateFileMagic ^ payload) let readStateFile ?schemaName ~package () = let ch = open_in_bin (getStateFilePath ?schemaName package) in diff --git a/src/ml/GenerateSchemaValidation.ml b/src/ml/GenerateSchemaValidation.ml index 4e3c30d0..74755f02 100644 --- a/src/ml/GenerateSchemaValidation.ml +++ b/src/ml/GenerateSchemaValidation.ml @@ -314,6 +314,80 @@ let validateInterfaceImplementationCycles (schemaState : schemaState) = typ.displayName; }) +type graphqlTypeNameEntry = { + name: string; + kind: string; + loc: Location.t; + fileUri: Uri.t; +} + +let validateTypeNameUniqueness (schemaState : schemaState) = + let entries = ref [] in + let add ~name ~kind ~loc ~fileUri = + entries := {name; kind; loc; fileUri} :: !entries + in + let addTypeLocation ~name ~kind = function + | Concrete typeLocation -> + add ~name ~kind ~loc:typeLocation.loc ~fileUri:typeLocation.fileUri + | Synthetic {fileUri} -> add ~name ~kind ~loc:emptyLoc ~fileUri + in + schemaState.types + |> Hashtbl.iter (fun _name (typ : gqlObjectType) -> + match typ.typeLocation with + | None -> () + | Some location -> + addTypeLocation ~name:typ.displayName ~kind:"object type" location); + schemaState.inputObjects + |> Hashtbl.iter (fun _name (typ : gqlInputObjectType) -> + match typ.typeLocation with + | None -> () + | Some location -> + add ~name:typ.displayName ~kind:"input object" ~loc:location.loc + ~fileUri:location.fileUri); + schemaState.inputUnions + |> Hashtbl.iter (fun _name (typ : gqlInputUnionType) -> + add ~name:typ.displayName ~kind:"input union" ~loc:typ.typeLocation.loc + ~fileUri:typ.typeLocation.fileUri); + schemaState.enums + |> Hashtbl.iter (fun _name (typ : gqlEnum) -> + addTypeLocation ~name:typ.displayName ~kind:"enum" typ.typeLocation); + schemaState.unions + |> Hashtbl.iter (fun _name (typ : gqlUnion) -> + addTypeLocation ~name:typ.displayName ~kind:"union" typ.typeLocation); + schemaState.interfaces + |> Hashtbl.iter (fun _name (typ : gqlInterface) -> + add ~name:typ.displayName ~kind:"interface" ~loc:typ.typeLocation.loc + ~fileUri:typ.typeLocation.fileUri); + schemaState.scalars + |> Hashtbl.iter (fun _name (typ : gqlScalar) -> + add ~name:typ.displayName ~kind:"scalar" ~loc:typ.typeLocation.loc + ~fileUri:typ.typeLocation.fileUri); + let registered = Hashtbl.create (List.length !entries) in + !entries + |> List.sort (fun left right -> + compare + (left.name, left.kind, Uri.toPath left.fileUri, Loc.toString left.loc) + ( right.name, + right.kind, + Uri.toPath right.fileUri, + Loc.toString right.loc )) + |> List.iter (fun entry -> + match Hashtbl.find_opt registered entry.name with + | None -> Hashtbl.add registered entry.name entry + | Some previous -> + schemaState + |> addDiagnostic + ~diagnostic: + { + loc = entry.loc; + fileUri = entry.fileUri; + message = + Printf.sprintf + "GraphQL type name `%s` is used by both a %s and a %s. \ + Type names must be unique across all GraphQL kinds." + entry.name previous.kind entry.kind; + }) + let validateSchema (schemaState : schemaState) = validateRootTypes schemaState; validateInterfaceImplementationCycles schemaState; @@ -336,6 +410,7 @@ let validateSchema (schemaState : schemaState) = (* No need to validate each case, ReScript has already done it for us. *) validateName ~name:typ.displayName ~typeLocation:typ.typeLocation schemaState); + validateTypeNameUniqueness schemaState; schemaState.unions |> Hashtbl.iter (fun _name (typ : gqlUnion) -> diff --git a/src/ml/GenerationContext.ml b/src/ml/GenerationContext.ml new file mode 100644 index 00000000..7966c1e3 --- /dev/null +++ b/src/ml/GenerationContext.ml @@ -0,0 +1,43 @@ +type t = { + cmts: (string, CmtDirect.t option) Hashtbl.t; + summaries: (string, SharedTypes.File.t option) Hashtbl.t; +} + +let create () = {cmts = Hashtbl.create 128; summaries = Hashtbl.create 128} + +let canonicalize path = + try Unix.realpath path + with _ -> + if Filename.is_relative path then Filename.concat (Sys.getcwd ()) path + else path + +let loadCmt context ~moduleName ~path = + let key = canonicalize path in + match Hashtbl.find_opt context.cmts key with + | Some result -> result + | None -> + let result = CmtDirect.of_path ~moduleName ~path in + Hashtbl.replace context.cmts key result; + result + +let seedSummary context ~moduleName file = + Hashtbl.replace context.summaries moduleName (Some file) + +let loadSummary context ~(package : SharedTypes.package) ~moduleName = + match Hashtbl.find_opt context.summaries moduleName with + | Some result -> result + | None -> + let result = + match Hashtbl.find_opt package.pathsForModule moduleName with + | None -> None + | Some paths -> + let uri = SharedTypes.getUri paths in + let cmtPath = SharedTypes.getCmtPath ~uri paths in + Option.map + (fun cmt -> + CmtSummarize.file_from_cmt_infos ~moduleName ~uri + (CmtDirect.infos cmt)) + (loadCmt context ~moduleName ~path:cmtPath) + in + Hashtbl.replace context.summaries moduleName result; + result diff --git a/src/ml/GenerationContext.mli b/src/ml/GenerationContext.mli new file mode 100644 index 00000000..5579cbac --- /dev/null +++ b/src/ml/GenerationContext.mli @@ -0,0 +1,10 @@ +type t + +val create : unit -> t +val loadCmt : t -> moduleName:string -> path:string -> CmtDirect.t option +val seedSummary : t -> moduleName:string -> SharedTypes.File.t -> unit +val loadSummary : + t -> + package:SharedTypes.package -> + moduleName:string -> + SharedTypes.File.t option diff --git a/src/ml/dune b/src/ml/dune index b8c47090..ea58a8ba 100644 --- a/src/ml/dune +++ b/src/ml/dune @@ -1,11 +1,10 @@ -(executable - (public_name resgraph) - (name Cli) - (modes byte exe) +(library + (name resgraph_engine) + (wrapped true) (flags (-w "+6+26+27+32+33+39")) (libraries unix str ext ml jsonlib syntax) - (modules Cli CodeWriter GenerateSchema GenerateSchemaDirect + (modules CodeWriter GenerateSchema GenerateSchemaDirect GenerationContext GenerateSchemaTypes GenerateSchemaAuthorization GenerateSchemaUtils GenerateSchemaDiagnostics GenerateSchemaSDL GenerateSchemaCache GenerateSchemaValidation GenerateSchemaTypePrinters ProcessAttributes @@ -13,3 +12,19 @@ ModuleResolution BuildSystem Cache Cfg CmtDirect CmtSummarize DirectResolve DirectReferences Debug Log PrintType Protocol References Completion Hover Analyze Markdown FindFiles)) + +(executable + (public_name resgraph) + (name Cli) + (modes byte exe) + (flags + (-w "+6+26+27+32+33+39")) + (libraries resgraph_engine) + (modules Cli)) + +(test + (name ArchitectureTests) + (flags + (-w "+6+26+27+32+33+39")) + (modules ArchitectureTests) + (libraries resgraph_engine)) diff --git a/src/res/DataLoader.mjs b/src/res/DataLoader.mjs index d79a4421..bc117600 100644 --- a/src/res/DataLoader.mjs +++ b/src/res/DataLoader.mjs @@ -41,6 +41,25 @@ function loadMany(lazyLoader, keys) { return loader.loadMany(keys); } +let isError = (value => value instanceof Error); + +function loadManyResults(lazyLoader, keys) { + let loader = Stdlib_Lazy.get(lazyLoader); + return loader.loadMany(keys).then(values => values.map(value => { + if (isError(value)) { + return { + TAG: "Error", + _0: value + }; + } else { + return { + TAG: "Ok", + _0: value + }; + } + })); +} + function clear(lazyLoader, key) { let loader = Stdlib_Lazy.get(lazyLoader); loader.clear(key); @@ -56,11 +75,21 @@ function prime(lazyLoader, value) { loader.prime(value); } +function primeAt(lazyLoader, key, value) { + let loader = Stdlib_Lazy.get(lazyLoader); + loader.prime(key, value); +} + function primeWithPromise(lazyLoader, value) { let loader = Stdlib_Lazy.get(lazyLoader); loader.prime(value); } +function primeWithPromiseAt(lazyLoader, key, value) { + let loader = Stdlib_Lazy.get(lazyLoader); + loader.prime(key, value); +} + function name(lazyLoader) { let loader = Stdlib_Lazy.get(lazyLoader); return Primitive_option.fromNullable(loader.name); @@ -72,10 +101,13 @@ export { makeBatched, load, loadMany, + loadManyResults, clear, clearAll, prime, + primeAt, primeWithPromise, + primeWithPromiseAt, name, } /* dataloader Not a pure module */ diff --git a/src/res/DataLoader.res b/src/res/DataLoader.res index 97eb40b2..42cb36c0 100644 --- a/src/res/DataLoader.res +++ b/src/res/DataLoader.res @@ -40,6 +40,8 @@ module Plain = { type batchFn<'key, 'value> = array<'key> => promise> + type rawLoadManyEntry<'value> + @new @module("dataloader") external make: (batchFn<'key, 'value>, ~options: options=?) => t<'key, 'value> = "default" @@ -54,6 +56,10 @@ module Plain = { */ @send external loadMany: (t<'key, 'value>, array<'key>) => promise> = "loadMany" + @send + external loadManyRaw: (t<'key, 'value>, array<'key>) => promise>> = + "loadMany" + /** * Clears the value at `key` from the cache, if it exists. @@ -74,6 +80,9 @@ module Plain = { */ @send external prime: (t<'key, 'value>, 'value) => unit = "prime" + @send + external primeAt: (t<'key, 'value>, 'key, 'value) => unit = "prime" + /** * Adds the provided key and (promised) value to the cache. If the key already exists, no @@ -81,6 +90,9 @@ module Plain = { */ @send external primeWithPromise: (t<'key, 'value>, promise<'value>) => unit = "prime" + @send + external primeWithPromiseAt: (t<'key, 'value>, 'key, promise<'value>) => unit = "prime" + /** * The name given to this `DataLoader` instance, if set. Useful for APM tools.. @@ -147,6 +159,25 @@ let loadMany = (lazyLoader, keys) => { loader->Plain.loadMany(keys) } + +let isError: Plain.rawLoadManyEntry<'value> => bool = %raw(`value => value instanceof Error`) +external rawLoadManyEntryToValue: Plain.rawLoadManyEntry<'value> => 'value = "%identity" +external rawLoadManyEntryToError: Plain.rawLoadManyEntry<'value> => exn = "%identity" + +let loadManyResults = (lazyLoader, keys) => { + let loader = lazyLoader->Lazy.get + loader + ->Plain.loadManyRaw(keys) + ->Promise.thenResolve(values => + values->Array.map(value => + if isError(value) { + Error(value->rawLoadManyEntryToError) + } else { + Ok(value->rawLoadManyEntryToValue) + } + ) + ) +} let clear = (lazyLoader, key) => { let loader = lazyLoader->Lazy.get loader->Plain.clear(key) @@ -162,11 +193,21 @@ let prime = (lazyLoader, value) => { loader->Plain.prime(value) } +let primeAt = (lazyLoader, ~key, ~value) => { + let loader = lazyLoader->Lazy.get + loader->Plain.primeAt(key, value) +} + let primeWithPromise = (lazyLoader, value) => { let loader = lazyLoader->Lazy.get loader->Plain.primeWithPromise(value) } +let primeWithPromiseAt = (lazyLoader, ~key, ~value) => { + let loader = lazyLoader->Lazy.get + loader->Plain.primeWithPromiseAt(key, value) +} + let name = lazyLoader => { let loader = lazyLoader->Lazy.get loader->Plain.name diff --git a/src/res/DataLoader.resi b/src/res/DataLoader.resi index 2f7192d4..bb594a82 100644 --- a/src/res/DataLoader.resi +++ b/src/res/DataLoader.resi @@ -71,14 +71,23 @@ module Plain: { */ @send external prime: (t<'key, 'value>, 'value) => unit = "prime" + /** Adds a value to the cache at the provided key. */ + @send + external primeAt: (t<'key, 'value>, 'key, 'value) => unit = "prime" + /** - * Adds the provided key and (promised) value to the cache. If the key already exists, no + * Adds the provided key and (promised) value to the cache. If the key already * change is made. */ @send external primeWithPromise: (t<'key, 'value>, promise<'value>) => unit = "prime" + + /** Adds a promised value to the cache at the provided key. */ + @send + external primeWithPromiseAt: (t<'key, 'value>, 'key, promise<'value>) => unit = "prime" + /** - * The name given to this `DataLoader` instance, if set. Useful for APM tools.. + * The name given to this `DataLoader` instance, if set. Useful for APM tools. */ @get @return(nullable) external name: t<'key, 'value> => option = "name" @@ -119,6 +128,9 @@ let load: (t<'key, 'value>, 'key) => promise<'value> */ let loadMany: (t<'key, 'value>, array<'key>) => promise> + +/** Loads multiple keys while preserving DataLoader's per-entry errors. */ +let loadManyResults: (t<'key, 'value>, array<'key>) => promise>> /** * Clears the value at `key` from the cache, if it exists. */ @@ -136,12 +148,18 @@ let clearAll: t<'key, 'value> => unit */ let prime: (t<'key, 'value>, 'value) => unit + +/** Adds a value to the cache at the provided key. */ +let primeAt: (t<'key, 'value>, ~key: 'key, ~value: 'value) => unit /** * Adds the provided key and (promised) value to the cache. If the key already exists, no * change is made. */ let primeWithPromise: (t<'key, 'value>, promise<'value>) => unit + +/** Adds a promised value to the cache at the provided key. */ +let primeWithPromiseAt: (t<'key, 'value>, ~key: 'key, ~value: promise<'value>) => unit /** * The name given to this `DataLoader` instance, if set. Useful for APM tools.. */ diff --git a/src/res/GraphQLYoga.res b/src/res/GraphQLYoga.res index b0d1e679..04296284 100644 --- a/src/res/GraphQLYoga.res +++ b/src/res/GraphQLYoga.res @@ -5,18 +5,16 @@ module FormData = Fetch.FormData module Blob = Fetch.Blob module GraphQLError = { - type t + type t = exn type options<'extensions> = {extensions?: {..} as 'extensions} @module("graphql") @new external make: (string, ~options: options<'extensions>=?) => t = "GraphQLError" - let raise: t => 'a = err => throw(Obj.magic(err)) + let raise: t => 'a = err => throw(err) } module Envelope = { - type plugin - module Plugin = { type t @@ -34,6 +32,8 @@ module Envelope = { external use: config => t = "useExtendedValidation" } } + + type plugin = Plugin.t } module Server = { diff --git a/src/res/ResGraph.mjs b/src/res/ResGraph.mjs index 6600dabf..6a3b2167 100644 --- a/src/res/ResGraph.mjs +++ b/src/res/ResGraph.mjs @@ -1,8 +1,8 @@ // Generated by ReScript, PLEASE EDIT WITH CARE import * as Graphql from "graphql"; -import * as Nodecrypto from "node:crypto"; import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js"; +import * as ResGraph__ExecuteRuntimeMjs from "./ResGraph__ExecuteRuntime.mjs"; function makeError(message, code) { return { @@ -32,32 +32,40 @@ let Authorization = { raiseForbidden: raiseForbidden }; -function hashQuery(query) { - return Nodecrypto.createHash("sha256").update(query).digest("hex"); +function makeQueryDocumentCache(prim) { + return ResGraph__ExecuteRuntimeMjs.createQueryDocumentCache(); } -function makeQueryDocumentCache() { - return {}; +function makeQueryDocumentCacheWithMaxSize(prim) { + return ResGraph__ExecuteRuntimeMjs.createQueryDocumentCache(prim); } -function setCachedQuery(cache, query, document) { - cache[hashQuery(query)] = document; +function setCachedQuery(prim0, prim1, prim2) { + ResGraph__ExecuteRuntimeMjs.setCachedQuery(prim0, prim1, prim2); } -function getCachedQuery(cache, query) { - return cache[hashQuery(query)]; +function getCachedQuery(prim0, prim1) { + return ResGraph__ExecuteRuntimeMjs.getCachedQuery(prim0, prim1); } function parseQueryCached(cache, query) { - let document = cache[hashQuery(query)]; + let document = ResGraph__ExecuteRuntimeMjs.getCachedQuery(cache, query); if (document !== undefined) { return Primitive_option.valFromOption(document); } let document$1 = Graphql.parse(query); - cache[hashQuery(query)] = document$1; + setCachedQuery(cache, query, document$1); return document$1; } +function invalidVariablesJson() { + return { + errors: [{ + message: "GraphQL variables must be a JSON object or null." + }] + }; +} + function variablesFromJson(json) { if (json === null || typeof json !== "object" || Array.isArray(json)) { return; @@ -71,7 +79,7 @@ function variablesToJson(variables) { } function executeParsed(schema, document, contextValue, variableValues, operationName, rootValue) { - return Graphql.execute({ + return ResGraph__ExecuteRuntimeMjs.executeValidated({ schema: schema, document: document, contextValue: contextValue, @@ -82,18 +90,32 @@ function executeParsed(schema, document, contextValue, variableValues, operation } function executeParsedToJson(schema, document, contextValue, variablesJson, operationName, rootValue) { - let variableValues = variablesJson !== undefined ? variablesFromJson(variablesJson) : undefined; - return executeParsed(schema, document, contextValue, variableValues, operationName, rootValue).then(prim => prim); + if (variablesJson !== undefined && variablesJson !== null) { + let variableValues = variablesFromJson(variablesJson); + if (variableValues !== undefined) { + return executeParsed(schema, document, contextValue, variableValues, operationName, rootValue).then(prim => prim); + } else { + return Promise.resolve(invalidVariablesJson()); + } + } + return executeParsed(schema, document, contextValue, undefined, operationName, rootValue).then(prim => prim); } function execute(schema, query, contextValue, cache, variableValues, operationName, rootValue) { - let document = cache !== undefined ? parseQueryCached(cache, query) : Graphql.parse(query); + let document = cache !== undefined ? parseQueryCached(Primitive_option.valFromOption(cache), query) : Graphql.parse(query); return executeParsed(schema, document, contextValue, variableValues, operationName, rootValue); } function executeToJson(schema, query, contextValue, cache, variablesJson, operationName, rootValue) { - let variableValues = variablesJson !== undefined ? variablesFromJson(variablesJson) : undefined; - return execute(schema, query, contextValue, cache, variableValues, operationName, rootValue).then(prim => prim); + if (variablesJson !== undefined && variablesJson !== null) { + let variableValues = variablesFromJson(variablesJson); + if (variableValues !== undefined) { + return execute(schema, query, contextValue, cache, variableValues, operationName, rootValue).then(prim => prim); + } else { + return Promise.resolve(invalidVariablesJson()); + } + } + return execute(schema, query, contextValue, cache, undefined, operationName, rootValue).then(prim => prim); } function Execute_parseQuery(prim) { @@ -107,6 +129,7 @@ function Execute_executionResultToJson(prim) { let Execute = { parseQuery: Execute_parseQuery, makeQueryDocumentCache: makeQueryDocumentCache, + makeQueryDocumentCacheWithMaxSize: makeQueryDocumentCacheWithMaxSize, setCachedQuery: setCachedQuery, getCachedQuery: getCachedQuery, parseQueryCached: parseQueryCached, diff --git a/src/res/ResGraph.res b/src/res/ResGraph.res index d5a0165e..b9d26bcf 100644 --- a/src/res/ResGraph.res +++ b/src/res/ResGraph.res @@ -53,6 +53,7 @@ module Execute: { let parseQuery: string => document let makeQueryDocumentCache: unit => queryDocumentCache + let makeQueryDocumentCacheWithMaxSize: (~maxSize: int) => queryDocumentCache let setCachedQuery: (~cache: queryDocumentCache, ~query: string, ~document: document) => unit let getCachedQuery: (~cache: queryDocumentCache, ~query: string) => option let parseQueryCached: (~cache: queryDocumentCache, ~query: string) => document @@ -109,7 +110,7 @@ module Execute: { type jsonExecutionResult = executionResult type variables = Dict.t - type queryDocumentCache = Dict.t + type queryDocumentCache type executeArgs<'appContext, 'rootValue> = { schema: schema<'appContext>, @@ -122,37 +123,45 @@ module Execute: { @module("graphql") external parseQuery: string => document = "parse" - @module("graphql") + @module("./ResGraph__ExecuteRuntime.mjs") external executeInternal: executeArgs<'appContext, 'rootValue> => promise< executionResult<'data, 'error, 'extensions>, - > = "execute" + > = "executeValidated" - @module("node:crypto") external createHash: 'a = "createHash" external variablesOfJsonObject: dict => variables = "%identity" external variablesToJsonObject: variables => dict = "%identity" external executionResultToJson: jsonExecutionResult => JSON.t = "%identity" - let hashQuery = query => createHash("sha256")["update"](query)["digest"]("hex") - - let cacheKeyForQuery = query => hashQuery(query) - - let makeQueryDocumentCache = () => Dict.make() - - let setCachedQuery = (~cache, ~query, ~document) => { - cache->Dict.set(query->cacheKeyForQuery, document) - } - - let getCachedQuery = (~cache, ~query) => cache->Dict.get(query->cacheKeyForQuery) + @module("./ResGraph__ExecuteRuntime.mjs") + external makeQueryDocumentCache: unit => queryDocumentCache = "createQueryDocumentCache" + @module("./ResGraph__ExecuteRuntime.mjs") + external makeQueryDocumentCacheWithMaxSize: (~maxSize: int) => queryDocumentCache = + "createQueryDocumentCache" + @module("./ResGraph__ExecuteRuntime.mjs") + external setCachedQuery: (~cache: queryDocumentCache, ~query: string, ~document: document) => unit = + "setCachedQuery" + @module("./ResGraph__ExecuteRuntime.mjs") + external getCachedQuery: (~cache: queryDocumentCache, ~query: string) => option = + "getCachedQuery" let parseQueryCached = (~cache, ~query) => - switch cache->Dict.get(query->cacheKeyForQuery) { + switch getCachedQuery(~cache, ~query) { | Some(document) => document | None => let document = parseQuery(query) - cache->Dict.set(query->cacheKeyForQuery, document) + setCachedQuery(~cache, ~query, ~document) document } + let invalidVariablesJson = () => + JSON.Object(dict{ + "errors": JSON.Array([ + JSON.Object(dict{ + "message": JSON.String("GraphQL variables must be a JSON object or null."), + }), + ]), + }) + let variablesFromJson = json => switch json { | JSON.Object(jsonObject) => Some(jsonObject->variablesOfJsonObject) @@ -187,19 +196,29 @@ module Execute: { ~operationName=?, ~rootValue=?, ) => { - let variableValues = switch variablesJson { - | Some(json) => json->variablesFromJson - | None => None + switch variablesJson { + | Some(JSON.Null) | None => + executeParsed( + schema, + ~document, + ~contextValue, + ~operationName?, + ~rootValue?, + )->Promise.thenResolve(executionResultToJson) + | Some(json) => + switch json->variablesFromJson { + | Some(variableValues) => + executeParsed( + schema, + ~document, + ~contextValue, + ~variableValues, + ~operationName?, + ~rootValue?, + )->Promise.thenResolve(executionResultToJson) + | None => Promise.resolve(invalidVariablesJson()) + } } - - executeParsed( - schema, - ~document, - ~contextValue, - ~variableValues?, - ~operationName?, - ~rootValue?, - )->Promise.thenResolve(executionResultToJson) } let execute = ( @@ -228,20 +247,31 @@ module Execute: { ~operationName=?, ~rootValue=?, ) => { - let variableValues = switch variablesJson { - | Some(json) => json->variablesFromJson - | None => None + switch variablesJson { + | Some(JSON.Null) | None => + execute( + schema, + ~query, + ~contextValue, + ~cache?, + ~operationName?, + ~rootValue?, + )->Promise.thenResolve(executionResultToJson) + | Some(json) => + switch json->variablesFromJson { + | Some(variableValues) => + execute( + schema, + ~query, + ~contextValue, + ~cache?, + ~variableValues, + ~operationName?, + ~rootValue?, + )->Promise.thenResolve(executionResultToJson) + | None => Promise.resolve(invalidVariablesJson()) + } } - - execute( - schema, - ~query, - ~contextValue, - ~cache?, - ~variableValues?, - ~operationName?, - ~rootValue?, - )->Promise.thenResolve(executionResultToJson) } } diff --git a/src/res/ResGraph__ExecuteRuntime.mjs b/src/res/ResGraph__ExecuteRuntime.mjs new file mode 100644 index 00000000..372631a6 --- /dev/null +++ b/src/res/ResGraph__ExecuteRuntime.mjs @@ -0,0 +1,53 @@ +import {execute, validate} from "graphql"; + +const validatedDocumentsBySchema = new WeakMap(); +export function createQueryDocumentCache(maxSize = 100) { + if (!Number.isSafeInteger(maxSize) || maxSize < 1) { + throw new RangeError("ResGraph query cache maxSize must be a positive integer."); + } + return {maxSize, documents: new Map()}; +} + +export function getCachedQuery(cache, query) { + const document = cache.documents.get(query); + if (document !== undefined) { + cache.documents.delete(query); + cache.documents.set(query, document); + } + return document; +} + +export function setCachedQuery(cache, query, document) { + if (cache.documents.has(query)) { + cache.documents.delete(query); + } else if (cache.documents.size >= cache.maxSize) { + const oldestQuery = cache.documents.keys().next().value; + cache.documents.delete(oldestQuery); + } + cache.documents.set(query, document); +} + + +function validationErrors(schema, document) { + let validatedDocuments = validatedDocumentsBySchema.get(schema); + if (validatedDocuments?.has(document)) { + return []; + } + + const errors = validate(schema, document); + if (errors.length === 0) { + if (validatedDocuments === undefined) { + validatedDocuments = new WeakSet(); + validatedDocumentsBySchema.set(schema, validatedDocuments); + } + validatedDocuments.add(document); + } + return errors; +} + +export function executeValidated(args) { + return Promise.resolve().then(() => { + const errors = validationErrors(args.schema, args.document); + return errors.length > 0 ? {errors} : execute(args); + }); +} diff --git a/src/res/stableStringify.mjs b/src/res/stableStringify.mjs index f437739a..c4925bd1 100644 --- a/src/res/stableStringify.mjs +++ b/src/res/stableStringify.mjs @@ -14,11 +14,10 @@ export function stableStringify(data) { let i; let out; if (Array.isArray(node)) { - let sortedNode = node.slice().sort(); out = "["; - for (i = 0; i < sortedNode.length; i++) { + for (i = 0; i < node.length; i++) { if (i) out += ","; - out += stringify(sortedNode[i]) || "null"; + out += stringify(node[i]) || "null"; } return out + "]"; } diff --git a/tests/Makefile b/tests/Makefile index 5993ad83..955be3c6 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -1,7 +1,7 @@ SHELL = /bin/bash node_modules/.bin/rescript: - npm install + npm ci build: node_modules/.bin/rescript node_modules/.bin/rescript @@ -17,7 +17,7 @@ test: build authorization/run-tests.sh clean: - rm -r node_modules lib + rm -rf node_modules lib .DEFAULT_GOAL := test diff --git a/tests/multi-schema-test.sh b/tests/multi-schema-test.sh index 89787b20..b239299d 100755 --- a/tests/multi-schema-test.sh +++ b/tests/multi-schema-test.sh @@ -288,7 +288,7 @@ failure_output=$(cd "$fixture_dir/failure" && node "$cli" build 2>&1) failure_status=$? set -e [[ $failure_status -ne 0 ]] -[[ "$failure_output" == *'[uncompiled] Generator process failed.'* ]] +[[ "$failure_output" == *'[uncompiled] Schema generation failed.'* ]] [[ "$failure_output" == *'[broken] Schema generation failed.'* ]] [[ "$failure_output" == *'[admin] Build succeeded'* ]] rm -f "$fixture_dir/src/generated/broken/BrokenSchema.res" "$fixture_dir/src/generated/broken/BrokenSchema.resi" @@ -301,7 +301,7 @@ for cmt in \ printf 'x' >"$cmt" fi done -capture_watch_output "$fixture_dir/failure" '[uncompiled] Generator process failed.' uncompiled +capture_watch_output "$fixture_dir/failure" '[uncompiled] Schema generation failed.' uncompiled [[ "$watch_output" != *'Build succeeded'* ]] rm -rf "$fixture_dir/uncompiled/lib" diff --git a/tests/runtime-bindings.mjs b/tests/runtime-bindings.mjs new file mode 100644 index 00000000..56a6fbc1 --- /dev/null +++ b/tests/runtime-bindings.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { + GraphQLInt, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, +} from "../node_modules/graphql/index.js"; +import * as DataLoader from "../src/res/DataLoader.mjs"; +import {Execute} from "../src/res/ResGraph.mjs"; +import {stableStringify} from "../src/res/stableStringify.mjs"; + +assert.notEqual( + stableStringify([1, 2]), + stableStringify([2, 1]), + "stable DataLoader keys must preserve array order", +); + +let loadCount = 0; +const loader = DataLoader.makeBatched(async keys => { + loadCount += keys.length; + return keys.map(key => + key === "error" + ? new Error("expected loader error") + : `loaded:${key}`, + ); +}); + +DataLoader.primeAt(loader, "primed", "from-cache"); +assert.equal(await DataLoader.load(loader, "primed"), "from-cache"); +assert.equal(loadCount, 0, "primeAt must populate the requested key"); + +const loadManyResults = await DataLoader.loadManyResults(loader, ["ok", "error"]); +assert.deepEqual(loadManyResults[0], {TAG: "Ok", _0: "loaded:ok"}); +assert.equal(loadManyResults[1].TAG, "Error"); +assert.match(loadManyResults[1]._0.message, /expected loader error/); + +const queryType = new GraphQLObjectType({ + name: "Query", + fields: { + greeting: { + type: GraphQLString, + resolve: () => "hello", + }, + echo: { + type: GraphQLInt, + args: {value: {type: GraphQLInt}}, + resolve: (_source, args) => args.value, + }, + }, +}); +const schema = new GraphQLSchema({query: queryType}); + +const synchronousResult = await Execute.executeToJson(schema, "{ greeting }", undefined); +assert.equal(synchronousResult.data.greeting, "hello"); + +const boundedCache = Execute.makeQueryDocumentCacheWithMaxSize(1); +const firstQuery = "{ greeting }"; +const secondQuery = "query EchoAgain { echo(value: 7) }"; +Execute.parseQueryCached(boundedCache, firstQuery); +Execute.parseQueryCached(boundedCache, secondQuery); +assert.equal(Execute.getCachedQuery(boundedCache, firstQuery), undefined); +assert.notEqual(Execute.getCachedQuery(boundedCache, secondQuery), undefined); +assert.throws(() => Execute.makeQueryDocumentCacheWithMaxSize(0), /positive integer/); + +const query = "query Echo($value: Int) { echo(value: $value) }"; +const cache = Execute.makeQueryDocumentCache(); +const variablesResult = await Execute.executeToJson(schema, query, undefined, cache, {value: 42}); +assert.equal(variablesResult.data.echo, 42); +assert.notEqual(Execute.getCachedQuery(cache, query), undefined); + +const invalidQueryResult = await Execute.executeToJson(schema, "{ missing }", undefined); +assert.match(invalidQueryResult.errors[0].message, /Cannot query field "missing"/); + +assert.deepEqual( + await Execute.executeToJson(schema, "{ greeting }", undefined, undefined, []), + {errors: [{message: "GraphQL variables must be a JSON object or null."}]}, +); + +console.log("Runtime binding fixtures passed."); diff --git a/tests/test.sh b/tests/test.sh index fc1dc46f..66f8871c 100755 --- a/tests/test.sh +++ b/tests/test.sh @@ -1,5 +1,59 @@ #!/usr/bin/env bash +set -euo pipefail + +alternateExecutable="" +configBackup="" +symlinkTargets="" +createdBsconfig=false +dependencyConfig="" +dependencyConfigBackup="" +hiddenDependency="" +hiddenConfigBackup="" +compiledConfigBackup="" +sourceBackup="" +schemaBackup="" + +cleanup() { + if [[ -n $alternateExecutable ]]; then + rm -f "$alternateExecutable" + fi + if [[ -n $configBackup && -f $configBackup ]]; then + cp "$configBackup" ./rescript.json + rm -f "$configBackup" ./cache-link-src + fi + if [[ -n $symlinkTargets && -d $symlinkTargets ]]; then + rm -rf "$symlinkTargets" + fi + if [[ $createdBsconfig == true ]]; then + rm -f ./bsconfig.json + fi + if [[ -n $dependencyConfigBackup && -f $dependencyConfigBackup ]]; then + cp "$dependencyConfigBackup" "$dependencyConfig" + rm -f "$dependencyConfigBackup" + fi + if [[ -n $hiddenConfigBackup && -f $hiddenConfigBackup ]]; then + cp "$hiddenConfigBackup" ./rescript.json + rm -f "$hiddenConfigBackup" + rm -rf "$hiddenDependency" + fi + if [[ -n $compiledConfigBackup && -f $compiledConfigBackup ]]; then + cp "$compiledConfigBackup" ./rescript.json + rm -f "$compiledConfigBackup" ./lib/bs/empty-compiled-src/NewModule.cmt + rmdir ./empty-compiled-src ./lib/bs/empty-compiled-src 2>/dev/null || true + fi + if [[ -n $sourceBackup && -f $sourceBackup ]]; then + cp "$sourceBackup" ./src/ResGraphContext.res + rm -f "$sourceBackup" + fi + if [[ -n $schemaBackup && -f $schemaBackup ]]; then + cp "$schemaBackup" ./src/__generated__/ResGraphSchema.res + rm -f "$schemaBackup" + fi +} + +trap cleanup EXIT INT TERM + warningYellow='\033[0;33m' successGreen='\033[0;32m' reset='\033[0m' @@ -121,11 +175,13 @@ printf '%b%s%b\n' "$successGreen" \ '✅ Symlink retargets invalidate incremental cache.' "$reset" printf '{}\n' >./bsconfig.json +createdBsconfig=true configAdditionOutput=$( RESGRAPH_INCREMENTAL_DEBUG=1 ../bin/dev/resgraph.exe generate-schema \ ./src ./src/__generated__ true 2>&1 ) rm -f ./bsconfig.json +createdBsconfig=false if [[ $configAdditionOutput != *"project input changed"* ]]; then printf '%b%s\n%s\n%b\n' "$warningYellow" \ '⚠️ Added configuration file did not invalidate incremental cache.' \ diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json index c3fd4ff0..d538e3ba 100644 --- a/vscode-extension/package-lock.json +++ b/vscode-extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-resgraph", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "vscode-resgraph", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { "cosmiconfig": "^8.1.3", diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 6eeae27a..3c760422 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -7,11 +7,12 @@ "vscode": "^1.77.0" }, "scripts": { - "vscode:prepublish": "yarn build", - "build:watch": "tsc -w", + "vscode:prepublish": "npm run build", + "typecheck": "tsc --noEmit", + "build:watch": "tsc -w --noEmit", "build:extension": "esbuild ./src/extension.ts --bundle --outfile=build/extension.js --external:vscode --format=cjs --platform=node --sourcemap", - "build:clean": "rm -rf build", - "build": "yarn build:clean && yarn build:extension" + "build:clean": "node -e \"require('node:fs').rmSync('build', {recursive: true, force: true})\"", + "build": "npm run build:clean && npm run typecheck && npm run build:extension" }, "author": "Gabriel Nordeborn ", "repository": { diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 2ab7dba2..1445da4f 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -18,6 +18,18 @@ import { cosmiconfig } from "cosmiconfig"; import { dirname, resolve } from "path"; const DEBUG = false; +function resolveWorkspaceCli(workspacePath: string): string { + try { + return require.resolve("resgraph/dist/Cli.mjs", { + paths: [workspacePath], + }); + } catch { + throw new Error( + "Could not find ResGraph in this workspace. Install resgraph in the project before starting the extension." + ); + } +} + export async function activate(context: ExtensionContext) { let currentWorkspacePath = workspace.workspaceFolders?.[0].uri.fsPath; @@ -30,28 +42,22 @@ export async function activate(context: ExtensionContext) { let { filepath } = c; let fileDir = dirname(filepath); + const cliPath = DEBUG + ? resolve(__filename, "../../../cli/Cli.mjs") + : resolveWorkspaceCli(fileDir); if (DEBUG) { window.showInformationMessage("Running in debug mode." + __filename); } - let serverOptions: ServerOptions = DEBUG - ? { - transport: TransportKind.stdio, - command: "node", - args: [resolve(__filename, "../../../cli/Cli.mjs"), "lsp", fileDir], - options: { - cwd: fileDir, - }, - } - : { - transport: TransportKind.stdio, - command: "npx", - args: ["resgraph", "lsp", fileDir], - options: { - cwd: fileDir, - }, - }; + let serverOptions: ServerOptions = { + transport: TransportKind.stdio, + command: process.execPath, + args: [cliPath, "lsp", fileDir], + options: { + cwd: fileDir, + }, + }; let clientOptions: LanguageClientOptions = { documentSelector: [ @@ -59,7 +65,7 @@ export async function activate(context: ExtensionContext) { { scheme: "file", language: "graphql" }, ], synchronize: { - fileEvents: workspace.createFileSystemWatcher("**/*.res"), + fileEvents: workspace.createFileSystemWatcher("**/*.{res,resi,graphql}"), }, outputChannelName: "ResGraph Language Server", revealOutputChannelOn: RevealOutputChannelOn.Never, diff --git a/vscode-extension/src/tsconfig.json b/vscode-extension/tsconfig.json similarity index 92% rename from vscode-extension/src/tsconfig.json rename to vscode-extension/tsconfig.json index 09a4bb31..c94b0995 100644 --- a/vscode-extension/src/tsconfig.json +++ b/vscode-extension/tsconfig.json @@ -5,7 +5,7 @@ "outDir": "build", "moduleResolution": "node", "lib": ["dom", "es2017", "esnext"], - "types": ["node", "jest"], + "types": ["node", "vscode"], "sourceMap": false, "esModuleInterop": true, "removeComments": true, From fcb1f7be06567b95819035f8e13d9d249c230fc5 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Fri, 31 Jul 2026 22:09:32 +0200 Subject: [PATCH 2/6] Build CLI bundle before integration tests --- Makefile | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index dae95f4d..bd882ffb 100644 --- a/Makefile +++ b/Makefile @@ -10,10 +10,13 @@ build-resgraph-binary-dev: dune build cp _build/install/default/bin/resgraph bin/dev/resgraph.exe +build-cli: + npm run build + build-tests: make -C tests build -build: build-resgraph-binary build-tests +build: build-resgraph-binary build-cli build-tests dce: build-resgraph-binary opam exec reanalyze.exe -- -dce-cmt _build -suppress vendor @@ -21,7 +24,7 @@ dce: build-resgraph-binary format: dune build @fmt --auto-promote -test-resgraph-binary: build-resgraph-binary +test-resgraph-binary: build-resgraph-binary build-cli make -C tests test test: test-resgraph-binary @@ -36,4 +39,4 @@ checkformat: .DEFAULT_GOAL := build -.PHONY: build-resgraph-binary build-resgraph-binary-dev build-tests dce clean format test +.PHONY: build-resgraph-binary build-resgraph-binary-dev build-cli build-tests dce clean format test From eaef1b1e5a89717c0f223f4621a7c970bde50edc Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sat, 1 Aug 2026 10:00:55 +0200 Subject: [PATCH 3/6] Harden batching, DataLoader, and LSP boundaries --- CHANGELOG.md | 9 ++++-- cli/Cli.res | 4 ++- cli/Lsp.res | 44 ++++++++++++++++++++++++----- docs/docs/architecture.md | 19 +++++++++---- scripts/test-package.mjs | 11 ++++++++ src/ml/ArchitectureTests.ml | 46 ++++++++++++++++++++++++++++++ src/ml/GenerateSchemaDirect.ml | 51 ++++++++++++++++++++++------------ src/ml/GenerationContext.ml | 23 +++++++++------ src/ml/GenerationContext.mli | 7 ++++- src/res/DataLoader.mjs | 6 ++++ src/res/DataLoader.res | 25 ++++++++++++++--- src/res/DataLoader.resi | 41 +++++++++++++++++---------- tests/multi-schema-lsp.cjs | 15 ++++++++-- tests/multi-schema-test.sh | 26 +++++++++++++++++ tests/runtime-bindings.mjs | 14 ++++++++++ 15 files changed, 280 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e808727b..04189b79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,9 @@ ## Unreleased - Validate operations before execution, support synchronous GraphQL.js results, reject malformed variables, and avoid redundant query hashing. -- Add correct keyed DataLoader priming, per-entry `loadMany` results, and - order-preserving structural cache keys while retaining existing APIs. +- Add correct keyed DataLoader priming and type-safe per-entry batch results, + deprecate the unsound legacy priming and `loadMany` signatures, and preserve + array order in structural cache keys. - Preserve last-known-good generated schemas on validation failures and use atomic writes for generated files and native state. - Add a packed-package consumer test, native architecture fixtures, strict @@ -13,6 +14,10 @@ generation context; batch schemas by compiler root to share CMT summaries while retaining per-schema results; and publish architecture and configuration-schema docs. +- Scope shared native summaries by package and keep artifact-write failures + inside a single structured batch response. +- Preserve numeric and string JSON-RPC IDs and contain language-server handler + failures. ## 1.3.0 diff --git a/cli/Cli.res b/cli/Cli.res index 13174688..6aa58cbf 100644 --- a/cli/Cli.res +++ b/cli/Cli.res @@ -171,7 +171,9 @@ let buildSchemas = (config: Utils.config, schemas: array) => results->Array.forEachWithIndex((result, index) => { let schema = group[index]->Option.getOrThrow(~message="Missing schema for batch result.") switch result { - | Completion(_) | Hover(_) | Definition(_) | FindDefinition(_) | NotInitialized => () + | Completion(_) | Hover(_) | Definition(_) | FindDefinition(_) | NotInitialized => + Console.error(`[${schema.name}] Native generator returned an unexpected response.`) + hadError := true | Success(_) => printBuildTime(schema, performance->now -. timeStart, ~showSchemaName) printAuthorizationBaselineWarning(schema.authorization) diff --git a/cli/Lsp.res b/cli/Lsp.res index a86faa81..dc5dfd79 100644 --- a/cli/Lsp.res +++ b/cli/Lsp.res @@ -21,6 +21,7 @@ let log = Console.error module Message = { type msg + type requestId type t = msg @@ -54,7 +55,7 @@ module Message = { external unsafeGetParams: t => 'a = "params" @get - external getId: t => string = "id" + external getId: t => requestId = "id" module LspMessage = { @live @@ -153,14 +154,15 @@ module Message = { module Error: { type t - type code = ServerNotInitialized | InvalidRequest + type code = ServerNotInitialized | InvalidRequest | InternalError let make: (~code: code, ~message: string) => t } = { - type code = ServerNotInitialized | InvalidRequest + type code = ServerNotInitialized | InvalidRequest | InternalError let codeToInt = code => switch code { | ServerNotInitialized => -32002 | InvalidRequest => -32600 + | InternalError => -32603 } @live @@ -267,12 +269,12 @@ module Message = { module Response: { type t external asMessage: t => msg = "%identity" - let make: (~id: string, ~error: Error.t=?, ~result: Result.t=?, unit) => t + let make: (~id: requestId, ~error: Error.t=?, ~result: Result.t=?, unit) => t } = { @live type t = { jsonrpc: string, - id: string, + id: requestId, error: option, result: option, } @@ -640,6 +642,34 @@ let start = (~mode, ~configFilePath) => { } } + let onMessageSafely = msg => { + let sendInternalError = () => { + if Message.isRequestMessage(msg) { + Message.Response.make( + ~id=msg->Message.getId, + ~error=Message.Error.make( + ~code=InternalError, + ~message="ResGraph language server request failed.", + ), + (), + ) + ->Message.Response.asMessage + ->send + } + } + + try { + onMessage(msg) + } catch { + | Exn.Error(error) => + log(error) + sendInternalError() + | _ => + log("Unknown ResGraph language server request failure.") + sendInternalError() + } + } + // //// // BOOT // //// @@ -649,12 +679,12 @@ let start = (~mode, ~configFilePath) => { let writer = Rpc.StreamMessageWriter.make(stdout) let reader = Rpc.StreamMessageReader.make(stdin) sendFn := (msg => writer->Rpc.StreamMessageWriter.write(msg)) - reader->Rpc.StreamMessageReader.listen(onMessage) + reader->Rpc.StreamMessageReader.listen(onMessageSafely) log(`Starting LSP in stdio mode.`) | NodeRpc => sendFn := processSend - processOnMessage(onMessage) + processOnMessage(onMessageSafely) log(`Starting LSP in Node RPC.`) } } diff --git a/docs/docs/architecture.md b/docs/docs/architecture.md index 4604745f..8c9ac9eb 100644 --- a/docs/docs/architecture.md +++ b/docs/docs/architecture.md @@ -30,8 +30,9 @@ The native executable is intentionally thin. Generator code lives in the wrapped `resgraph_engine` library, and `GenerationContext` is the explicit home for reusable CMT and module-summary caches. Normal builds group schemas by canonical compiler root and send one length-prefixed batch request per root. -Schemas build sequentially with aligned results: compiler summaries are shared, -while mutable schema state, artifacts, diagnostics, and failures remain isolated. +Schemas build sequentially with aligned results: compiler summaries are shared +within their owning package, while mutable schema state, artifacts, diagnostics, +and failures remain isolated. ## Invariants @@ -49,6 +50,10 @@ while mutable schema state, artifacts, diagnostics, and failures remain isolated - GraphQL operations executed through `ResGraph.Execute` are validated before execution and normalized to promises whether GraphQL.js completes synchronously or asynchronously. +- `DataLoader.makeBatchedResults` and `loadManyResults` preserve per-key + failures as `result` values. Use `primeAt` and `primeWithPromiseAt` for + keyed priming; the old keyless signatures remain only for source + compatibility and are deprecated. - Compiler-specific values stay inside the native engine. Public runtime APIs expose ReScript types, GraphQL values, JSON, results, and promises. @@ -95,9 +100,13 @@ Stdout is reserved for one JSON response and stderr for operational failures or diagnostic presentation. Existing command shapes are compatibility surfaces for the published Node CLI even though they are not a user-facing API. -When evolving this protocol, introduce a versioned request/response DTO, retain -per-schema results, and update package-consumer and LSP tests together. Avoid -adding process exits below the CLI boundary. +Language-server request IDs are opaque passthrough values so both numeric and +string JSON-RPC IDs survive unchanged. Handler failures return an internal-error +response without terminating the server. + +When evolving the native protocol, introduce a versioned request/response DTO, +retain per-schema results, and update package-consumer and LSP tests together. +Avoid adding process exits below the CLI boundary. ## Performance work diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 999e18ac..0f863869 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -150,6 +150,17 @@ assert.equal(result.data.greeting, "hello from package"); const loader = DataLoader.makeSingle(async key => "loaded:" + key); DataLoader.primeAt(loader, "key", "primed"); assert.equal(await DataLoader.load(loader, "key"), "primed"); + +const resultsLoader = DataLoader.makeBatchedResults(async keys => + keys.map(key => + key === "error" + ? {TAG: "Error", _0: new Error("packed loader error")} + : {TAG: "Ok", _0: "loaded:" + key} + ) +); +const entries = await DataLoader.loadManyResults(resultsLoader, ["ok", "error"]); +assert.equal(entries[0]._0, "loaded:ok"); +assert.equal(entries[1]._0.message, "packed loader error"); `, ); run("node", ["verify.mjs"], {cwd: consumerRoot}); diff --git a/src/ml/ArchitectureTests.ml b/src/ml/ArchitectureTests.ml index f6ce948e..0c4e66d7 100644 --- a/src/ml/ArchitectureTests.ml +++ b/src/ml/ArchitectureTests.ml @@ -37,6 +37,51 @@ let makeSchemaState () : schemaState = diagnostics = []; } +let makePackage rootPath : SharedTypes.package = + { + genericJsxModule = None; + suffix = ".mjs"; + rootPath; + projectFiles = SharedTypes.FileSet.empty; + dependenciesFiles = SharedTypes.FileSet.empty; + pathsForModule = Hashtbl.create 1; + namespace = None; + opens = []; + uncurried = true; + rescriptVersion = (12, 0); + autocomplete = Misc.StringMap.empty; + } + +let testGenerationContextScopesSummariesByPackage () = + let context = GenerationContext.create () in + let firstPackage = makePackage "/tmp/resgraph-package-a" in + let secondPackage = makePackage "/tmp/resgraph-package-b" in + let moduleName = "Shared" in + let firstFile = + SharedTypes.File.create moduleName + (Uri.fromPath "/tmp/resgraph-package-a/src/Shared.res") + in + let secondFile = + SharedTypes.File.create moduleName + (Uri.fromPath "/tmp/resgraph-package-b/src/Shared.res") + in + GenerationContext.seedSummary context ~package:firstPackage ~moduleName + firstFile; + GenerationContext.seedSummary context ~package:secondPackage ~moduleName + secondFile; + let loadedPath package = + match GenerationContext.loadSummary context ~package ~moduleName with + | Some file -> Uri.toPath file.uri + | None -> fail "A seeded generation summary must be available." + in + assertTrue + (loadedPath firstPackage = "/tmp/resgraph-package-a/src/Shared.res") + "A generation context must not reuse a same-named module from another \ + package."; + assertTrue + (loadedPath secondPackage = "/tmp/resgraph-package-b/src/Shared.res") + "A generation context must retain summaries independently per package." + let testInputUnionRegistry () = let schemaState = makeSchemaState () in let fileUri = Uri.fromPath "/tmp/Schema.res" in @@ -128,6 +173,7 @@ let testCrossKindGraphqlNameCollision () = "Cross-kind GraphQL type names must be rejected before emission." let () = + testGenerationContextScopesSummariesByPackage (); testInputUnionRegistry (); testAtomicWrite (); testWriteFailureIsRaised (); diff --git a/src/ml/GenerateSchemaDirect.ml b/src/ml/GenerateSchemaDirect.ml index 4aceff8a..98236cc4 100644 --- a/src/ml/GenerateSchemaDirect.ml +++ b/src/ml/GenerateSchemaDirect.ml @@ -97,7 +97,7 @@ let print_collect_errors errs = let with_hooks ~context ~package ~preloaded f = preloaded |> List.iter (fun (moduleName, file) -> - GenerationContext.seedSummary context ~moduleName file); + GenerationContext.seedSummary context ~package ~moduleName file); let loader ~moduleName = GenerationContext.loadSummary context ~package ~moduleName in @@ -254,6 +254,36 @@ let generateSchemaDirect ?generationContext ~printToStdOut ~writeStateFile in if schemaState.diagnostics |> List.length > 0 then ( + let diagnostics = + schemaState.diagnostics |> List.rev |> List.map snd + in + let diagnostics = + try + (* Preserve prior successful artifacts; bootstrap only on + the first build. *) + if not (Sys.file_exists schemaOutputPath) then + GenerateSchemaUtils.writeIfHasChanges schemaOutputPath + (Printf.sprintf + "let schema: ResGraph.schema<%s> = \ + ResGraph__GraphQLJs.GraphQLSchemaType.make(Obj.magic())\n" + contextType + |> markNamedSchemaFile); + if not (Sys.file_exists resiOutputPath) then + GenerateSchemaUtils.writeIfHasChanges resiOutputPath + resiContent; + diagnostics + with (Sys_error _ | Unix.Unix_error _) as exn -> + diagnostics + @ [ + { + loc = Location.none; + fileUri = Uri.fromPath outputFolder; + message = + "Failed to write compile-safe bootstrap artifacts: " + ^ Printexc.to_string exn; + }; + ] + in if printToStdOut then Printf.printf "{\n\ @@ -263,22 +293,9 @@ let generateSchemaDirect ?generationContext ~printToStdOut ~writeStateFile \ %s\n\ \ ]\n\ }" - (schemaState.diagnostics |> List.rev - |> List.map (fun (_, diagnostic) -> - GenerateSchemaUtils.printDiagnostic diagnostic) - |> String.concat ",\n"); - - (* Preserve prior successful artifacts; bootstrap only on the first build. *) - if not (Sys.file_exists schemaOutputPath) then - GenerateSchemaUtils.writeIfHasChanges schemaOutputPath - (Printf.sprintf - "let schema: ResGraph.schema<%s> = \ - ResGraph__GraphQLJs.GraphQLSchemaType.make(Obj.magic())\n" - contextType - |> markNamedSchemaFile); - if not (Sys.file_exists resiOutputPath) then - GenerateSchemaUtils.writeIfHasChanges resiOutputPath - resiContent) + (diagnostics + |> List.map GenerateSchemaUtils.printDiagnostic + |> String.concat ",\n")) else let () = match schemaName with diff --git a/src/ml/GenerationContext.ml b/src/ml/GenerationContext.ml index 7966c1e3..6ef7b3fe 100644 --- a/src/ml/GenerationContext.ml +++ b/src/ml/GenerationContext.ml @@ -6,10 +6,16 @@ type t = { let create () = {cmts = Hashtbl.create 128; summaries = Hashtbl.create 128} let canonicalize path = - try Unix.realpath path - with _ -> - if Filename.is_relative path then Filename.concat (Sys.getcwd ()) path - else path + let path = + try Unix.realpath path + with _ -> + if Filename.is_relative path then Filename.concat (Sys.getcwd ()) path + else path + in + if Sys.win32 then String.lowercase_ascii path else path + +let summaryKey (package : SharedTypes.package) moduleName = + canonicalize package.rootPath ^ "\000" ^ moduleName let loadCmt context ~moduleName ~path = let key = canonicalize path in @@ -20,11 +26,12 @@ let loadCmt context ~moduleName ~path = Hashtbl.replace context.cmts key result; result -let seedSummary context ~moduleName file = - Hashtbl.replace context.summaries moduleName (Some file) +let seedSummary context ~(package : SharedTypes.package) ~moduleName file = + Hashtbl.replace context.summaries (summaryKey package moduleName) (Some file) let loadSummary context ~(package : SharedTypes.package) ~moduleName = - match Hashtbl.find_opt context.summaries moduleName with + let key = summaryKey package moduleName in + match Hashtbl.find_opt context.summaries key with | Some result -> result | None -> let result = @@ -39,5 +46,5 @@ let loadSummary context ~(package : SharedTypes.package) ~moduleName = (CmtDirect.infos cmt)) (loadCmt context ~moduleName ~path:cmtPath) in - Hashtbl.replace context.summaries moduleName result; + Hashtbl.replace context.summaries key result; result diff --git a/src/ml/GenerationContext.mli b/src/ml/GenerationContext.mli index 5579cbac..633e68c7 100644 --- a/src/ml/GenerationContext.mli +++ b/src/ml/GenerationContext.mli @@ -2,7 +2,12 @@ type t val create : unit -> t val loadCmt : t -> moduleName:string -> path:string -> CmtDirect.t option -val seedSummary : t -> moduleName:string -> SharedTypes.File.t -> unit +val seedSummary : + t -> + package:SharedTypes.package -> + moduleName:string -> + SharedTypes.File.t -> + unit val loadSummary : t -> package:SharedTypes.package -> diff --git a/src/res/DataLoader.mjs b/src/res/DataLoader.mjs index bc117600..378da02e 100644 --- a/src/res/DataLoader.mjs +++ b/src/res/DataLoader.mjs @@ -31,6 +31,11 @@ function makeBatched(loadFn, options) { return Stdlib_Lazy.make(() => new Dataloader(loadFn, Primitive_option.toUndefined(mapOptions(options)))); } +function makeBatchedResults(loadFn, options) { + let rawLoadFn = keys => loadFn(keys).then(results => results.map(result => result._0)); + return Stdlib_Lazy.make(() => new Dataloader(rawLoadFn, Primitive_option.toUndefined(mapOptions(options)))); +} + function load(lazyLoader, key) { let loader = Stdlib_Lazy.get(lazyLoader); return loader.load(key); @@ -99,6 +104,7 @@ export { Plain, makeSingle, makeBatched, + makeBatchedResults, load, loadMany, loadManyResults, diff --git a/src/res/DataLoader.res b/src/res/DataLoader.res index 42cb36c0..22de93bf 100644 --- a/src/res/DataLoader.res +++ b/src/res/DataLoader.res @@ -60,7 +60,6 @@ module Plain = { external loadManyRaw: (t<'key, 'value>, array<'key>) => promise>> = "loadMany" - /** * Clears the value at `key` from the cache, if it exists. */ @@ -83,7 +82,6 @@ module Plain = { @send external primeAt: (t<'key, 'value>, 'key, 'value) => unit = "prime" - /** * Adds the provided key and (promised) value to the cache. If the key already exists, no * change is made. @@ -93,7 +91,6 @@ module Plain = { @send external primeWithPromiseAt: (t<'key, 'value>, 'key, promise<'value>) => unit = "prime" - /** * The name given to this `DataLoader` instance, if set. Useful for APM tools.. */ @@ -104,6 +101,7 @@ module Plain = { type t<'key, 'value> = Lazy.t> type batchFn<'key, 'value> = array<'key> => promise> +type batchResultsFn<'key, 'value> = array<'key> => promise>> type options = { /** @@ -149,6 +147,25 @@ let makeBatched = (loadFn: batchFn<'key, 'value>, ~options=?) => { Lazy.make(() => Plain.make(loadFn, ~options=?mapOptions(options))) } +external valueToRawLoadManyEntry: 'value => Plain.rawLoadManyEntry<'value> = "%identity" +external errorToRawLoadManyEntry: exn => Plain.rawLoadManyEntry<'value> = "%identity" +external rawLoaderToLoader: Plain.t<'key, Plain.rawLoadManyEntry<'value>> => Plain.t<'key, 'value> = + "%identity" + +let makeBatchedResults = (loadFn: batchResultsFn<'key, 'value>, ~options=?) => { + let rawLoadFn = keys => + loadFn(keys)->Promise.thenResolve(results => + results->Array.map(result => + switch result { + | Ok(value) => value->valueToRawLoadManyEntry + | Error(error) => error->errorToRawLoadManyEntry + } + ) + ) + + Lazy.make(() => Plain.make(rawLoadFn, ~options=?mapOptions(options))->rawLoaderToLoader) +} + let load = (lazyLoader, key) => { let loader = lazyLoader->Lazy.get loader->Plain.load(key) @@ -159,7 +176,6 @@ let loadMany = (lazyLoader, keys) => { loader->Plain.loadMany(keys) } - let isError: Plain.rawLoadManyEntry<'value> => bool = %raw(`value => value instanceof Error`) external rawLoadManyEntryToValue: Plain.rawLoadManyEntry<'value> => 'value = "%identity" external rawLoadManyEntryToError: Plain.rawLoadManyEntry<'value> => exn = "%identity" @@ -178,6 +194,7 @@ let loadManyResults = (lazyLoader, keys) => { ) ) } + let clear = (lazyLoader, key) => { let loader = lazyLoader->Lazy.get loader->Plain.clear(key) diff --git a/src/res/DataLoader.resi b/src/res/DataLoader.resi index bb594a82..9291b88c 100644 --- a/src/res/DataLoader.resi +++ b/src/res/DataLoader.resi @@ -50,8 +50,10 @@ module Plain: { @send external load: (t<'key, 'value>, 'key) => promise<'value> = "load" /** - * Loads multiple keys, promising an array of values. + * Loads multiple keys, promising an array of values. DataLoader can place + * errors in this array, which this legacy signature cannot represent. */ + @deprecated("Use the high-level loadManyResults API.") @send external loadMany: (t<'key, 'value>, array<'key>) => promise> = "loadMany" /** @@ -66,9 +68,9 @@ module Plain: { @send external clearAll: t<'key, 'value> => unit = "clearAll" /** - * Adds the provided key and value to the cache. If the key already exists, no - * change is made. + * Legacy binding with a missing key argument. */ + @deprecated("Use primeAt, which accepts both the cache key and value.") @send external prime: (t<'key, 'value>, 'value) => unit = "prime" /** Adds a value to the cache at the provided key. */ @@ -76,9 +78,9 @@ module Plain: { external primeAt: (t<'key, 'value>, 'key, 'value) => unit = "prime" /** - * Adds the provided key and (promised) value to the cache. If the key already - * change is made. + * Legacy binding with a missing key argument. */ + @deprecated("Use primeWithPromiseAt, which accepts both the cache key and promised value.") @send external primeWithPromise: (t<'key, 'value>, promise<'value>) => unit = "prime" @@ -96,6 +98,7 @@ module Plain: { type t<'key, 'value> type batchFn<'key, 'value> = array<'key> => promise> +type batchResultsFn<'key, 'value> = array<'key> => promise>> type options = { /** @@ -118,19 +121,29 @@ let makeSingle: ('key => promise<'value>, ~options: options=?) => t<'key, 'value /** Creates a new data loader, where the load function is batched. */ let makeBatched: (batchFn<'key, 'value>, ~options: options=?) => t<'key, 'value> +/** + * Creates a batched loader whose individual keys may resolve to errors. + */ +let makeBatchedResults: ( + batchResultsFn<'key, 'value>, + ~options: options=?, +) => t<'key, 'value> + /** * Loads a key, returning a `promise` for the value represented by that key. */ let load: (t<'key, 'value>, 'key) => promise<'value> /** - * Loads multiple keys, promising an array of values. + * Loads multiple keys, promising an array of values. DataLoader can place + * errors in this array, which this legacy signature cannot represent. */ +@deprecated("Use loadManyResults to preserve per-entry errors.") let loadMany: (t<'key, 'value>, array<'key>) => promise> - /** Loads multiple keys while preserving DataLoader's per-entry errors. */ let loadManyResults: (t<'key, 'value>, array<'key>) => promise>> + /** * Clears the value at `key` from the cache, if it exists. */ @@ -143,24 +156,24 @@ let clear: (t<'key, 'value>, 'key) => unit let clearAll: t<'key, 'value> => unit /** - * Adds the provided key and value to the cache. If the key already exists, no - * change is made. + * Legacy binding with a missing key argument. */ +@deprecated("Use primeAt, which accepts both the cache key and value.") let prime: (t<'key, 'value>, 'value) => unit - /** Adds a value to the cache at the provided key. */ let primeAt: (t<'key, 'value>, ~key: 'key, ~value: 'value) => unit + /** - * Adds the provided key and (promised) value to the cache. If the key already exists, no - * change is made. + * Legacy binding with a missing key argument. */ +@deprecated("Use primeWithPromiseAt, which accepts both the cache key and promised value.") let primeWithPromise: (t<'key, 'value>, promise<'value>) => unit - /** Adds a promised value to the cache at the provided key. */ let primeWithPromiseAt: (t<'key, 'value>, ~key: 'key, ~value: promise<'value>) => unit + /** - * The name given to this `DataLoader` instance, if set. Useful for APM tools.. + * The name given to this `DataLoader` instance, if set. Useful for APM tools. */ let name: t<'key, 'value> => option diff --git a/tests/multi-schema-lsp.cjs b/tests/multi-schema-lsp.cjs index 3b627421..2f71e176 100644 --- a/tests/multi-schema-lsp.cjs +++ b/tests/multi-schema-lsp.cjs @@ -38,13 +38,22 @@ const send = message => { child.stdin.write(`Content-Length: ${Buffer.byteLength(payload)}\r\n\r\n${payload}`); }; -send({jsonrpc: "2.0", id: "initialize", method: "initialize", params: {}}); +send({jsonrpc: "2.0", id: 1, method: "initialize", params: {}}); setTimeout(() => { send({ jsonrpc: "2.0", method: "textDocument/didOpen", params: {textDocument: {uri: publicSourceUri, text: readFileSync(publicSource, "utf8")}}, }); + send({ + jsonrpc: "2.0", + id: "invalid-uri", + method: "textDocument/hover", + params: { + textDocument: {uri: "untitled:invalid"}, + position: {line: 0, character: 0}, + }, + }); send({ jsonrpc: "2.0", id: "completion", @@ -68,8 +77,10 @@ child.on("close", code => { const valid = code === 0 && removedArtifactWasCleaned && - stdout.includes('"id":"initialize"') && + stdout.includes('"id":1') && stdout.includes('"capabilities"') && + stdout.includes('"id":"invalid-uri"') && + stdout.includes('"code":-32603') && stdout.includes('"id":"completion"') && stdout.includes("PublicContext.context"); if (!valid) { diff --git a/tests/multi-schema-test.sh b/tests/multi-schema-test.sh index b239299d..6da1aab9 100755 --- a/tests/multi-schema-test.sh +++ b/tests/multi-schema-test.sh @@ -293,6 +293,32 @@ set -e [[ "$failure_output" == *'[admin] Build succeeded'* ]] rm -f "$fixture_dir/src/generated/broken/BrokenSchema.res" "$fixture_dir/src/generated/broken/BrokenSchema.resi" +missing_output_parent=$(mktemp -d /tmp/resgraph-batch-write-failure.XXXXXX) +trap 'rm -rf "$missing_output_parent"' EXIT +batch_write_failure_output=$( + "$repo_dir/bin/dev/resgraph.exe" generate-schemas-v1 \ + 12 generate-schema "$fixture_dir" "$missing_output_parent/missing" false \ + --schema broken-write \ + --module BrokenWriteSchema \ + --context BrokenContext.context \ + --include "$fixture_dir/src/broken" \ + 2>/dev/null +) +node -e ' +const results = JSON.parse(process.argv[1]); +if ( + results.length !== 1 || + results[0].status !== "Error" || + !results[0].errors.some(error => + error.message.includes("Failed to write compile-safe bootstrap artifacts:") + ) +) { + process.exit(1); +} +' "$batch_write_failure_output" +rm -rf "$missing_output_parent" +trap - EXIT + (cd "$fixture_dir/uncompiled" && "$rescript_bin") for cmt in \ "$fixture_dir/uncompiled/lib/bs/src/Schema.cmt" \ diff --git a/tests/runtime-bindings.mjs b/tests/runtime-bindings.mjs index 56a6fbc1..dd7ef5b9 100644 --- a/tests/runtime-bindings.mjs +++ b/tests/runtime-bindings.mjs @@ -34,6 +34,20 @@ assert.deepEqual(loadManyResults[0], {TAG: "Ok", _0: "loaded:ok"}); assert.equal(loadManyResults[1].TAG, "Error"); assert.match(loadManyResults[1]._0.message, /expected loader error/); +const typedResultsLoader = DataLoader.makeBatchedResults(async keys => + keys.map(key => + key === "error" + ? {TAG: "Error", _0: new Error("typed loader error")} + : {TAG: "Ok", _0: `typed:${key}`}, + ), +); +const typedResults = await DataLoader.loadManyResults(typedResultsLoader, [ + "ok", + "error", +]); +assert.deepEqual(typedResults[0], {TAG: "Ok", _0: "typed:ok"}); +assert.match(typedResults[1]._0.message, /typed loader error/); + const queryType = new GraphQLObjectType({ name: "Query", fields: { From c599ecdfaf132b8254aa76ac02e93f3b7201eb30 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sat, 1 Aug 2026 10:46:53 +0200 Subject: [PATCH 4/6] Harden runtime errors and artifact ownership --- CHANGELOG.md | 8 ++- cli/GeneratedArtifacts.res | 49 ++++++++++--------- docs/docs/architecture.md | 10 ++-- docs/docs/multiple-schemas.md | 6 ++- scripts/test-package.mjs | 10 ++++ src/ml/GenerateSchemaDirect.ml | 11 +++-- src/ml/GenerateSchemaTypePrinters.ml | 11 +++-- src/res/DataLoader.mjs | 25 +++++++++- src/res/DataLoader.res | 16 +++++- src/res/ResGraph.mjs | 11 ++++- src/res/ResGraph.res | 33 ++++++++++--- src/res/ResGraph__ExecuteRuntime.mjs | 24 ++++++++- tests/multi-schema-test.sh | 36 ++++++++++++++ .../src/generated/ResGraphSchema.res | 2 + .../src/generated/ResGraphSchema.resi | 2 + tests/runtime-bindings.mjs | 10 ++++ tests/src/__generated__/ResGraphSchema.res | 2 + tests/src/__generated__/ResGraphSchema.resi | 2 + 18 files changed, 214 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04189b79..f54ac225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,16 @@ ## Unreleased - Validate operations before execution, support synchronous GraphQL.js results, - reject malformed variables, and avoid redundant query hashing. + return query syntax, validation, and malformed-variable failures through the + execution-result envelope, and avoid redundant query hashing. - Add correct keyed DataLoader priming and type-safe per-entry batch results, deprecate the unsound legacy priming and `loadMany` signatures, and preserve - array order in structural cache keys. + array order in structural cache keys. Non-JavaScript ReScript exceptions are + normalized for DataLoader and retain their identity in `loadManyResults`. - Preserve last-known-good generated schemas on validation failures and use atomic writes for generated files and native state. +- Mark legacy schema modules as generated, preserve unmarked user-owned files + during named-schema migration, and surface ownership persistence failures. - Add a packed-package consumer test, native architecture fixtures, strict integration failure propagation, release guards, and VS Code extension checks. - Extract the native generator into a wrapped engine library with a reusable diff --git a/cli/GeneratedArtifacts.res b/cli/GeneratedArtifacts.res index 38f80136..4dde5568 100644 --- a/cli/GeneratedArtifacts.res +++ b/cli/GeneratedArtifacts.res @@ -83,11 +83,15 @@ let fileStartsWith = (path, prefix) => let removeFile = path => { if Fs.existsSync(path) { - try { - Fs.unlinkSync(path) - } catch { - | _ => () - } + Fs.unlinkSync(path) + } +} + +let removeFileIgnoringErrors = path => { + try { + path->removeFile + } catch { + | _ => () } } @@ -103,22 +107,18 @@ let cleanOwnership = ownership => { if Fs.existsSync(ownership.outputFolder) { let interfacePrefix = ownership.moduleName ++ "__Interface_" - try { - ownership.outputFolder - ->Fs.readdirSync - ->Array.forEach(fileName => { - let path = Path.resolve([ownership.outputFolder, fileName]) - if ( - fileName->String.startsWith(interfacePrefix) && - fileName->String.endsWith(".res") && - path->fileStartsWith(generatedInterfaceHeader) - ) { - path->removeFile - } - }) - } catch { - | _ => () - } + ownership.outputFolder + ->Fs.readdirSync + ->Array.forEach(fileName => { + let path = Path.resolve([ownership.outputFolder, fileName]) + if ( + fileName->String.startsWith(interfacePrefix) && + fileName->String.endsWith(".res") && + path->fileStartsWith(generatedInterfaceHeader) + ) { + path->removeFile + } + }) } let schemaSdl = Path.resolve([ownership.outputFolder, "schema.graphql"]) @@ -144,7 +144,12 @@ let writeManifest = (path, schemas) => { Fs.writeFileSync(temporaryPath, Buffer.fromString(payload->JSON.stringifyAny->Option.getOr(""))) Fs.renameSync(~from=temporaryPath, ~to_=path) } catch { - | _ => temporaryPath->removeFile + | Exn.Error(error) => + temporaryPath->removeFileIgnoringErrors + Utils.rethrow(error) + | _ => + temporaryPath->removeFileIgnoringErrors + panic("Unknown failure while writing the ResGraph schema ownership manifest.") } } diff --git a/docs/docs/architecture.md b/docs/docs/architecture.md index 8c9ac9eb..6803311a 100644 --- a/docs/docs/architecture.md +++ b/docs/docs/architecture.md @@ -49,11 +49,13 @@ and failures remain isolated. output succeeds. - GraphQL operations executed through `ResGraph.Execute` are validated before execution and normalized to promises whether GraphQL.js completes - synchronously or asynchronously. + synchronously or asynchronously. Query syntax, operation validation, and + malformed-variable failures are returned in the same execution-result + envelope. - `DataLoader.makeBatchedResults` and `loadManyResults` preserve per-key - failures as `result` values. Use `primeAt` and `primeWithPromiseAt` for - keyed priming; the old keyless signatures remain only for source - compatibility and are deprecated. + failures as `result` values, including the identity of custom ReScript + exceptions. Use `primeAt` and `primeWithPromiseAt` for keyed priming; the old + keyless signatures remain only for source compatibility and are deprecated. - Compiler-specific values stay inside the native engine. Public runtime APIs expose ReScript types, GraphQL values, JSON, results, and promises. diff --git a/docs/docs/multiple-schemas.md b/docs/docs/multiple-schemas.md index 9d684c0b..f9c0b8d3 100644 --- a/docs/docs/multiple-schemas.md +++ b/docs/docs/multiple-schemas.md @@ -68,7 +68,11 @@ Named schemas keep their generated outputs isolated: For example, the `public` schema emits `PublicSchema__Interface_node.res`. Use `PublicSchema__Interface_node.Resolver.t` when a resolver explicitly returns that interface for the public schema. The module prefix matters when two schemas expose an interface with the same GraphQL name. -ResGraph tracks generated-file ownership. Renaming or removing a schema or module cleans marker-owned stale modules, interfaces, and SDL without deleting user-owned files. Switching from the original single-schema configuration also removes obsolete legacy generated modules on the next named build. +ResGraph tracks generated-file ownership. Renaming or removing a schema or module cleans marker-owned stale modules, interfaces, and SDL without deleting user-owned files. Switching from the original single-schema configuration also removes marker-owned obsolete legacy modules on the next named build. + +Legacy modules generated by this release carry an ownership marker. Older +unmarked `ResGraphSchema.res` and `.resi` files are deliberately preserved +during migration; inspect and remove those files manually when they are stale. ## Sharing modules diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 0f863869..55607fc9 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -147,6 +147,9 @@ import {schema} from "./src/generated/ResGraphSchema.mjs"; const result = await Execute.executeToJson(schema, "{ greeting }", undefined); assert.equal(result.data.greeting, "hello from package"); +const malformed = await Execute.executeToJson(schema, "{", undefined); +assert.match(malformed.errors[0].message, /Syntax Error/); + const loader = DataLoader.makeSingle(async key => "loaded:" + key); DataLoader.primeAt(loader, "key", "primed"); assert.equal(await DataLoader.load(loader, "key"), "primed"); @@ -161,6 +164,13 @@ const resultsLoader = DataLoader.makeBatchedResults(async keys => const entries = await DataLoader.loadManyResults(resultsLoader, ["ok", "error"]); assert.equal(entries[0]._0, "loaded:ok"); assert.equal(entries[1]._0.message, "packed loader error"); + +const customException = {RE_EXN_ID: "Packed_exception"}; +const customExceptionLoader = DataLoader.makeBatchedResults(async () => [ + {TAG: "Error", _0: customException} +]); +const [customEntry] = await DataLoader.loadManyResults(customExceptionLoader, ["custom"]); +assert.equal(customEntry._0, customException); `, ); run("node", ["verify.mjs"], {cwd: consumerRoot}); diff --git a/src/ml/GenerateSchemaDirect.ml b/src/ml/GenerateSchemaDirect.ml index 98236cc4..2db52d1e 100644 --- a/src/ml/GenerateSchemaDirect.ml +++ b/src/ml/GenerateSchemaDirect.ml @@ -236,17 +236,18 @@ let generateSchemaDirect ?generationContext ~printToStdOut ~writeStateFile GenerateSchemaUtils.processSchema schemaState in GenerateSchemaAuthorization.buildPlans ~loader ~package schemaState; - let markNamedSchemaFile contents = + let markSchemaFile contents = match schemaName with | Some _ -> GenerateSchemaTypePrinters.markNamedSchemaFile contents - | None -> contents + | None -> + GenerateSchemaTypePrinters.markLegacySchemaFile contents in let schemaOutputPath = outputFolder ^ "/" ^ moduleName ^ ".res" in let resiOutputPath = schemaOutputPath ^ "i" in let resiContent = Printf.sprintf "let schema: ResGraph.schema<%s>\n" contextType - |> markNamedSchemaFile + |> markSchemaFile in let sdlOutputPath = outputFolder ^ "/schema.graphql" in let interfaceModulePrefix = @@ -267,7 +268,7 @@ let generateSchemaDirect ?generationContext ~printToStdOut ~writeStateFile "let schema: ResGraph.schema<%s> = \ ResGraph__GraphQLJs.GraphQLSchemaType.make(Obj.magic())\n" contextType - |> markNamedSchemaFile); + |> markSchemaFile); if not (Sys.file_exists resiOutputPath) then GenerateSchemaUtils.writeIfHasChanges resiOutputPath resiContent; @@ -311,7 +312,7 @@ let generateSchemaDirect ?generationContext ~printToStdOut ~writeStateFile let schemaCode = GenerateSchemaTypePrinters.printSchemaJsFile schemaState processedSchema ~interfaceModulePrefix - |> markNamedSchemaFile + |> markSchemaFile in GenerateSchemaTypePrinters.cleanInterfaceFiles schemaState diff --git a/src/ml/GenerateSchemaTypePrinters.ml b/src/ml/GenerateSchemaTypePrinters.ml index b3d6abc0..e9560420 100644 --- a/src/ml/GenerateSchemaTypePrinters.ml +++ b/src/ml/GenerateSchemaTypePrinters.ml @@ -614,14 +614,15 @@ let cleanInterfaceFiles (schemaState : schemaState) ~outputFolder Sys.remove (Filename.concat outputFolder fileName)) let namedSchemaGeneratedHeader = "/* @generated by ResGraph named schema */" +let legacySchemaGeneratedHeader = "/* @generated by ResGraph legacy schema */" let markNamedSchemaFile contents = namedSchemaGeneratedHeader ^ "\n\n" ^ contents -let namedInterfaceFileRegexp = Str.regexp "^.*__Interface_.*\\.res$" +let markLegacySchemaFile contents = + legacySchemaGeneratedHeader ^ "\n\n" ^ contents -let isLegacySchemaFile fileName = - fileName = "ResGraphSchema.res" || fileName = "ResGraphSchema.resi" +let namedInterfaceFileRegexp = Str.regexp "^.*__Interface_.*\\.res$" let isGeneratedInterfaceFile fileName = (String.starts_with fileName ~prefix:"interface_" @@ -629,8 +630,7 @@ let isGeneratedInterfaceFile fileName = && Filename.check_suffix fileName ".res" let isNamedSchemaGeneratedFile ~outputFolder fileName = - if isLegacySchemaFile fileName then true - else if + if not (Filename.check_suffix fileName ".res" || Filename.check_suffix fileName ".resi") @@ -640,6 +640,7 @@ let isNamedSchemaGeneratedFile ~outputFolder fileName = | None -> false | Some contents -> String.starts_with contents ~prefix:namedSchemaGeneratedHeader + || String.starts_with contents ~prefix:legacySchemaGeneratedHeader || isGeneratedInterfaceFile fileName && String.starts_with contents ~prefix:"/* @generated */" diff --git a/src/res/DataLoader.mjs b/src/res/DataLoader.mjs index 378da02e..afd1bd2f 100644 --- a/src/res/DataLoader.mjs +++ b/src/res/DataLoader.mjs @@ -31,8 +31,24 @@ function makeBatched(loadFn, options) { return Stdlib_Lazy.make(() => new Dataloader(loadFn, Primitive_option.toUndefined(mapOptions(options)))); } +let errorToRawLoadManyEntry = (error => { + if (error instanceof Error) return error; + const message = error != null && typeof error.RE_EXN_ID === "string" + ? error.RE_EXN_ID + : String(error); + const wrapped = new Error(message); + Object.defineProperty(wrapped, "__resgraphException", {value: error}); + return wrapped; +}); + function makeBatchedResults(loadFn, options) { - let rawLoadFn = keys => loadFn(keys).then(results => results.map(result => result._0)); + let rawLoadFn = keys => loadFn(keys).then(results => results.map(result => { + if (result.TAG === "Ok") { + return result._0; + } else { + return errorToRawLoadManyEntry(result._0); + } + })); return Stdlib_Lazy.make(() => new Dataloader(rawLoadFn, Primitive_option.toUndefined(mapOptions(options)))); } @@ -48,13 +64,18 @@ function loadMany(lazyLoader, keys) { let isError = (value => value instanceof Error); +let rawLoadManyEntryToError = (value => + Object.prototype.hasOwnProperty.call(value, "__resgraphException") + ? value.__resgraphException + : value); + function loadManyResults(lazyLoader, keys) { let loader = Stdlib_Lazy.get(lazyLoader); return loader.loadMany(keys).then(values => values.map(value => { if (isError(value)) { return { TAG: "Error", - _0: value + _0: rawLoadManyEntryToError(value) }; } else { return { diff --git a/src/res/DataLoader.res b/src/res/DataLoader.res index 22de93bf..b5827900 100644 --- a/src/res/DataLoader.res +++ b/src/res/DataLoader.res @@ -148,7 +148,15 @@ let makeBatched = (loadFn: batchFn<'key, 'value>, ~options=?) => { } external valueToRawLoadManyEntry: 'value => Plain.rawLoadManyEntry<'value> = "%identity" -external errorToRawLoadManyEntry: exn => Plain.rawLoadManyEntry<'value> = "%identity" +let errorToRawLoadManyEntry: exn => Plain.rawLoadManyEntry<'value> = %raw(`error => { + if (error instanceof Error) return error; + const message = error != null && typeof error.RE_EXN_ID === "string" + ? error.RE_EXN_ID + : String(error); + const wrapped = new Error(message); + Object.defineProperty(wrapped, "__resgraphException", {value: error}); + return wrapped; +}`) external rawLoaderToLoader: Plain.t<'key, Plain.rawLoadManyEntry<'value>> => Plain.t<'key, 'value> = "%identity" @@ -178,7 +186,11 @@ let loadMany = (lazyLoader, keys) => { let isError: Plain.rawLoadManyEntry<'value> => bool = %raw(`value => value instanceof Error`) external rawLoadManyEntryToValue: Plain.rawLoadManyEntry<'value> => 'value = "%identity" -external rawLoadManyEntryToError: Plain.rawLoadManyEntry<'value> => exn = "%identity" +let rawLoadManyEntryToError: Plain.rawLoadManyEntry<'value> => exn = %raw(`value => + Object.prototype.hasOwnProperty.call(value, "__resgraphException") + ? value.__resgraphException + : value +`) let loadManyResults = (lazyLoader, keys) => { let loader = lazyLoader->Lazy.get diff --git a/src/res/ResGraph.mjs b/src/res/ResGraph.mjs index 6a3b2167..64604db8 100644 --- a/src/res/ResGraph.mjs +++ b/src/res/ResGraph.mjs @@ -102,8 +102,15 @@ function executeParsedToJson(schema, document, contextValue, variablesJson, oper } function execute(schema, query, contextValue, cache, variableValues, operationName, rootValue) { - let document = cache !== undefined ? parseQueryCached(Primitive_option.valFromOption(cache), query) : Graphql.parse(query); - return executeParsed(schema, document, contextValue, variableValues, operationName, rootValue); + return ResGraph__ExecuteRuntimeMjs.executeQueryValidated({ + schema: schema, + query: query, + contextValue: contextValue, + cache: cache, + variableValues: variableValues, + operationName: operationName, + rootValue: rootValue + }); } function executeToJson(schema, query, contextValue, cache, variablesJson, operationName, rootValue) { diff --git a/src/res/ResGraph.res b/src/res/ResGraph.res index b9d26bcf..7e28c4b4 100644 --- a/src/res/ResGraph.res +++ b/src/res/ResGraph.res @@ -121,6 +121,16 @@ module Execute: { rootValue?: 'rootValue, } + type executeQueryArgs<'appContext, 'rootValue> = { + schema: schema<'appContext>, + query: string, + contextValue: 'appContext, + cache?: queryDocumentCache, + variableValues?: variables, + operationName?: string, + rootValue?: 'rootValue, + } + @module("graphql") external parseQuery: string => document = "parse" @module("./ResGraph__ExecuteRuntime.mjs") @@ -128,6 +138,11 @@ module Execute: { executionResult<'data, 'error, 'extensions>, > = "executeValidated" + @module("./ResGraph__ExecuteRuntime.mjs") + external executeQueryInternal: executeQueryArgs<'appContext, 'rootValue> => promise< + executionResult<'data, 'error, 'extensions>, + > = "executeQueryValidated" + external variablesOfJsonObject: dict => variables = "%identity" external variablesToJsonObject: variables => dict = "%identity" external executionResultToJson: jsonExecutionResult => JSON.t = "%identity" @@ -229,14 +244,16 @@ module Execute: { ~variableValues=?, ~operationName=?, ~rootValue=?, - ) => { - let document = switch cache { - | None => parseQuery(query) - | Some(cache) => parseQueryCached(~cache, ~query) - } - - executeParsed(schema, ~document, ~contextValue, ~variableValues?, ~operationName?, ~rootValue?) - } + ) => + executeQueryInternal({ + schema, + query, + contextValue, + ?cache, + ?variableValues, + ?operationName, + ?rootValue, + }) let executeToJson = ( schema, diff --git a/src/res/ResGraph__ExecuteRuntime.mjs b/src/res/ResGraph__ExecuteRuntime.mjs index 372631a6..ac1acefc 100644 --- a/src/res/ResGraph__ExecuteRuntime.mjs +++ b/src/res/ResGraph__ExecuteRuntime.mjs @@ -1,4 +1,4 @@ -import {execute, validate} from "graphql"; +import {execute, parse, validate} from "graphql"; const validatedDocumentsBySchema = new WeakMap(); export function createQueryDocumentCache(maxSize = 100) { @@ -51,3 +51,25 @@ export function executeValidated(args) { return errors.length > 0 ? {errors} : execute(args); }); } + +export function executeQueryValidated(args) { + return Promise.resolve().then(() => { + let document = args.cache === undefined + ? undefined + : getCachedQuery(args.cache, args.query); + + if (document === undefined) { + try { + document = parse(args.query); + } catch (error) { + return {errors: [error]}; + } + if (args.cache !== undefined) { + setCachedQuery(args.cache, args.query, document); + } + } + + const {query: _query, cache: _cache, ...executeArgs} = args; + return executeValidated({...executeArgs, document}); + }); +} diff --git a/tests/multi-schema-test.sh b/tests/multi-schema-test.sh index 6da1aab9..bece1e89 100755 --- a/tests/multi-schema-test.sh +++ b/tests/multi-schema-test.sh @@ -101,6 +101,35 @@ admin_cache_output=$(cd "$fixture_dir" && RESGRAPH_INCREMENTAL_DEBUG=1 node "$cl [[ "$public_cache_output" == *'Incremental cache hit'* ]] [[ "$admin_cache_output" == *'Incremental cache hit'* ]] +manifest_failure_dir=$(mktemp -d /tmp/resgraph-manifest-failure.XXXXXX) +node -e ' + const fs = require("node:fs"); + const [configPath, projectRoot, includePath, outputFolder] = process.argv.slice(1); + fs.writeFileSync(configPath, JSON.stringify({ + schemas: { + public: { + projectRoot, + include: [includePath], + outputFolder, + moduleName: "PublicSchema", + contextType: "PublicContext.context" + } + } + })); +' "$manifest_failure_dir/resgraph.json" "$fixture_dir" \ + "$fixture_dir/src" "$fixture_dir/src/generated/public" +printf 'blocks ownership directory creation\n' >"$manifest_failure_dir/lib" +set +e +manifest_failure_output=$(cd "$manifest_failure_dir" && node "$cli" build 2>&1) +manifest_failure_status=$? +set -e +rm -rf "$manifest_failure_dir" +if [[ $manifest_failure_status -eq 0 ]]; then + printf 'Build unexpectedly ignored an ownership-manifest write failure.\n' >&2 + exit 1 +fi +[[ "$manifest_failure_output" == *'ENOTDIR'* || "$manifest_failure_output" == *'not a directory'* ]] + config_backup=$(mktemp /tmp/resgraph-config.XXXXXX) cp "$fixture_dir/resgraph.json" "$config_backup" restore_config() { @@ -462,6 +491,13 @@ test ! -e "$fixture_dir/package-a/src/generated/ResGraphSchema.res" test ! -e "$fixture_dir/package-a/src/generated/ResGraphSchema.resi" test ! -e "$fixture_dir/package-a/src/generated/interface_obsolete.res" test -f "$fixture_dir/package-a/src/generated/interface_custom.res" + +printf 'let preserved = true\n' >"$fixture_dir/package-a/src/generated/ResGraphSchema.res" +printf 'let preserved: bool\n' >"$fixture_dir/package-a/src/generated/ResGraphSchema.resi" +(cd "$fixture_dir/central" && node "$cli" build package-a >/dev/null) +assert_contains "$fixture_dir/package-a/src/generated/ResGraphSchema.res" 'let preserved = true' +assert_contains "$fixture_dir/package-a/src/generated/ResGraphSchema.resi" 'let preserved: bool' + restore_legacy_generated trap - EXIT (cd "$fixture_dir/package-a" && "$rescript_bin") diff --git a/tests/multi-schema/package-a/src/generated/ResGraphSchema.res b/tests/multi-schema/package-a/src/generated/ResGraphSchema.res index 7d64cece..0cd6b2d7 100644 --- a/tests/multi-schema/package-a/src/generated/ResGraphSchema.res +++ b/tests/multi-schema/package-a/src/generated/ResGraphSchema.res @@ -1,3 +1,5 @@ +/* @generated by ResGraph legacy schema */ + @@warning("-27-32") open ResGraph__GraphQLJs diff --git a/tests/multi-schema/package-a/src/generated/ResGraphSchema.resi b/tests/multi-schema/package-a/src/generated/ResGraphSchema.resi index d8f77043..5d249565 100644 --- a/tests/multi-schema/package-a/src/generated/ResGraphSchema.resi +++ b/tests/multi-schema/package-a/src/generated/ResGraphSchema.resi @@ -1 +1,3 @@ +/* @generated by ResGraph legacy schema */ + let schema: ResGraph.schema diff --git a/tests/runtime-bindings.mjs b/tests/runtime-bindings.mjs index dd7ef5b9..a59d9505 100644 --- a/tests/runtime-bindings.mjs +++ b/tests/runtime-bindings.mjs @@ -48,6 +48,14 @@ const typedResults = await DataLoader.loadManyResults(typedResultsLoader, [ assert.deepEqual(typedResults[0], {TAG: "Ok", _0: "typed:ok"}); assert.match(typedResults[1]._0.message, /typed loader error/); +const customException = {RE_EXN_ID: "Custom_exception"}; +const customExceptionLoader = DataLoader.makeBatchedResults(async () => [ + {TAG: "Error", _0: customException}, +]); +const [customExceptionResult] = await DataLoader.loadManyResults(customExceptionLoader, ["key"]); +assert.equal(customExceptionResult.TAG, "Error"); +assert.equal(customExceptionResult._0, customException); + const queryType = new GraphQLObjectType({ name: "Query", fields: { @@ -85,6 +93,8 @@ assert.notEqual(Execute.getCachedQuery(cache, query), undefined); const invalidQueryResult = await Execute.executeToJson(schema, "{ missing }", undefined); assert.match(invalidQueryResult.errors[0].message, /Cannot query field "missing"/); +const malformedQueryResult = await Execute.executeToJson(schema, "{", undefined); +assert.match(malformedQueryResult.errors[0].message, /Syntax Error/); assert.deepEqual( await Execute.executeToJson(schema, "{ greeting }", undefined, undefined, []), {errors: [{message: "GraphQL variables must be a JSON object or null."}]}, diff --git a/tests/src/__generated__/ResGraphSchema.res b/tests/src/__generated__/ResGraphSchema.res index 8015a5b1..0cd1aa62 100644 --- a/tests/src/__generated__/ResGraphSchema.res +++ b/tests/src/__generated__/ResGraphSchema.res @@ -1,3 +1,5 @@ +/* @generated by ResGraph legacy schema */ + @@warning("-27-32") open ResGraph__GraphQLJs diff --git a/tests/src/__generated__/ResGraphSchema.resi b/tests/src/__generated__/ResGraphSchema.resi index d8f77043..5d249565 100644 --- a/tests/src/__generated__/ResGraphSchema.resi +++ b/tests/src/__generated__/ResGraphSchema.resi @@ -1 +1,3 @@ +/* @generated by ResGraph legacy schema */ + let schema: ResGraph.schema From f18c517b1820c367131730d67523ec7ba97fd48c Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sat, 1 Aug 2026 11:11:41 +0200 Subject: [PATCH 5/6] Validate synthetic GraphQL type names --- src/ml/ArchitectureTests.ml | 50 ++++++++++++++++++++++++++++++ src/ml/GenerateSchemaValidation.ml | 22 ++++++++----- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/ml/ArchitectureTests.ml b/src/ml/ArchitectureTests.ml index 0c4e66d7..1271f9f8 100644 --- a/src/ml/ArchitectureTests.ml +++ b/src/ml/ArchitectureTests.ml @@ -172,10 +172,60 @@ let testCrossKindGraphqlNameCollision () = (List.length schemaState.diagnostics = 1) "Cross-kind GraphQL type names must be rejected before emission." +let testSyntheticGraphqlNameCollisions () = + let schemaState = makeSchemaState () in + let fileUri = Uri.fromPath "/tmp/Schema.res" in + let typeLocation typeName = + { + fileName = "Schema"; + fileUri; + modulePath = []; + typeName; + loc = Location.none; + } + in + let addEnum id displayName = + Hashtbl.add schemaState.enums id + { + id; + displayName; + values = []; + description = None; + typeLocation = Concrete (typeLocation id); + } + in + addEnum "syntheticObjectEnum" "SyntheticObject"; + addEnum "syntheticInputEnum" "SyntheticInput"; + Hashtbl.add schemaState.types "syntheticObject" + { + id = "syntheticObject"; + displayName = "SyntheticObject"; + fields = []; + description = None; + typeLocation = None; + syntheticTypeLocation = Some {fileUri; loc = Location.none}; + interfaces = []; + explicitInterfaces = []; + }; + Hashtbl.add schemaState.inputObjects "syntheticInput" + { + id = "syntheticInput"; + displayName = "SyntheticInput"; + fields = []; + description = None; + typeLocation = None; + syntheticTypeLocation = Some {fileUri; loc = Location.none}; + }; + GenerateSchemaValidation.validateTypeNameUniqueness schemaState; + assertTrue + (List.length schemaState.diagnostics = 2) + "Synthetic GraphQL types must participate in cross-kind name validation." + let () = testGenerationContextScopesSummariesByPackage (); testInputUnionRegistry (); testAtomicWrite (); testWriteFailureIsRaised (); testCrossKindGraphqlNameCollision (); + testSyntheticGraphqlNameCollisions (); print_endline "Native architecture fixtures passed." diff --git a/src/ml/GenerateSchemaValidation.ml b/src/ml/GenerateSchemaValidation.ml index 74755f02..1987e4b4 100644 --- a/src/ml/GenerateSchemaValidation.ml +++ b/src/ml/GenerateSchemaValidation.ml @@ -333,17 +333,23 @@ let validateTypeNameUniqueness (schemaState : schemaState) = in schemaState.types |> Hashtbl.iter (fun _name (typ : gqlObjectType) -> - match typ.typeLocation with - | None -> () - | Some location -> - addTypeLocation ~name:typ.displayName ~kind:"object type" location); + match (typ.typeLocation, typ.syntheticTypeLocation) with + | Some location, _ -> + addTypeLocation ~name:typ.displayName ~kind:"object type" location + | None, Some location -> + add ~name:typ.displayName ~kind:"object type" ~loc:location.loc + ~fileUri:location.fileUri + | None, None -> ()); schemaState.inputObjects |> Hashtbl.iter (fun _name (typ : gqlInputObjectType) -> - match typ.typeLocation with - | None -> () - | Some location -> + match (typ.typeLocation, typ.syntheticTypeLocation) with + | Some location, _ -> add ~name:typ.displayName ~kind:"input object" ~loc:location.loc - ~fileUri:location.fileUri); + ~fileUri:location.fileUri + | None, Some location -> + add ~name:typ.displayName ~kind:"input object" ~loc:location.loc + ~fileUri:location.fileUri + | None, None -> ()); schemaState.inputUnions |> Hashtbl.iter (fun _name (typ : gqlInputUnionType) -> add ~name:typ.displayName ~kind:"input union" ~loc:typ.typeLocation.loc From bea1d714dc1db59cdd38272bfbe10d21e87513e1 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sat, 1 Aug 2026 11:32:15 +0200 Subject: [PATCH 6/6] Secure LSP temporary files --- CHANGELOG.md | 3 ++- cli/Utils.res | 34 +++++++++++++++++++++------------- tests/runtime-bindings.mjs | 23 +++++++++++++++++++++++ 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f54ac225..7f2ca5bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,8 @@ - Scope shared native summaries by package and keep artifact-write failures inside a single structured batch response. - Preserve numeric and string JSON-RPC IDs and contain language-server handler - failures. + failures. Completion buffers now use private, OS-created temporary directories + that are cleaned after both successful and failed requests. ## 1.3.0 diff --git a/cli/Utils.res b/cli/Utils.res index caf0262f..51f35bbb 100644 --- a/cli/Utils.res +++ b/cli/Utils.res @@ -352,14 +352,7 @@ let setupWatcher = (~onResult, ~onStartRebuild, ~config: schemaConfig) => { compilerWatcher } -let tempFilePrefix = "resgraph_support_file_" ++ Process.process->Process.pid->Int.toString ++ "_" -let tempFileId = ref(0) - -let createFileInTempDir = (~extension="") => { - let tempFileName = tempFilePrefix ++ tempFileId.contents->Int.toString ++ extension - tempFileId := tempFileId.contents + 1 - Path.join([Os.tmpdir(), tempFileName]) -} +@module("node:fs") external makeTemporaryDirectory: string => string = "mkdtempSync" let removeFileIfExists = path => { if Fs.existsSync(path) { @@ -371,21 +364,36 @@ let removeFileIfExists = path => { } } +let removeDirectoryIfExists = path => { + if Fs.existsSync(path) { + try { + Fs.rmdirSync(path) + } catch { + | _ => () + } + } +} + let rethrow: 'error => 'value = %raw(`error => { throw error }`) let withTemporaryFile = (~contents, callback) => { - let path = createFileInTempDir() - Fs.writeFileSyncWith(path, Buffer.fromString(contents), {encoding: "utf-8"}) + let directory = makeTemporaryDirectory(Path.join([Os.tmpdir(), "resgraph-support-"])) + let path = Path.join([directory, "source.res"]) + let cleanup = () => { + removeFileIfExists(path) + removeDirectoryIfExists(directory) + } try { + Fs.writeFileSyncWith(path, Buffer.fromString(contents), {encoding: "utf-8"}) let result = callback(path) - removeFileIfExists(path) + cleanup() result } catch { | Exn.Error(error) => - removeFileIfExists(path) + cleanup() rethrow(error) | _ => - removeFileIfExists(path) + cleanup() panic("Unknown failure while using a ResGraph temporary file.") } } diff --git a/tests/runtime-bindings.mjs b/tests/runtime-bindings.mjs index a59d9505..f0fd42a5 100644 --- a/tests/runtime-bindings.mjs +++ b/tests/runtime-bindings.mjs @@ -1,14 +1,37 @@ import assert from "node:assert/strict"; +import {existsSync, readFileSync} from "node:fs"; +import path from "node:path"; import { GraphQLInt, GraphQLObjectType, GraphQLSchema, GraphQLString, } from "../node_modules/graphql/index.js"; +import * as CliUtils from "../cli/Utils.mjs"; import * as DataLoader from "../src/res/DataLoader.mjs"; import {Execute} from "../src/res/ResGraph.mjs"; import {stableStringify} from "../src/res/stableStringify.mjs"; +let temporaryDirectory; +const temporaryResult = CliUtils.withTemporaryFile("temporary contents", temporaryPath => { + temporaryDirectory = path.dirname(temporaryPath); + assert.equal(readFileSync(temporaryPath, "utf8"), "temporary contents"); + return "callback result"; +}); +assert.equal(temporaryResult, "callback result"); +assert.equal(existsSync(temporaryDirectory), false); + +const expectedTemporaryError = new Error("expected temporary callback failure"); +assert.throws( + () => + CliUtils.withTemporaryFile("temporary contents", temporaryPath => { + temporaryDirectory = path.dirname(temporaryPath); + throw expectedTemporaryError; + }), + error => error === expectedTemporaryError, +); +assert.equal(existsSync(temporaryDirectory), false); + assert.notEqual( stableStringify([1, 2]), stableStringify([2, 1]),