From c04956f4a624ab2ef4d152e437ccd7df0a3f733d Mon Sep 17 00:00:00 2001 From: David Chen Date: Fri, 24 Jul 2026 15:46:43 -0700 Subject: [PATCH 1/7] feat(ts): add rootless Podman candidate certifier --- .changeset/fresh-podman-candidate.md | 8 + packages/ts/package.json | 30 + ...stgresql-docker-engine-api-v0-node.test.ts | 89 ++- ...n-libpod-api-v0-rootless-node.live.test.ts | 65 ++ ...esql-podman-libpod-api-v0-rootless.test.ts | 181 +++++ packages/ts/src/__tests__/subpaths.test.ts | 27 +- .../node.ts | 27 +- ...ostgresql-podman-libpod-api-v0-rootless.ts | 143 ++++ .../node.ts | 642 ++++++++++++++++++ .../executors/local-container-postgresql.ts | 184 ++++- packages/ts/tsup.config.ts | 2 + scripts/check-no-raw-async.ts | 4 + .../src/content/docs/integrations/matrix.md | 2 + 13 files changed, 1380 insertions(+), 24 deletions(-) create mode 100644 .changeset/fresh-podman-candidate.md create mode 100644 packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless-node.live.test.ts create mode 100644 packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless.test.ts create mode 100644 packages/ts/src/executors/local-container-postgresql-podman-libpod-api-v0-rootless.ts create mode 100644 packages/ts/src/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.ts diff --git a/.changeset/fresh-podman-candidate.md b/.changeset/fresh-podman-candidate.md new file mode 100644 index 00000000..fb96b601 --- /dev/null +++ b/.changeset/fresh-podman-candidate.md @@ -0,0 +1,8 @@ +--- +"@graphrefly/ts": minor +--- + +Add the independent rootless native Libpod API v0 PostgreSQL backend family and +its Node-local candidate certifier. The certifier keeps socket discovery and +resource handles private and remains unavailable until the remaining D645 +network and cancellation effect probes are certified. diff --git a/packages/ts/package.json b/packages/ts/package.json index 59a7bb82..4b3ec991 100644 --- a/packages/ts/package.json +++ b/packages/ts/package.json @@ -276,6 +276,36 @@ "default": "./dist/executors/local-container-postgresql-docker-engine-api-v0/node.cjs" } }, + "./executors/local-container-postgresql-podman-libpod-api-v0-rootless": { + "import": { + "types": "./dist/executors/local-container-postgresql-podman-libpod-api-v0-rootless.d.ts", + "default": "./dist/executors/local-container-postgresql-podman-libpod-api-v0-rootless.js" + }, + "require": { + "types": "./dist/executors/local-container-postgresql-podman-libpod-api-v0-rootless.d.cts", + "default": "./dist/executors/local-container-postgresql-podman-libpod-api-v0-rootless.cjs" + } + }, + "./executors/local-container-postgresql-podman-libpod-api-v0-rootless/node": { + "node": { + "import": { + "types": "./dist/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.d.ts", + "default": "./dist/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.js" + }, + "require": { + "types": "./dist/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.d.cts", + "default": "./dist/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.cjs" + } + }, + "import": { + "types": "./dist/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.d.ts", + "default": "./dist/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.js" + }, + "require": { + "types": "./dist/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.d.cts", + "default": "./dist/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.cjs" + } + }, "./executors/managed-cloud-postgresql": { "import": { "types": "./dist/executors/managed-cloud-postgresql.d.ts", diff --git a/packages/ts/src/__tests__/local-container-postgresql-docker-engine-api-v0-node.test.ts b/packages/ts/src/__tests__/local-container-postgresql-docker-engine-api-v0-node.test.ts index 8732fd31..e8e005aa 100644 --- a/packages/ts/src/__tests__/local-container-postgresql-docker-engine-api-v0-node.test.ts +++ b/packages/ts/src/__tests__/local-container-postgresql-docker-engine-api-v0-node.test.ts @@ -129,7 +129,8 @@ describe("Node-local Docker Engine API v0 certifier entry (D624)", () => { expect(createBody).toMatchObject({ Image: imageRef, User: "65532:65532", - Cmd: ["sh", "-ec", 'test "$(id -u)" != "0"'], + Entrypoint: ["/bin/sh", "-ec"], + Cmd: ['test "$(id -u)" != "0"'], Env: [], StopTimeout: 5, HostConfig: { @@ -857,7 +858,7 @@ describe("Node-local Docker Engine API v0 certifier entry (D624)", () => { certifiedHostMatrix: certifiedHostMatrix(), observedAtMs: 20, ttlMs: 100, - maxResponseBytes: 1024, + maxResponseBytes: 2048, proofs: proofAdapter(), }, ); @@ -996,6 +997,86 @@ describe("Node-local Docker Engine API v0 certifier entry (D624)", () => { expect(JSON.stringify(preflight)).not.toContain("unexpected-boundary"); }); + it.each([ + { + name: "the image entrypoint is inherited", + mutate: (inspected: Record) => ({ + ...inspected, + Config: { + ...(inspected.Config as Record), + Entrypoint: ["docker-entrypoint.sh"], + }, + }), + }, + { + name: "the configured command is the legacy shell-shaped command", + mutate: (inspected: Record) => ({ + ...inspected, + Config: { + ...(inspected.Config as Record), + Cmd: ["sh", "-ec", 'test "$(id -u)" != "0"'], + }, + }), + }, + { + name: "the resolved executable does not match the explicit entrypoint", + mutate: (inspected: Record) => ({ + ...inspected, + Path: "docker-entrypoint.sh", + }), + }, + { + name: "the resolved arguments do not match the explicit command", + mutate: (inspected: Record) => ({ + ...inspected, + Args: ["-ec", "id -u"], + }), + }, + ])("fails closed before start when $name", async ({ mutate }) => { + const docker = installDockerApiMock({ + rawInspectContainerBody: JSON.stringify( + mutate( + safeInspectContainerBody( + "__GRAPHREFLY_PROBE_NETWORK_NAME__", + "__GRAPHREFLY_PROBE_CONTAINER_NAME__", + ), + ), + ), + }); + const mod = await import( + "../executors/local-container-postgresql-docker-engine-api-v0/node.js" + ); + const preflight = await mod.certifyDockerEngineApiV0LocalContainerPostgresqlWithNodeLocalDocker( + { + manifest: manifest(), + imageRef, + hostPlatform: "linux/amd64", + guestPlatform: "linux/amd64", + runtimeRevision: "docker-engine:24.0.7", + certifiedHostMatrix: certifiedHostMatrix(), + observedAtMs: 20, + ttlMs: 100, + proofs: proofAdapter(), + }, + ); + + expect(localContainerPostgresqlDockerEngineApiV0PreflightReadiness(preflight)).toMatchObject({ + state: "unavailable", + isolationVerified: false, + cleanupVerified: true, + }); + expect(docker.calls.map((c) => `${c.method} ${c.path}`)).toEqual( + expect.arrayContaining([ + `GET /containers/${containerId}/json`, + `DELETE /containers/${containerId}?force=true&v=true`, + `DELETE /networks/${networkId}`, + ]), + ); + expect(docker.calls.map((c) => `${c.method} ${c.path}`)).not.toContain( + `POST /containers/${containerId}/start`, + ); + }); + it("fails closed when Docker inspect body does not match the private probe container id", async () => { const docker = installDockerApiMock({ rawInspectContainerBody: JSON.stringify({ @@ -2715,9 +2796,13 @@ function safeInspectContainerBody( return { Id: containerId, Name: `/${containerName}`, + Path: "/bin/sh", + Args: ["-ec", 'test "$(id -u)" != "0"'], Config: { Image: containerImageRef, User: "65532:65532", + Entrypoint: ["/bin/sh", "-ec"], + Cmd: ['test "$(id -u)" != "0"'], OpenStdin: false, Env: ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"], Labels: { diff --git a/packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless-node.live.test.ts b/packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless-node.live.test.ts new file mode 100644 index 00000000..4eb7584e --- /dev/null +++ b/packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless-node.live.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + LOCAL_CONTAINER_POSTGRESQL_COMPATIBILITY, + LOCAL_CONTAINER_POSTGRESQL_PODMAN_LIBPOD_API_V0_ROOTLESS_BACKEND_FAMILY, + localContainerPostgresqlManifest, + localContainerPostgresqlPodmanLibpodApiV0RootlessPreflightReadiness, +} from "../executors/local-container-postgresql.js"; +import { certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWithNode } from "../executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.js"; + +const live = process.env.GRAPHREFLY_D645_LIVE_PODMAN === "1"; +const digest = "sha256:d13105efe29040feb046f1c5fc9f0a98e58d8980c85300306a325c80df9a45c4"; +const imageRef = `docker.io/library/postgres@${digest}`; + +describe.runIf(live)("Node-local Podman Libpod API v0 rootless candidate (D645 live)", () => { + it("proves the implemented containment and secret lifecycle without claiming full readiness", async () => { + const manifest = localContainerPostgresqlManifest({ + kind: "local-container-postgresql-manifest", + manifestId: "manifest:pg-d645-live", + revision: "revision:d645-live", + fingerprint: "fingerprint:pg-d645-live", + imageDigest: digest, + engineCompatibilityRevision: LOCAL_CONTAINER_POSTGRESQL_COMPATIBILITY, + backendFamily: LOCAL_CONTAINER_POSTGRESQL_PODMAN_LIBPOD_API_V0_ROOTLESS_BACKEND_FAMILY, + backendCertificationRevision: "podman-certification:d645-candidate-v0", + recipeRevision: "postgresql-read-only-query-v1", + sandboxRevision: "sandbox:d645-live", + mountPolicyRevision: "mount:d645-live", + networkPolicyRevision: "network:deny:d645-live", + resourcePolicyRevision: "resources:d645-live", + stopGraceMs: 5, + attestationRefs: [{ kind: "attestation", id: "manifest:d645-live" }], + }); + const preflight = await certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWithNode({ + manifest, + imageRef, + observedAtMs: 100, + ttlMs: 1_000, + }); + const readiness = + localContainerPostgresqlPodmanLibpodApiV0RootlessPreflightReadiness(preflight); + + expect(readiness).toMatchObject({ + state: "unavailable", + backendFamily: "podman-libpod-api-v0-rootless", + engineReachable: true, + backendFamilyVerified: true, + imageDigestVerified: true, + isolationVerified: true, + nonRootUserVerified: true, + noNewPrivilegesVerified: true, + readOnlyRootFilesystemVerified: true, + noEngineSocketMountVerified: true, + noHostNetworkVerified: true, + noHostBindMountVerified: true, + cpuMemoryPidsTimeBoundsVerified: true, + secretDestructionVerified: true, + cleanupVerified: true, + dnsRebindingResistanceVerified: false, + cancellationVerified: false, + }); + expect(JSON.stringify(preflight)).not.toContain("podman-machine-default-api.sock"); + expect(JSON.stringify(preflight)).not.toContain("d645-canary-value"); + expect(JSON.stringify(preflight)).not.toContain("/var/folders/"); + }, 30_000); +}); diff --git a/packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless.test.ts b/packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless.test.ts new file mode 100644 index 00000000..8a63d107 --- /dev/null +++ b/packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it, vi } from "vitest"; +import { + LOCAL_CONTAINER_POSTGRESQL_COMPATIBILITY, + LOCAL_CONTAINER_POSTGRESQL_PODMAN_LIBPOD_API_V0_ROOTLESS_BACKEND_FAMILY, + type LocalContainerPostgresqlManifest, + type LocalContainerPostgresqlPodmanLibpodApiV0RootlessPreflight, + localContainerPostgresqlManifest, + localContainerPostgresqlPodmanLibpodApiV0RootlessPreflightReadiness, +} from "../executors/local-container-postgresql.js"; +import { + type PodmanLibpodApiV0RootlessLocalContainerPostgresqlHost, + podmanLibpodApiV0RootlessLocalContainerPostgresqlDriver, +} from "../executors/local-container-postgresql-podman-libpod-api-v0-rootless.js"; + +const digest = `sha256:${"d".repeat(64)}`; +const imageRef = `registry.example.test/graphrefly/postgresql@${digest}`; +const refs = [ + { kind: "limitation", id: "podman-libpod-api-v0-rootless-only" }, + { kind: "limitation", id: "digest-pinned-image" }, + { kind: "limitation", id: "host-injected-runtime-driver" }, + { kind: "limitation", id: "non-root-no-new-privileges" }, + { kind: "limitation", id: "read-only-bounded-filesystem" }, + { kind: "limitation", id: "cpu-memory-pids-time-bounds" }, + { kind: "policy", id: "deny-by-default-isolation" }, + { kind: "policy", id: "destination-pinned-egress" }, + { kind: "policy", id: "runtime-ephemeral-auth-material-mount" }, + { kind: "policy", id: "remove-on-terminal-cleanup" }, + { kind: "policy", id: "engine-api-not-mounted" }, + { kind: "policy", id: "host-mounts-denied" }, + { kind: "policy", id: "metadata-link-local-loopback-host-gateway-denied" }, + { kind: "policy", id: "dns-rebinding-resistance" }, + { kind: "readiness", id: "local-container-cleanup-removal-verified" }, + { kind: "readiness", id: "local-container-cancellation-verified" }, + { kind: "readiness", id: "ephemeral-auth-material-destruction-verified" }, +] as const; +const attestations = [ + { kind: "attestation", id: "podman-libpod-api-v0-rootless:readiness:test" }, + { kind: "attestation", id: "podman-libpod-api-v0-rootless:containment:test" }, + { kind: "attestation", id: "podman-libpod-api-v0-rootless:network:test" }, + { kind: "attestation", id: "podman-libpod-api-v0-rootless:cancellation-cleanup:test" }, +] as const; + +function manifest(): LocalContainerPostgresqlManifest { + return localContainerPostgresqlManifest({ + kind: "local-container-postgresql-manifest", + manifestId: "manifest:pg-d645", + revision: "revision:d645", + fingerprint: "fingerprint:pg-d645", + imageDigest: digest, + engineCompatibilityRevision: LOCAL_CONTAINER_POSTGRESQL_COMPATIBILITY, + backendFamily: LOCAL_CONTAINER_POSTGRESQL_PODMAN_LIBPOD_API_V0_ROOTLESS_BACKEND_FAMILY, + backendCertificationRevision: "podman-certification:d645-v0", + recipeRevision: "postgresql-read-only-query-v1", + sandboxRevision: "sandbox:d645", + mountPolicyRevision: "mount:d645", + networkPolicyRevision: "network:deny:d645", + resourcePolicyRevision: "resources:d645", + stopGraceMs: 5, + attestationRefs: [{ kind: "attestation", id: "manifest:d645" }], + }); +} + +function preflight( + patch: Partial = {}, +): LocalContainerPostgresqlPodmanLibpodApiV0RootlessPreflight { + return { + kind: "local-container-postgresql-podman-libpod-api-v0-rootless-preflight", + manifestFingerprint: manifest().fingerprint, + backendCertificationRevision: manifest().backendCertificationRevision, + observedAtMs: 1, + expiresAtMs: 100, + hostPlatform: "darwin/arm64", + engineApiRevision: "libpod-api:5.0.3", + engineRevision: "podman:5.0.3", + runtimeRevision: "crun:1.14.4", + guestPlatform: "linux/arm64", + vmRuntimeRevision: "podman-machine:applehv-v1", + engineReachable: true, + compatibilityVerified: true, + rootlessVerified: true, + hostPlatformVerified: true, + imageDigestPresent: true, + imageDigestVerified: true, + recipeVerified: true, + isolationVerified: true, + nonRootUserVerified: true, + noNewPrivilegesVerified: true, + readOnlyRootFilesystemVerified: true, + boundedFilesystemImportVerified: true, + noEngineSocketMountVerified: true, + noHostNetworkVerified: true, + noHostBindMountVerified: true, + destinationPinnedEgressDenyVerified: true, + metadataEgressDenyVerified: true, + linkLocalEgressDenyVerified: true, + loopbackEgressDenyVerified: true, + hostGatewayEgressDenyVerified: true, + dnsRebindingResistanceVerified: true, + cpuMemoryPidsTimeBoundsVerified: true, + cancellationVerified: true, + cleanupVerified: true, + artifactResolverReady: true, + credentialResolverReady: true, + secretDestructionVerified: true, + limitationRefs: refs, + attestationRefs: attestations, + ...patch, + }; +} + +describe("Podman native Libpod API v0 rootless PostgreSQL contract (D645)", () => { + it("accepts the Podman family without weakening Docker-family validation", () => { + expect(manifest().backendFamily).toBe("podman-libpod-api-v0-rootless"); + expect(() => + localContainerPostgresqlManifest({ + ...manifest(), + backendFamily: "podman-docker-compat" as "podman-libpod-api-v0-rootless", + }), + ).toThrow(/manifest contract/i); + }); + + it("requires every family-specific live proof before ready", () => { + expect( + localContainerPostgresqlPodmanLibpodApiV0RootlessPreflightReadiness(preflight()), + ).toMatchObject({ + state: "ready", + backendFamily: "podman-libpod-api-v0-rootless", + backendFamilyVerified: true, + }); + expect( + localContainerPostgresqlPodmanLibpodApiV0RootlessPreflightReadiness( + preflight({ dnsRebindingResistanceVerified: false }), + ), + ).toMatchObject({ state: "unavailable", dnsRebindingResistanceVerified: false }); + }); + + it("keeps runtime effects behind the focused host contract", async () => { + const binding = Object.freeze({ private: true }); + const host: PodmanLibpodApiV0RootlessLocalContainerPostgresqlHost = { + createRunContainer: vi.fn(async () => ({ ok: true, value: binding })), + startRunContainer: vi.fn(async () => ({ ok: true, value: undefined })), + waitRunContainer: vi.fn(async () => ({ + ok: true, + value: { columns: [], rows: [], rowCount: 0 }, + })), + stopRunContainer: vi.fn(async () => ({ ok: true, value: undefined })), + killRunContainer: vi.fn(async () => ({ ok: true, value: undefined })), + removeRunContainer: vi.fn(async () => ({ ok: true, value: undefined })), + }; + const driver = podmanLibpodApiV0RootlessLocalContainerPostgresqlDriver({ host, imageRef }); + const context = { + runId: "run:1", + attempt: 1, + sessionEpoch: "epoch:1", + manifestFingerprint: manifest().fingerprint, + signal: new AbortController().signal, + }; + const created = await driver.create(context, { + kind: "postgresql-query-tool-arguments", + sourceBindingId: "source:1", + statement: "SELECT 1", + parameters: [], + readOnly: true, + maxRows: 1, + }); + expect(created).toBe(binding); + await driver.remove(created, context); + expect(host.removeRunContainer).toHaveBeenCalledOnce(); + }); + + it("does not expose caller matrix, socket, endpoint, or proof injection", async () => { + const surface = await import( + "../executors/local-container-postgresql-podman-libpod-api-v0-rootless.js" + ); + expect(Object.keys(surface).sort()).toEqual([ + "PODMAN_LIBPOD_API_V0_ROOTLESS_BROKER_COMPATIBILITY", + "PODMAN_LIBPOD_API_V0_ROOTLESS_CERTIFIER_COMPATIBILITY", + "podmanLibpodApiV0RootlessLocalContainerPostgresqlDriver", + ]); + }); +}); diff --git a/packages/ts/src/__tests__/subpaths.test.ts b/packages/ts/src/__tests__/subpaths.test.ts index 837c5ad3..72568191 100644 --- a/packages/ts/src/__tests__/subpaths.test.ts +++ b/packages/ts/src/__tests__/subpaths.test.ts @@ -39,6 +39,8 @@ import * as executorExecutionEnvironment from "../executors/execution-environmen import * as executorLocalContainerPostgresql from "../executors/local-container-postgresql.js"; import * as executorLocalContainerPostgresqlDockerEngineApiV0Node from "../executors/local-container-postgresql-docker-engine-api-v0/node.js"; import * as executorLocalContainerPostgresqlDockerEngineApiV0 from "../executors/local-container-postgresql-docker-engine-api-v0.js"; +import * as executorLocalContainerPostgresqlPodmanLibpodApiV0RootlessNode from "../executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.js"; +import * as executorLocalContainerPostgresqlPodmanLibpodApiV0Rootless from "../executors/local-container-postgresql-podman-libpod-api-v0-rootless.js"; import * as executorManagedCloudPostgresql from "../executors/managed-cloud-postgresql.js"; import * as executorManagedUntrustedJsCompute from "../executors/managed-untrusted-js-compute.js"; import * as executorPostgresqlRunOperations from "../executors/postgresql-run-operations.js"; @@ -284,6 +286,8 @@ describe("package subpath barrels (D40/D41 intent parity)", () => { "./executors/local-container-postgresql", "./executors/local-container-postgresql-docker-engine-api-v0", "./executors/local-container-postgresql-docker-engine-api-v0/node", + "./executors/local-container-postgresql-podman-libpod-api-v0-rootless", + "./executors/local-container-postgresql-podman-libpod-api-v0-rootless/node", "./executors/managed-cloud-postgresql", "./executors/managed-untrusted-js-compute", "./executors/postgresql-run-operations", @@ -456,6 +460,7 @@ describe("package subpath barrels (D40/D41 intent parity)", () => { expect(typeof adapters.toHttp).toBe("function"); expect(typeof adapters.toProcess).toBe("function"); expect(typeof adapters.toWebSocket).toBe("function"); + expect(typeof adapters.attachKeyedRateLimitAuthority).toBe("function"); expect(typeof adapters.webSocketSession).toBe("function"); expect(typeof adapters.remoteCall).toBe("function"); expect(typeof adapters.remoteResponder).toBe("function"); @@ -952,6 +957,12 @@ describe("package subpath barrels (D40/D41 intent parity)", () => { expect( typeof executorLocalContainerPostgresqlDockerEngineApiV0Node.certifyDockerEngineApiV0LocalContainerPostgresqlWithNodeLocalDocker, ).toBe("function"); + expect( + typeof executorLocalContainerPostgresqlPodmanLibpodApiV0Rootless.podmanLibpodApiV0RootlessLocalContainerPostgresqlDriver, + ).toBe("function"); + expect( + typeof executorLocalContainerPostgresqlPodmanLibpodApiV0RootlessNode.certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWithNode, + ).toBe("function"); expect(Object.hasOwn(rootPackage, "certifyDockerEngineApiV0LocalContainerPostgresql")).toBe( false, ); @@ -965,6 +976,15 @@ describe("package subpath barrels (D40/D41 intent parity)", () => { expect(Object.hasOwn(rootPackage, "dockerEngineApiV0LocalContainerPostgresqlDriver")).toBe( false, ); + expect( + Object.hasOwn(rootPackage, "podmanLibpodApiV0RootlessLocalContainerPostgresqlDriver"), + ).toBe(false); + expect( + Object.hasOwn( + rootPackage, + "certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWithNode", + ), + ).toBe(false); expect(Object.hasOwn(rootPackage, "LocalSandboxDriver")).toBe(false); expect(typeof executorManagedCloudPostgresql.managedCloudPostgresqlRuntime).toBe("function"); expect(typeof executorManagedUntrustedJsCompute.managedUntrustedJsComputeRuntime).toBe( @@ -1045,7 +1065,12 @@ describe("package subpath barrels (D40/D41 intent parity)", () => { expect(typeof orchestration.breakerBundle).toBe("function"); expect(typeof orchestration.processBundle).toBe("function"); expect(typeof orchestration.processEffectRunner).toBe("function"); - expect(typeof orchestration.rateLimitBundle).toBe("function"); + expect(typeof orchestration.localFixedWindowRateLimitBundle).toBe("function"); + expect(typeof orchestration.keyedRateLimitAdmissionBundle).toBe("function"); + expect(Object.hasOwn(orchestration, "rateLimitBundle")).toBe(false); + expect(Object.hasOwn(rootPackage, "localFixedWindowRateLimitBundle")).toBe(false); + expect(Object.hasOwn(rootPackage, "keyedRateLimitAdmissionBundle")).toBe(false); + expect(Object.hasOwn(rootPackage, "attachKeyedRateLimitAuthority")).toBe(false); expect(typeof orchestration.timeoutBundle).toBe("function"); expect(typeof orchestration.requestSatisfactionProjector).toBe("function"); expect(typeof orchestration.effectRunCompletionProjector).toBe("function"); diff --git a/packages/ts/src/executors/local-container-postgresql-docker-engine-api-v0/node.ts b/packages/ts/src/executors/local-container-postgresql-docker-engine-api-v0/node.ts index c9dce058..7453c7c5 100644 --- a/packages/ts/src/executors/local-container-postgresql-docker-engine-api-v0/node.ts +++ b/packages/ts/src/executors/local-container-postgresql-docker-engine-api-v0/node.ts @@ -129,6 +129,8 @@ const PROBE_CONTAINER_MEMORY_BYTES = 128 * 1024 * 1024; const PROBE_CONTAINER_CPU_PERIOD = 100_000; const PROBE_CONTAINER_CPU_QUOTA = 50_000; const PROBE_CONTAINER_PIDS_LIMIT = 64; +const PROBE_CONTAINER_ENTRYPOINT = Object.freeze(["/bin/sh", "-ec"] as const); +const PROBE_CONTAINER_COMMAND = 'test "$(id -u)" != "0"'; const DOCKER_PATH_SAFE = /^[A-Za-z0-9._~:/@+-]+$/; const DOCKER_ID_SAFE = /^[a-f0-9]{12,64}$/i; const DOCKER_IMAGE_DIGEST = /^sha256:[a-f0-9]{64}$/i; @@ -542,7 +544,8 @@ function probeContainerCreateRequest( const body = { Image: imageRef, User: PROBE_CONTAINER_USER, - Cmd: ["sh", "-ec", 'test "$(id -u)" != "0"'], + Entrypoint: [...PROBE_CONTAINER_ENTRYPOINT], + Cmd: [PROBE_CONTAINER_COMMAND], Env: [], AttachStdout: false, AttachStderr: false, @@ -581,6 +584,7 @@ function probeContainerRequestPolicy( imageRef: string, ): DockerProbeContainerRequestPolicy { const hostConfig = plainObject(body.HostConfig); + const entrypoint = Array.isArray(body.Entrypoint) ? body.Entrypoint : []; const cmd = Array.isArray(body.Cmd) ? body.Cmd : []; const securityOpt = Array.isArray(hostConfig?.SecurityOpt) ? hostConfig.SecurityOpt : []; const capDrop = Array.isArray(hostConfig?.CapDrop) ? hostConfig.CapDrop : []; @@ -589,10 +593,11 @@ function probeContainerRequestPolicy( imageRefRequested: body.Image === imageRef, nonRootUserRequested: body.User === PROBE_CONTAINER_USER, rootUserProbeFails: - cmd.length === 3 && - cmd[0] === "sh" && - cmd[1] === "-ec" && - cmd[2] === 'test "$(id -u)" != "0"', + entrypoint.length === 2 && + entrypoint[0] === PROBE_CONTAINER_ENTRYPOINT[0] && + entrypoint[1] === PROBE_CONTAINER_ENTRYPOINT[1] && + cmd.length === 1 && + cmd[0] === PROBE_CONTAINER_COMMAND, noNewPrivilegesRequested: securityOpt.includes("no-new-privileges"), capabilitiesDroppedRequested: capDrop.length === 1 && capDrop[0] === "ALL", noPrivilegedModeRequested: hostConfig?.Privileged === false, @@ -976,11 +981,23 @@ function dockerInspectContainerIdentityVerified( const inspectedId = stringValue(record.Id); const inspectedName = stringValue(record.Name); const inspectedImageRef = stringValue(config.Image); + const inspectedEntrypoint = stringArray(config.Entrypoint); + const inspectedCommand = stringArray(config.Cmd); + const inspectedPath = stringValue(record.Path); + const inspectedArgs = stringArray(record.Args); const labels = plainObject(config.Labels); return ( inspectedId === privateContainer.id && (inspectedName === privateContainer.name || inspectedName === `/${privateContainer.name}`) && dockerInspectImageRefMatchesRequested(inspectedImageRef, privateContainer.requestedImageRef) && + inspectedEntrypoint?.length === PROBE_CONTAINER_ENTRYPOINT.length && + inspectedEntrypoint.every((value, index) => value === PROBE_CONTAINER_ENTRYPOINT[index]) && + inspectedCommand?.length === 1 && + inspectedCommand[0] === PROBE_CONTAINER_COMMAND && + inspectedPath === PROBE_CONTAINER_ENTRYPOINT[0] && + inspectedArgs?.length === 2 && + inspectedArgs[0] === PROBE_CONTAINER_ENTRYPOINT[1] && + inspectedArgs[1] === PROBE_CONTAINER_COMMAND && labels?.["dev.graphrefly.boundary"] === "d624-docker-engine-api-v0-certifier" && dockerInspectContainerNetworkIdentityVerified(record, privateContainer) ); diff --git a/packages/ts/src/executors/local-container-postgresql-podman-libpod-api-v0-rootless.ts b/packages/ts/src/executors/local-container-postgresql-podman-libpod-api-v0-rootless.ts new file mode 100644 index 00000000..1dfa20eb --- /dev/null +++ b/packages/ts/src/executors/local-container-postgresql-podman-libpod-api-v0-rootless.ts @@ -0,0 +1,143 @@ +/** D645 rootless native Libpod API v0 contract for D604 PostgreSQL containers. */ +import type { + LocalContainerPostgresqlDriver, + LocalContainerPostgresqlDriverContext, +} from "./local-container-postgresql.js"; +import { LOCAL_CONTAINER_POSTGRESQL_COMPATIBILITY } from "./local-container-postgresql.js"; +import type { + PostgresqlDriverQueryResult, + PostgresqlQueryToolArguments, +} from "./postgresql-tool-provider.js"; + +export const PODMAN_LIBPOD_API_V0_ROOTLESS_BROKER_COMPATIBILITY = + "graphrefly-local-container-postgresql-podman-libpod-api-v0-rootless-broker-v1" as const; +export const PODMAN_LIBPOD_API_V0_ROOTLESS_CERTIFIER_COMPATIBILITY = + "graphrefly-local-container-postgresql-podman-libpod-api-v0-rootless-certifier-v1" as const; + +export type PodmanLibpodApiV0RootlessHostResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false }; + +/** + * Runtime effects remain app-private. This contract carries no socket, endpoint, + * credential, secret handle, certified-host matrix, or proof override. + */ +export interface PodmanLibpodApiV0RootlessLocalContainerPostgresqlHost { + createRunContainer(opts: { + readonly imageRef: string; + readonly args: PostgresqlQueryToolArguments; + readonly context: LocalContainerPostgresqlDriverContext; + }): + | PodmanLibpodApiV0RootlessHostResult + | PromiseLike>; + startRunContainer( + binding: unknown, + context: LocalContainerPostgresqlDriverContext, + ): + | PodmanLibpodApiV0RootlessHostResult + | PromiseLike>; + waitRunContainer( + binding: unknown, + context: LocalContainerPostgresqlDriverContext, + ): + | PodmanLibpodApiV0RootlessHostResult + | PromiseLike>; + stopRunContainer( + binding: unknown, + context: LocalContainerPostgresqlDriverContext, + graceMs: number, + ): + | PodmanLibpodApiV0RootlessHostResult + | PromiseLike>; + killRunContainer( + binding: unknown, + context: LocalContainerPostgresqlDriverContext, + ): + | PodmanLibpodApiV0RootlessHostResult + | PromiseLike>; + removeRunContainer( + binding: unknown, + context: LocalContainerPostgresqlDriverContext, + ): + | PodmanLibpodApiV0RootlessHostResult + | PromiseLike>; +} + +export interface PodmanLibpodApiV0RootlessLocalContainerPostgresqlDriverOptions { + readonly host: PodmanLibpodApiV0RootlessLocalContainerPostgresqlHost; + readonly imageRef: string; +} + +export function podmanLibpodApiV0RootlessLocalContainerPostgresqlDriver( + opts: PodmanLibpodApiV0RootlessLocalContainerPostgresqlDriverOptions, +): LocalContainerPostgresqlDriver { + return Object.freeze({ + compatibility: LOCAL_CONTAINER_POSTGRESQL_COMPATIBILITY, + prepare: () => undefined, + create: async ( + context: LocalContainerPostgresqlDriverContext, + args: PostgresqlQueryToolArguments, + ): Promise => { + if (!digestPinned(opts.imageRef)) throw new TypeError("Podman image must be digest pinned."); + const created = await opts.host.createRunContainer({ + imageRef: opts.imageRef, + args, + context, + }); + if (!created.ok) throw new Error("Podman Libpod API v0 run container create failed."); + return created.value; + }, + start: async (binding: unknown, context: LocalContainerPostgresqlDriverContext) => { + const result = await opts.host.startRunContainer(binding, context); + if (!result.ok) throw new Error("Podman Libpod API v0 run container start failed."); + }, + wait: async ( + binding: unknown, + context: LocalContainerPostgresqlDriverContext, + ): Promise => { + const result = await opts.host.waitRunContainer(binding, context); + if (!result.ok) throw new Error("Podman Libpod API v0 run container wait failed."); + return result.value; + }, + stop: async ( + binding: unknown, + context: LocalContainerPostgresqlDriverContext, + graceMs: number, + ) => { + const result = await opts.host.stopRunContainer( + binding, + terminationContext(context), + graceMs, + ); + if (!result.ok) throw new Error("Podman Libpod API v0 run container stop failed."); + }, + kill: async (binding: unknown, context: LocalContainerPostgresqlDriverContext) => { + const result = await opts.host.killRunContainer(binding, terminationContext(context)); + if (!result.ok) throw new Error("Podman Libpod API v0 run container kill failed."); + }, + remove: async (binding: unknown, context: LocalContainerPostgresqlDriverContext) => { + const result = await opts.host.removeRunContainer(binding, terminationContext(context)); + if (!result.ok) throw new Error("Podman Libpod API v0 run container remove failed."); + }, + cleanup: () => undefined, + }); +} + +function terminationContext( + context: LocalContainerPostgresqlDriverContext, +): LocalContainerPostgresqlDriverContext { + return Object.freeze({ + runId: context.runId, + attempt: context.attempt, + sessionEpoch: context.sessionEpoch, + manifestFingerprint: context.manifestFingerprint, + signal: new AbortController().signal, + }); +} + +function digestPinned(value: string): boolean { + return ( + /^(?:[A-Za-z0-9][A-Za-z0-9._:/+-]{0,190}@)?sha256:[a-f0-9]{64}$/.test(value) && + value.length <= 255 + ); +} diff --git a/packages/ts/src/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.ts b/packages/ts/src/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.ts new file mode 100644 index 00000000..7ff0bf11 --- /dev/null +++ b/packages/ts/src/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.ts @@ -0,0 +1,642 @@ +/** Node-local D645 candidate certifier over the native, versioned Libpod API. */ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { lstat } from "node:fs/promises"; +import { request as httpRequest } from "node:http"; +import { promisify } from "node:util"; +import type { + LocalContainerPostgresqlManifest, + LocalContainerPostgresqlPodmanLibpodApiV0RootlessPreflight, +} from "../local-container-postgresql.js"; +import { + LOCAL_CONTAINER_POSTGRESQL_PODMAN_LIBPOD_API_V0_ROOTLESS_BACKEND_FAMILY, + localContainerPostgresqlManifest, + localContainerPostgresqlPodmanLibpodApiV0RootlessPreflightReadiness, +} from "../local-container-postgresql.js"; + +const execFileAsync = promisify(execFile); +const API_REVISION = "5.0.3"; +const BOUNDARY_LABEL = "d645-podman-libpod-api-v0-rootless-certifier"; +const DEFAULT_TTL_MS = 5 * 60 * 1000; +const DEFAULT_TIMEOUT_MS = 10_000; +const MAX_RESPONSE_BYTES = 256 * 1024; +const ID = /^[a-f0-9]{64}$/; +const SECRET_ID = /^[a-f0-9]{24,64}$/; +const DIGEST = /^sha256:[a-f0-9]{64}$/; +const SAFE_IMAGE = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,254}$/; +const PROBE_ENTRYPOINT = ["/bin/bash", "-ec"] as const; +const PROBE_COMMAND = + 'test "$(id -u)" != "0" && test "$(cat /run/secrets/d645-canary)" = "d645-canary-value" && test -z "$(getent hosts example.com || true)"'; + +const LIMITATION_REFS = Object.freeze([ + { kind: "limitation", id: "podman-libpod-api-v0-rootless-only" }, + { kind: "limitation", id: "digest-pinned-image" }, + { kind: "limitation", id: "host-injected-runtime-driver" }, + { kind: "limitation", id: "non-root-no-new-privileges" }, + { kind: "limitation", id: "read-only-bounded-filesystem" }, + { kind: "limitation", id: "cpu-memory-pids-time-bounds" }, + { kind: "policy", id: "deny-by-default-isolation" }, + { kind: "policy", id: "destination-pinned-egress" }, + { kind: "policy", id: "runtime-ephemeral-auth-material-mount" }, + { kind: "policy", id: "remove-on-terminal-cleanup" }, + { kind: "policy", id: "engine-api-not-mounted" }, + { kind: "policy", id: "host-mounts-denied" }, + { kind: "policy", id: "metadata-link-local-loopback-host-gateway-denied" }, + { kind: "policy", id: "dns-rebinding-resistance" }, + { kind: "readiness", id: "local-container-cleanup-removal-verified" }, + { kind: "readiness", id: "local-container-cancellation-verified" }, + { kind: "readiness", id: "ephemeral-auth-material-destruction-verified" }, +]); +const ATTESTATION_REFS = Object.freeze([ + { kind: "attestation", id: "podman-libpod-api-v0-rootless:readiness:d645-candidate-v0" }, + { kind: "attestation", id: "podman-libpod-api-v0-rootless:containment:d645-candidate-v0" }, + { kind: "attestation", id: "podman-libpod-api-v0-rootless:network:d645-candidate-v0" }, + { + kind: "attestation", + id: "podman-libpod-api-v0-rootless:cancellation-cleanup:d645-candidate-v0", + }, +]); + +export interface NodeLocalPodmanLibpodApiV0RootlessCertificationOptions { + readonly manifest: LocalContainerPostgresqlManifest; + readonly imageRef: string; + readonly observedAtMs?: number; + readonly ttlMs?: number; + readonly signal?: AbortSignal; +} + +/** + * Runs the currently implemented candidate probes. It intentionally remains + * unavailable until the D645 network-rebinding and cancellation effect canaries + * are implemented and the exact host profile is promoted to the certified set. + */ +export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWithNode( + opts: NodeLocalPodmanLibpodApiV0RootlessCertificationOptions, +): Promise { + const manifest = localContainerPostgresqlManifest(opts.manifest); + const observedAtMs = opts.observedAtMs ?? Date.now(); + const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS; + if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) + throw new TypeError("Invalid Podman observation time."); + if (!Number.isSafeInteger(ttlMs) || ttlMs < 1 || ttlMs > 60 * 60 * 1000) + throw new TypeError("Invalid Podman readiness TTL."); + if ( + manifest.backendFamily !== + LOCAL_CONTAINER_POSTGRESQL_PODMAN_LIBPOD_API_V0_ROOTLESS_BACKEND_FAMILY + ) + throw new TypeError("Podman certifier requires the Podman rootless backend family."); + if (!SAFE_IMAGE.test(opts.imageRef) || !imageRefPinsDigest(opts.imageRef, manifest.imageDigest)) + throw new TypeError("Podman certifier requires the manifest digest-pinned image."); + + const base = (): LocalContainerPostgresqlPodmanLibpodApiV0RootlessPreflight => ({ + kind: "local-container-postgresql-podman-libpod-api-v0-rootless-preflight", + manifestFingerprint: manifest.fingerprint, + backendCertificationRevision: manifest.backendCertificationRevision, + observedAtMs, + expiresAtMs: observedAtMs + ttlMs, + hostPlatform: `${process.platform}/${process.arch}`, + engineApiRevision: "libpod-api:unavailable", + engineRevision: "podman:unavailable", + runtimeRevision: "oci-runtime:unavailable", + guestPlatform: "linux/unknown", + ...(process.platform === "darwin" ? { vmRuntimeRevision: "podman-machine:unavailable" } : {}), + engineReachable: false, + compatibilityVerified: false, + rootlessVerified: false, + hostPlatformVerified: false, + imageDigestPresent: false, + imageDigestVerified: false, + recipeVerified: false, + isolationVerified: false, + nonRootUserVerified: false, + noNewPrivilegesVerified: false, + readOnlyRootFilesystemVerified: false, + boundedFilesystemImportVerified: false, + noEngineSocketMountVerified: false, + noHostNetworkVerified: false, + noHostBindMountVerified: false, + destinationPinnedEgressDenyVerified: false, + metadataEgressDenyVerified: false, + linkLocalEgressDenyVerified: false, + loopbackEgressDenyVerified: false, + hostGatewayEgressDenyVerified: false, + dnsRebindingResistanceVerified: false, + cpuMemoryPidsTimeBoundsVerified: false, + cancellationVerified: false, + cleanupVerified: false, + artifactResolverReady: false, + credentialResolverReady: false, + secretDestructionVerified: false, + limitationRefs: LIMITATION_REFS, + attestationRefs: ATTESTATION_REFS, + }); + const finish = ( + patch: Partial, + ): LocalContainerPostgresqlPodmanLibpodApiV0RootlessPreflight => { + const value = Object.freeze({ ...base(), ...patch }); + localContainerPostgresqlPodmanLibpodApiV0RootlessPreflightReadiness(value); + return value; + }; + if (opts.signal?.aborted) return finish({}); + + let socketPath: string | undefined; + let networkName: string | undefined; + let secretName: string | undefined; + let containerId: string | undefined; + let patch: Partial = {}; + try { + const discovered = await discoverRootlessPodmanSocket(opts.signal); + if (discovered === undefined) return finish({}); + socketPath = discovered.socketPath; + const version = await jsonRequest(socketPath, `/v${API_REVISION}/libpod/version`, opts.signal); + const info = await jsonRequest(socketPath, `/v${API_REVISION}/libpod/info`, opts.signal); + const facts = exactCandidateFacts(version, info, discovered); + if (facts === undefined) return finish({}); + patch = { + engineReachable: true, + engineApiRevision: `libpod-api:${API_REVISION}`, + engineRevision: `podman:${facts.engineRevision}`, + runtimeRevision: `crun:${facts.runtimeRevision}`, + guestPlatform: facts.guestPlatform, + vmRuntimeRevision: facts.vmRuntimeRevision, + rootlessVerified: true, + hostPlatformVerified: true, + }; + + const image = await jsonRequest( + socketPath, + `/v${API_REVISION}/libpod/images/${encodeURIComponent(opts.imageRef)}/json`, + opts.signal, + ); + const imageVerified = + image.status === 200 && + isRecord(image.body) && + image.body.Digest === manifest.imageDigest && + Array.isArray(image.body.RepoDigests) && + image.body.RepoDigests.includes(opts.imageRef); + patch = { + ...patch, + imageDigestPresent: image.status === 200, + imageDigestVerified: imageVerified, + }; + if (!imageVerified) return finish(patch); + + const suffix = randomUUID(); + networkName = `graphrefly-d645-${suffix}-network`; + secretName = `graphrefly-d645-${suffix}-secret`; + const containerName = `graphrefly-d645-${suffix}-container`; + const network = await jsonRequest( + socketPath, + `/v${API_REVISION}/libpod/networks/create`, + opts.signal, + "POST", + { + name: networkName, + internal: true, + labels: { "dev.graphrefly.boundary": BOUNDARY_LABEL }, + }, + ); + if ( + network.status !== 200 || + !isRecord(network.body) || + network.body.name !== networkName || + network.body.internal !== true + ) + return finish({ + ...patch, + cleanupVerified: await cleanup(socketPath, containerId, secretName, networkName), + }); + + const secret = await rawRequest( + socketPath, + `/v${API_REVISION}/libpod/secrets/create?name=${encodeURIComponent(secretName)}`, + opts.signal, + "POST", + "d645-canary-value", + "application/octet-stream", + ); + const secretBody = parseJson(secret.body); + if ( + secret.status !== 200 || + !isRecord(secretBody) || + typeof secretBody.ID !== "string" || + !SECRET_ID.test(secretBody.ID) + ) + return finish({ + ...patch, + cleanupVerified: await cleanup(socketPath, containerId, secretName, networkName), + }); + + const created = await jsonRequest( + socketPath, + `/v${API_REVISION}/libpod/containers/create`, + opts.signal, + "POST", + probeContainerRequest(containerName, networkName, secretName, opts.imageRef), + ); + if ( + created.status !== 201 || + !isRecord(created.body) || + typeof created.body.Id !== "string" || + !ID.test(created.body.Id) || + !Array.isArray(created.body.Warnings) || + created.body.Warnings.length !== 0 + ) + return finish({ + ...patch, + cleanupVerified: await cleanup(socketPath, containerId, secretName, networkName), + }); + containerId = created.body.Id; + + const inspected = await jsonRequest( + socketPath, + `/v${API_REVISION}/libpod/containers/${containerId}/json`, + opts.signal, + ); + if (!inspectMatches(inspected, containerId, containerName, networkName, opts.imageRef)) + return finish({ + ...patch, + cleanupVerified: await cleanup(socketPath, containerId, secretName, networkName), + }); + patch = { + ...patch, + isolationVerified: true, + nonRootUserVerified: true, + noNewPrivilegesVerified: true, + readOnlyRootFilesystemVerified: true, + boundedFilesystemImportVerified: true, + noEngineSocketMountVerified: true, + noHostNetworkVerified: true, + noHostBindMountVerified: true, + cpuMemoryPidsTimeBoundsVerified: true, + }; + + const started = await rawRequest( + socketPath, + `/v${API_REVISION}/libpod/containers/${containerId}/start`, + opts.signal, + "POST", + ); + if (started.status !== 204) + return finish({ + ...patch, + cleanupVerified: await cleanup(socketPath, containerId, secretName, networkName), + }); + const waited = await rawRequest( + socketPath, + `/v${API_REVISION}/libpod/containers/${containerId}/wait?condition=exited`, + opts.signal, + "POST", + ); + if (waited.status !== 200 || waited.body.trim() !== "0") + return finish({ + ...patch, + cleanupVerified: await cleanup(socketPath, containerId, secretName, networkName), + }); + + const cleanupVerified = await cleanup(socketPath, containerId, secretName, networkName); + containerId = undefined; + secretName = undefined; + networkName = undefined; + patch = { + ...patch, + compatibilityVerified: true, + recipeVerified: manifest.recipeRevision === "postgresql-read-only-query-v1", + artifactResolverReady: true, + credentialResolverReady: true, + secretDestructionVerified: cleanupVerified, + cleanupVerified, + // D645 remains deliberately unavailable until independent live effect + // probes prove all network classes and both cancellation paths. + }; + return finish(patch); + } catch { + const cleanupVerified = + socketPath === undefined + ? false + : await cleanup(socketPath, containerId, secretName, networkName); + return finish({ + ...patch, + secretDestructionVerified: cleanupVerified && patch.isolationVerified === true, + cleanupVerified, + }); + } +} + +interface DiscoveredPodman { + readonly socketPath: string; + readonly machineName: string; +} + +async function discoverRootlessPodmanSocket( + signal?: AbortSignal, +): Promise { + if (process.platform !== "darwin" || process.arch !== "arm64" || signal?.aborted) + return undefined; + const execution = await execFileAsync("podman", ["machine", "inspect"], { + encoding: "utf8", + timeout: DEFAULT_TIMEOUT_MS, + maxBuffer: 128 * 1024, + signal, + }); + const parsed = parseJson(execution.stdout); + if (!Array.isArray(parsed) || parsed.length !== 1 || !isRecord(parsed[0])) return undefined; + const machine = parsed[0]; + const connection = isRecord(machine.ConnectionInfo) ? machine.ConnectionInfo : undefined; + const socket = + connection && isRecord(connection.PodmanSocket) ? connection.PodmanSocket : undefined; + if ( + machine.Name !== "podman-machine-default" || + machine.State !== "running" || + machine.Rootful !== false || + machine.UserModeNetworking !== true || + !socket || + typeof socket.Path !== "string" || + !socket.Path.startsWith("/var/folders/") + ) + return undefined; + const metadata = await lstat(socket.Path); + const uid = process.getuid?.(); + if ( + !metadata.isSocket() || + uid === undefined || + metadata.uid !== uid || + (metadata.mode & 0o077) !== 0 + ) + return undefined; + return { socketPath: socket.Path, machineName: machine.Name }; +} + +function exactCandidateFacts( + version: JsonResponse, + info: JsonResponse, + discovered: DiscoveredPodman, +): + | { + readonly engineRevision: string; + readonly runtimeRevision: string; + readonly guestPlatform: string; + readonly vmRuntimeRevision: string; + } + | undefined { + if ( + version.status !== 200 || + info.status !== 200 || + !isRecord(version.body) || + !isRecord(info.body) || + version.body.Version !== API_REVISION + ) + return undefined; + const host = isRecord(info.body.host) ? info.body.host : undefined; + const security = host && isRecord(host.security) ? host.security : undefined; + const oci = host && isRecord(host.ociRuntime) ? host.ociRuntime : undefined; + const infoVersion = isRecord(info.body.version) ? info.body.version : undefined; + if ( + !host || + !security || + !oci || + !infoVersion || + infoVersion.APIVersion !== API_REVISION || + infoVersion.Version !== API_REVISION || + infoVersion.OsArch !== "linux/arm64" || + host.os !== "linux" || + host.arch !== "arm64" || + security.rootless !== true || + host.cgroupVersion !== "v2" || + host.cgroupManager !== "systemd" || + host.networkBackend !== "netavark" || + oci.name !== "crun" || + typeof oci.version !== "string" || + !oci.version.startsWith("crun version 1.14.4") + ) + return undefined; + return { + engineRevision: API_REVISION, + runtimeRevision: "1.14.4", + guestPlatform: "linux/arm64", + vmRuntimeRevision: `${discovered.machineName}:applehv-v1`, + }; +} + +function probeContainerRequest( + name: string, + network: string, + secret: string, + image: string, +): Record { + return { + name, + image, + entrypoint: [...PROBE_ENTRYPOINT], + command: [PROBE_COMMAND], + user: "65532:65532", + env: {}, + env_host: false, + httpproxy: false, + image_volume_mode: "ignore", + read_only_filesystem: true, + read_write_tmpfs: false, + privileged: false, + cap_drop: ["all"], + no_new_privileges: true, + terminal: false, + stdin: false, + remove: false, + publish_image_ports: false, + networks: { [network]: {} }, + secrets: [{ source: secret, target: "d645-canary", uid: 65532, gid: 65532, mode: 0o444 }], + labels: { "dev.graphrefly.boundary": BOUNDARY_LABEL }, + resource_limits: { + memory: { limit: 128 * 1024 * 1024 }, + cpu: { period: 100_000, quota: 50_000 }, + pids: { limit: 64 }, + }, + }; +} + +function inspectMatches( + response: JsonResponse, + containerId: string, + containerName: string, + networkName: string, + imageRef: string, +): boolean { + if (response.status !== 200 || !isRecord(response.body)) return false; + const body = response.body; + const config = isRecord(body.Config) ? body.Config : undefined; + const host = isRecord(body.HostConfig) ? body.HostConfig : undefined; + const settings = isRecord(body.NetworkSettings) ? body.NetworkSettings : undefined; + const networks = settings && isRecord(settings.Networks) ? settings.Networks : undefined; + const labels = config && isRecord(config.Labels) ? config.Labels : undefined; + const mounts = Array.isArray(body.Mounts) ? body.Mounts : undefined; + return ( + body.Id === containerId && + body.Name === containerName && + body.Path === PROBE_ENTRYPOINT[0] && + exactStrings(body.Args, [PROBE_ENTRYPOINT[1], PROBE_COMMAND]) && + !!config && + config.Image === imageRef && + config.User === "65532:65532" && + exactStrings(config.Entrypoint, PROBE_ENTRYPOINT) && + exactStrings(config.Cmd, [PROBE_COMMAND]) && + !!labels && + labels["dev.graphrefly.boundary"] === BOUNDARY_LABEL && + !!host && + host.ReadonlyRootfs === true && + host.Privileged === false && + Array.isArray(host.SecurityOpt) && + host.SecurityOpt.includes("no-new-privileges") && + host.Memory === 128 * 1024 * 1024 && + host.CpuPeriod === 100_000 && + host.CpuQuota === 50_000 && + host.PidsLimit === 64 && + !!networks && + Object.keys(networks).length === 1 && + networkName in networks && + !!mounts && + mounts.length === 0 + ); +} + +async function cleanup( + socketPath: string, + containerId?: string, + secretName?: string, + networkName?: string, +): Promise { + let verified = true; + if (containerId !== undefined) { + const response = await rawRequest( + socketPath, + `/v${API_REVISION}/libpod/containers/${containerId}?force=true&v=true`, + undefined, + "DELETE", + ).catch(() => undefined); + verified = response?.status === 200 && verified; + } + if (secretName !== undefined) { + const removed = await rawRequest( + socketPath, + `/v${API_REVISION}/libpod/secrets/${encodeURIComponent(secretName)}`, + undefined, + "DELETE", + ).catch(() => undefined); + const absent = await rawRequest( + socketPath, + `/v${API_REVISION}/libpod/secrets/${encodeURIComponent(secretName)}/json`, + ).catch(() => undefined); + verified = removed?.status === 204 && absent?.status === 404 && verified; + } + if (networkName !== undefined) { + const response = await rawRequest( + socketPath, + `/v${API_REVISION}/libpod/networks/${encodeURIComponent(networkName)}?force=true`, + undefined, + "DELETE", + ).catch(() => undefined); + verified = response?.status === 200 && verified; + } + return verified; +} + +interface RawResponse { + readonly status: number; + readonly body: string; +} +interface JsonResponse { + readonly status: number; + readonly body: unknown; +} + +async function jsonRequest( + socketPath: string, + path: string, + signal?: AbortSignal, + method = "GET", + body?: Record, +): Promise { + const response = await rawRequest( + socketPath, + path, + signal, + method, + body === undefined ? undefined : JSON.stringify(body), + "application/json", + ); + return { status: response.status, body: parseJson(response.body) }; +} + +function rawRequest( + socketPath: string, + path: string, + signal?: AbortSignal, + method = "GET", + body?: string, + contentType?: string, +): Promise { + return new Promise((resolve, reject) => { + const request = httpRequest( + { + socketPath, + path, + method, + signal, + headers: + body === undefined + ? undefined + : { + "content-type": contentType ?? "application/octet-stream", + "content-length": Buffer.byteLength(body), + }, + }, + (response) => { + let bytes = 0; + const chunks: Buffer[] = []; + response.on("data", (chunk: Buffer) => { + bytes += chunk.length; + if (bytes > MAX_RESPONSE_BYTES) { + request.destroy(new Error("Podman response exceeded byte budget.")); + return; + } + chunks.push(chunk); + }); + response.on("end", () => + resolve({ + status: response.statusCode ?? 0, + body: Buffer.concat(chunks).toString("utf8"), + }), + ); + }, + ); + request.setTimeout(DEFAULT_TIMEOUT_MS, () => + request.destroy(new Error("Podman request timed out.")), + ); + request.on("error", reject); + if (body !== undefined) request.write(body); + request.end(); + }); +} + +function parseJson(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function exactStrings(value: unknown, expected: readonly string[]): boolean { + return ( + Array.isArray(value) && + value.length === expected.length && + value.every((item, index) => item === expected[index]) + ); +} + +function imageRefPinsDigest(imageRef: string, digest: string): boolean { + return DIGEST.test(digest) && (imageRef === digest || imageRef.endsWith(`@${digest}`)); +} diff --git a/packages/ts/src/executors/local-container-postgresql.ts b/packages/ts/src/executors/local-container-postgresql.ts index aa779843..dbdceef9 100644 --- a/packages/ts/src/executors/local-container-postgresql.ts +++ b/packages/ts/src/executors/local-container-postgresql.ts @@ -20,7 +20,16 @@ import { postgresqlQueryToolArgumentsFromIntent } from "./postgresql-tool-provid export const LOCAL_CONTAINER_POSTGRESQL_COMPATIBILITY = "graphrefly-local-container-postgresql-v1" as const; -export const LOCAL_CONTAINER_POSTGRESQL_BACKEND_FAMILY = "docker-engine-api-v0" as const; +export const LOCAL_CONTAINER_POSTGRESQL_DOCKER_ENGINE_API_V0_BACKEND_FAMILY = + "docker-engine-api-v0" as const; +export const LOCAL_CONTAINER_POSTGRESQL_PODMAN_LIBPOD_API_V0_ROOTLESS_BACKEND_FAMILY = + "podman-libpod-api-v0-rootless" as const; +/** @deprecated Prefer the explicit Docker-family constant. */ +export const LOCAL_CONTAINER_POSTGRESQL_BACKEND_FAMILY = + LOCAL_CONTAINER_POSTGRESQL_DOCKER_ENGINE_API_V0_BACKEND_FAMILY; +export type LocalContainerPostgresqlBackendFamily = + | typeof LOCAL_CONTAINER_POSTGRESQL_DOCKER_ENGINE_API_V0_BACKEND_FAMILY + | typeof LOCAL_CONTAINER_POSTGRESQL_PODMAN_LIBPOD_API_V0_ROOTLESS_BACKEND_FAMILY; export interface LocalContainerPostgresqlManifest { readonly kind: "local-container-postgresql-manifest"; @@ -29,7 +38,7 @@ export interface LocalContainerPostgresqlManifest { readonly fingerprint: string; readonly imageDigest: string; readonly engineCompatibilityRevision: typeof LOCAL_CONTAINER_POSTGRESQL_COMPATIBILITY; - readonly backendFamily: typeof LOCAL_CONTAINER_POSTGRESQL_BACKEND_FAMILY; + readonly backendFamily: LocalContainerPostgresqlBackendFamily; readonly backendCertificationRevision: string; readonly recipeRevision: "postgresql-read-only-query-v1"; readonly sandboxRevision: string; @@ -47,7 +56,7 @@ export interface LocalContainerPostgresqlReadiness { readonly state: "ready" | "stale" | "unavailable"; readonly observedAtMs: number; readonly expiresAtMs: number; - readonly backendFamily: typeof LOCAL_CONTAINER_POSTGRESQL_BACKEND_FAMILY; + readonly backendFamily: LocalContainerPostgresqlBackendFamily; readonly hostPlatform: string; readonly engineApiRevision: string; readonly engineRevision: string; @@ -128,6 +137,49 @@ export interface LocalContainerPostgresqlDockerEngineApiV0Preflight { readonly attestationRefs: readonly SourceRef[]; } +export interface LocalContainerPostgresqlPodmanLibpodApiV0RootlessPreflight { + readonly kind: "local-container-postgresql-podman-libpod-api-v0-rootless-preflight"; + readonly manifestFingerprint: string; + readonly backendCertificationRevision: string; + readonly observedAtMs: number; + readonly expiresAtMs: number; + readonly hostPlatform: string; + readonly engineApiRevision: string; + readonly engineRevision: string; + readonly runtimeRevision: string; + readonly guestPlatform: string; + readonly vmRuntimeRevision?: string; + readonly engineReachable: boolean; + readonly compatibilityVerified: boolean; + readonly rootlessVerified: boolean; + readonly hostPlatformVerified: boolean; + readonly imageDigestPresent: boolean; + readonly imageDigestVerified: boolean; + readonly recipeVerified: boolean; + readonly isolationVerified: boolean; + readonly nonRootUserVerified: boolean; + readonly noNewPrivilegesVerified: boolean; + readonly readOnlyRootFilesystemVerified: boolean; + readonly boundedFilesystemImportVerified: boolean; + readonly noEngineSocketMountVerified: boolean; + readonly noHostNetworkVerified: boolean; + readonly noHostBindMountVerified: boolean; + readonly destinationPinnedEgressDenyVerified: boolean; + readonly metadataEgressDenyVerified: boolean; + readonly linkLocalEgressDenyVerified: boolean; + readonly loopbackEgressDenyVerified: boolean; + readonly hostGatewayEgressDenyVerified: boolean; + readonly dnsRebindingResistanceVerified: boolean; + readonly cpuMemoryPidsTimeBoundsVerified: boolean; + readonly cancellationVerified: boolean; + readonly cleanupVerified: boolean; + readonly artifactResolverReady: boolean; + readonly credentialResolverReady: boolean; + readonly secretDestructionVerified: boolean; + readonly limitationRefs: readonly SourceRef[]; + readonly attestationRefs: readonly SourceRef[]; +} + export type LocalContainerPostgresqlPhase = | "preparing" | "creating" @@ -327,8 +379,57 @@ const D613_DOCKER_ENGINE_API_V0_ATTESTATION_PREFIXES = Object.freeze([ "docker-engine-api-v0:network", "docker-engine-api-v0:cancellation-cleanup", ]); +const D645_PODMAN_LIBPOD_API_V0_ROOTLESS_PROOF_REFS = Object.freeze([ + { kind: "limitation", id: "podman-libpod-api-v0-rootless-only" }, + { kind: "limitation", id: "digest-pinned-image" }, + { kind: "limitation", id: "host-injected-runtime-driver" }, + { kind: "limitation", id: "non-root-no-new-privileges" }, + { kind: "limitation", id: "read-only-bounded-filesystem" }, + { kind: "limitation", id: "cpu-memory-pids-time-bounds" }, + { kind: "policy", id: "deny-by-default-isolation" }, + { kind: "policy", id: "destination-pinned-egress" }, + { kind: "policy", id: "runtime-ephemeral-auth-material-mount" }, + { kind: "policy", id: "remove-on-terminal-cleanup" }, + { kind: "policy", id: "engine-api-not-mounted" }, + { kind: "policy", id: "host-mounts-denied" }, + { kind: "policy", id: "metadata-link-local-loopback-host-gateway-denied" }, + { kind: "policy", id: "dns-rebinding-resistance" }, + { kind: "readiness", id: "local-container-cleanup-removal-verified" }, + { kind: "readiness", id: "local-container-cancellation-verified" }, + { kind: "readiness", id: "ephemeral-auth-material-destruction-verified" }, +]); +const D645_PODMAN_LIBPOD_API_V0_ROOTLESS_ATTESTATION_PREFIXES = Object.freeze([ + "podman-libpod-api-v0-rootless:readiness", + "podman-libpod-api-v0-rootless:containment", + "podman-libpod-api-v0-rootless:network", + "podman-libpod-api-v0-rootless:cancellation-cleanup", +]); const BOUNDED_ATTESTATION_EVIDENCE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +function localContainerPostgresqlBackendFamily( + value: unknown, +): value is LocalContainerPostgresqlBackendFamily { + return ( + value === LOCAL_CONTAINER_POSTGRESQL_DOCKER_ENGINE_API_V0_BACKEND_FAMILY || + value === LOCAL_CONTAINER_POSTGRESQL_PODMAN_LIBPOD_API_V0_ROOTLESS_BACKEND_FAMILY + ); +} + +function requiredProofPolicy(family: LocalContainerPostgresqlBackendFamily): { + readonly refs: readonly SourceRef[]; + readonly attestationPrefixes: readonly string[]; +} { + return family === LOCAL_CONTAINER_POSTGRESQL_DOCKER_ENGINE_API_V0_BACKEND_FAMILY + ? { + refs: D613_DOCKER_ENGINE_API_V0_PROOF_REFS, + attestationPrefixes: D613_DOCKER_ENGINE_API_V0_ATTESTATION_PREFIXES, + } + : { + refs: D645_PODMAN_LIBPOD_API_V0_ROOTLESS_PROOF_REFS, + attestationPrefixes: D645_PODMAN_LIBPOD_API_V0_ROOTLESS_ATTESTATION_PREFIXES, + }; +} + export function localContainerPostgresqlManifest( value: LocalContainerPostgresqlManifest, ): LocalContainerPostgresqlManifest { @@ -370,7 +471,7 @@ export function localContainerPostgresqlManifest( if ( !DIGEST.test(value.imageDigest) || value.engineCompatibilityRevision !== LOCAL_CONTAINER_POSTGRESQL_COMPATIBILITY || - value.backendFamily !== LOCAL_CONTAINER_POSTGRESQL_BACKEND_FAMILY || + !localContainerPostgresqlBackendFamily(value.backendFamily) || !publicCoordinate(value.backendCertificationRevision) || value.recipeRevision !== "postgresql-read-only-query-v1" || !Number.isSafeInteger(value.stopGraceMs) || @@ -410,7 +511,7 @@ export function localContainerPostgresqlReadiness( !Number.isSafeInteger(value.expiresAtMs) || value.observedAtMs < 0 || value.expiresAtMs <= value.observedAtMs || - value.backendFamily !== LOCAL_CONTAINER_POSTGRESQL_BACKEND_FAMILY || + !localContainerPostgresqlBackendFamily(value.backendFamily) || !publicCoordinate(value.hostPlatform) || !publicCoordinate(value.engineApiRevision) || !publicCoordinate(value.engineRevision) || @@ -535,13 +636,11 @@ export function localContainerPostgresqlReadiness( throw new TypeError("Ready local-container readiness lacks D613 proof booleans."); const limitationRefs = refs(value.limitationRefs); const attestationRefs = refs(value.attestationRefs); + const proofPolicy = requiredProofPolicy(value.backendFamily); if ( value.state === "ready" && - (!includesEveryRef(limitationRefs, D613_DOCKER_ENGINE_API_V0_PROOF_REFS) || - !includesEveryAttestationPrefix( - attestationRefs, - D613_DOCKER_ENGINE_API_V0_ATTESTATION_PREFIXES, - )) + (!includesEveryRef(limitationRefs, proofPolicy.refs) || + !includesEveryAttestationPrefix(attestationRefs, proofPolicy.attestationPrefixes)) ) throw new TypeError("Ready local-container readiness lacks D613 proof refs."); return Object.freeze({ @@ -771,6 +870,56 @@ export function localContainerPostgresqlDockerEngineApiV0PreflightReadiness( }); } +export function localContainerPostgresqlPodmanLibpodApiV0RootlessPreflightReadiness( + value: LocalContainerPostgresqlPodmanLibpodApiV0RootlessPreflight, +): LocalContainerPostgresqlReadiness { + if ( + !plain(value) || + value.kind !== "local-container-postgresql-podman-libpod-api-v0-rootless-preflight" || + typeof value.rootlessVerified !== "boolean" + ) + throw new TypeError("Invalid Podman Libpod API v0 rootless preflight."); + const { kind: _kind, rootlessVerified, ...evidence } = value; + const unavailable = localContainerPostgresqlReadiness({ + ...evidence, + kind: "local-container-postgresql-readiness", + state: "unavailable", + backendFamily: LOCAL_CONTAINER_POSTGRESQL_PODMAN_LIBPOD_API_V0_ROOTLESS_BACKEND_FAMILY, + backendFamilyVerified: rootlessVerified, + }); + const proofFields = [ + "engineReachable", + "compatibilityVerified", + "backendFamilyVerified", + "hostPlatformVerified", + "imageDigestPresent", + "imageDigestVerified", + "recipeVerified", + "isolationVerified", + "nonRootUserVerified", + "noNewPrivilegesVerified", + "readOnlyRootFilesystemVerified", + "boundedFilesystemImportVerified", + "noEngineSocketMountVerified", + "noHostNetworkVerified", + "noHostBindMountVerified", + "destinationPinnedEgressDenyVerified", + "metadataEgressDenyVerified", + "linkLocalEgressDenyVerified", + "loopbackEgressDenyVerified", + "hostGatewayEgressDenyVerified", + "dnsRebindingResistanceVerified", + "cpuMemoryPidsTimeBoundsVerified", + "cancellationVerified", + "cleanupVerified", + "artifactResolverReady", + "credentialResolverReady", + "secretDestructionVerified", + ] as const; + if (!proofFields.every((field) => unavailable[field])) return unavailable; + return localContainerPostgresqlReadiness({ ...unavailable, state: "ready" }); +} + export function localContainerPostgresqlRuntime( graph: Graph, opts: LocalContainerPostgresqlRuntimeOptions, @@ -1017,8 +1166,7 @@ export function localContainerPostgresqlRuntime( !publicCoordinate(environmentId) || !publicCoordinate(sessionEpoch) || !publicCoordinate(environmentRevision) || - manifest.backendFamily !== LOCAL_CONTAINER_POSTGRESQL_BACKEND_FAMILY || - posture.backendFamily !== LOCAL_CONTAINER_POSTGRESQL_BACKEND_FAMILY || + manifest.backendFamily !== posture.backendFamily || posture.backendCertificationRevision !== manifest.backendCertificationRevision || posture.state !== "ready" || posture.observedAtMs > (opts.now?.() ?? Date.now()) || @@ -1532,7 +1680,10 @@ function includesEveryAttestationPrefix( function publicSourceRefId(ref: SourceRef): boolean { if (ref.kind === "attestation" && typeof ref.id === "string") { - for (const prefix of D613_DOCKER_ENGINE_API_V0_ATTESTATION_PREFIXES) { + for (const prefix of [ + ...D613_DOCKER_ENGINE_API_V0_ATTESTATION_PREFIXES, + ...D645_PODMAN_LIBPOD_API_V0_ROOTLESS_ATTESTATION_PREFIXES, + ]) { const refPrefix = `${prefix}:`; if (ref.id.startsWith(refPrefix)) { return ( @@ -1545,9 +1696,10 @@ function publicSourceRefId(ref: SourceRef): boolean { } return ( publicCoordinate(ref.id) || - D613_DOCKER_ENGINE_API_V0_PROOF_REFS.some( - (required) => ref.kind === required.kind && ref.id === required.id, - ) + [ + ...D613_DOCKER_ENGINE_API_V0_PROOF_REFS, + ...D645_PODMAN_LIBPOD_API_V0_ROOTLESS_PROOF_REFS, + ].some((required) => ref.kind === required.kind && ref.id === required.id) ); } diff --git a/packages/ts/tsup.config.ts b/packages/ts/tsup.config.ts index 9d12f97b..cb01fc32 100644 --- a/packages/ts/tsup.config.ts +++ b/packages/ts/tsup.config.ts @@ -27,6 +27,8 @@ export default defineConfig({ "src/executors/execution-environment.ts", "src/executors/local-container-postgresql-docker-engine-api-v0.ts", "src/executors/local-container-postgresql-docker-engine-api-v0/node.ts", + "src/executors/local-container-postgresql-podman-libpod-api-v0-rootless.ts", + "src/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.ts", "src/executors/local-container-postgresql.ts", "src/executors/managed-cloud-postgresql.ts", "src/executors/managed-untrusted-js-compute.ts", diff --git a/scripts/check-no-raw-async.ts b/scripts/check-no-raw-async.ts index 8dc469c9..454a9f16 100644 --- a/scripts/check-no-raw-async.ts +++ b/scripts/check-no-raw-async.ts @@ -43,6 +43,10 @@ const ALLOW_ALL = new Set([ "packages/ts/src/executors/local-container-postgresql-docker-engine-api-v0.ts", // D624 Node-local Docker Engine API transport entry; sockets/IDs stay implementation-private. "packages/ts/src/executors/local-container-postgresql-docker-engine-api-v0/node.ts", + // D645 native rootless Libpod runtime contract; never part of the sync wave core. + "packages/ts/src/executors/local-container-postgresql-podman-libpod-api-v0-rootless.ts", + // D645 Node-local Podman transport entry; sockets/IDs stay implementation-private. + "packages/ts/src/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.ts", // D605 concrete managed control-store, WSS transport, and worker runtime boundary. "packages/ts/src/executors/managed-cloud-postgresql.ts", // D606 concrete customer endpoint, CAS store, outbound transport, and worker boundary. diff --git a/website/src/content/docs/integrations/matrix.md b/website/src/content/docs/integrations/matrix.md index dd996c34..92372ada 100644 --- a/website/src/content/docs/integrations/matrix.md +++ b/website/src/content/docs/integrations/matrix.md @@ -53,6 +53,8 @@ See [Adapters](/integrations/adapters/) for usage guidance and naming convention | Local-container PostgreSQL binding | Digest-pinned, host-injected PostgreSQL container lifecycle with exact cancellation and independently visible cleanup | `@graphrefly/ts/executors/local-container-postgresql` | | Local-container PostgreSQL Docker Engine API v0 broker | D624 host-runtime focused Docker Engine API broker/certifier; sockets, endpoints, client handles, resource IDs, raw inspect/logs, credentials, and cleanup handles remain private | `@graphrefly/ts/executors/local-container-postgresql-docker-engine-api-v0` | | Node-local Docker Engine API v0 certifier | D624 Node-only Docker Engine API certifier entry for the existing local-container PostgreSQL family; private Docker socket/resource handles stay inside the host process and caller-supplied proof adapters provide containment/network/secret evidence | `@graphrefly/ts/executors/local-container-postgresql-docker-engine-api-v0/node` | +| Rootless Podman native Libpod API v0 PostgreSQL contract | D645 independent Podman backend family and app-private runtime host contract; it does not use the Docker-compatible API or expose a provider registry | `@graphrefly/ts/executors/local-container-postgresql-podman-libpod-api-v0-rootless` | +| Node-local rootless Podman Libpod API v0 certifier | D645 Node-only candidate certifier with package-owned host coordinates, bounded CLI socket discovery, native Libpod requests, and private resource handles; it remains unavailable until every required live effect probe is certified | `@graphrefly/ts/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node` | | Managed-cloud PostgreSQL binding | PostgreSQL-16 atomic control-store and worker-initiated WSS session lifecycle with exact fenced leases, cancellation, and settlement | `@graphrefly/ts/executors/managed-cloud-postgresql` | | Managed untrusted JS compute | E2B Cloud v0 untrusted JavaScript compute lifecycle with deny-all network, bounded movement evidence, exact cancellation, and independently visible cleanup | `@graphrefly/ts/executors/managed-untrusted-js-compute` | | Customer-hosted PostgreSQL binding | Signed digest-pinned endpoint agent, outbound authenticated WSS, customer-resident credentials, exact cross-domain fences, and encrypted evidence-only offline outbox | `@graphrefly/ts/executors/customer-hosted-postgresql` | From f923fdd54dd8f9ba510f258ab9a9ed63b831f1ea Mon Sep 17 00:00:00 2001 From: David Chen Date: Fri, 24 Jul 2026 15:54:24 -0700 Subject: [PATCH 2/7] test(ts): certify Podman cancellation canaries --- ...n-libpod-api-v0-rootless-node.live.test.ts | 4 +- .../node.ts | 210 +++++++++++++++++- 2 files changed, 211 insertions(+), 3 deletions(-) diff --git a/packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless-node.live.test.ts b/packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless-node.live.test.ts index 4eb7584e..7eda63e4 100644 --- a/packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless-node.live.test.ts +++ b/packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless-node.live.test.ts @@ -12,7 +12,7 @@ const digest = "sha256:d13105efe29040feb046f1c5fc9f0a98e58d8980c85300306a325c80d const imageRef = `docker.io/library/postgres@${digest}`; describe.runIf(live)("Node-local Podman Libpod API v0 rootless candidate (D645 live)", () => { - it("proves the implemented containment and secret lifecycle without claiming full readiness", async () => { + it("proves containment, secret destruction, and both cancellation paths without claiming full readiness", async () => { const manifest = localContainerPostgresqlManifest({ kind: "local-container-postgresql-manifest", manifestId: "manifest:pg-d645-live", @@ -56,7 +56,7 @@ describe.runIf(live)("Node-local Podman Libpod API v0 rootless candidate (D645 l secretDestructionVerified: true, cleanupVerified: true, dnsRebindingResistanceVerified: false, - cancellationVerified: false, + cancellationVerified: true, }); expect(JSON.stringify(preflight)).not.toContain("podman-machine-default-api.sock"); expect(JSON.stringify(preflight)).not.toContain("d645-canary-value"); diff --git a/packages/ts/src/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.ts b/packages/ts/src/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.ts index 7ff0bf11..7ee5b17f 100644 --- a/packages/ts/src/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.ts +++ b/packages/ts/src/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.ts @@ -26,7 +26,7 @@ const DIGEST = /^sha256:[a-f0-9]{64}$/; const SAFE_IMAGE = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,254}$/; const PROBE_ENTRYPOINT = ["/bin/bash", "-ec"] as const; const PROBE_COMMAND = - 'test "$(id -u)" != "0" && test "$(cat /run/secrets/d645-canary)" = "d645-canary-value" && test -z "$(getent hosts example.com || true)"'; + 'test "$(id -u)" != "0" && test "$(cat /run/secrets/d645-canary)" = "d645-canary-value" && test -z "$(timeout 2 getent hosts example.com || true)"'; const LIMITATION_REFS = Object.freeze([ { kind: "limitation", id: "podman-libpod-api-v0-rootless-only" }, @@ -293,6 +293,14 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi ...patch, cleanupVerified: await cleanup(socketPath, containerId, secretName, networkName), }); + const cancellationVerified = await verifyCancellationCanaries( + socketPath, + networkName, + opts.imageRef, + suffix, + opts.signal, + ); + patch = { ...patch, cancellationVerified }; const cleanupVerified = await cleanup(socketPath, containerId, secretName, networkName); containerId = undefined; @@ -323,6 +331,125 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi } } +async function verifyCancellationCanaries( + socketPath: string, + networkName: string, + imageRef: string, + suffix: string, + signal?: AbortSignal, +): Promise { + const cooperative = await runCancellationCanary({ + socketPath, + networkName, + imageRef, + name: `graphrefly-d645-${suffix}-cooperative-cancel`, + command: "trap 'exit 0' TERM; while :; do :; done", + expectedExitCode: "0", + signal, + }); + if (!cooperative) return false; + return runCancellationCanary({ + socketPath, + networkName, + imageRef, + name: `graphrefly-d645-${suffix}-forced-cancel`, + command: "trap '' TERM; while :; do sleep 1; done", + expectedExitCode: "137", + signal, + }); +} + +async function runCancellationCanary(opts: { + readonly socketPath: string; + readonly networkName: string; + readonly imageRef: string; + readonly name: string; + readonly command: string; + readonly expectedExitCode: string; + readonly signal?: AbortSignal; +}): Promise { + let containerId: string | undefined; + try { + const created = await jsonRequest( + opts.socketPath, + `/v${API_REVISION}/libpod/containers/create`, + opts.signal, + "POST", + cancellationContainerRequest(opts.name, opts.networkName, opts.imageRef, opts.command), + ); + if ( + created.status !== 201 || + !isRecord(created.body) || + typeof created.body.Id !== "string" || + !ID.test(created.body.Id) || + !Array.isArray(created.body.Warnings) || + created.body.Warnings.length !== 0 + ) + return false; + containerId = created.body.Id; + const inspected = await jsonRequest( + opts.socketPath, + `/v${API_REVISION}/libpod/containers/${containerId}/json`, + opts.signal, + ); + if ( + !cancellationInspectMatches( + inspected, + containerId, + opts.name, + opts.networkName, + opts.imageRef, + opts.command, + ) + ) + return false; + const started = await rawRequest( + opts.socketPath, + `/v${API_REVISION}/libpod/containers/${containerId}/start`, + opts.signal, + "POST", + ); + if (started.status !== 204) return false; + const stopped = await rawRequest( + opts.socketPath, + `/v${API_REVISION}/libpod/containers/${containerId}/stop?timeout=2`, + opts.signal, + "POST", + ); + if (stopped.status !== 200 && stopped.status !== 204) return false; + const waited = await rawRequest( + opts.socketPath, + `/v${API_REVISION}/libpod/containers/${containerId}/wait?condition=exited`, + opts.signal, + "POST", + ); + if (waited.status !== 200) return false; + const settled = await jsonRequest( + opts.socketPath, + `/v${API_REVISION}/libpod/containers/${containerId}/json`, + opts.signal, + ); + if (settled.status !== 200 || !isRecord(settled.body)) return false; + const state = isRecord(settled.body.State) ? settled.body.State : undefined; + return ( + !!state && + state.Running === false && + state.ExitCode === Number.parseInt(opts.expectedExitCode, 10) + ); + } catch { + return false; + } finally { + if (containerId !== undefined) { + await rawRequest( + opts.socketPath, + `/v${API_REVISION}/libpod/containers/${containerId}?force=true&v=true`, + undefined, + "DELETE", + ).catch(() => undefined); + } + } +} + interface DiscoveredPodman { readonly socketPath: string; readonly machineName: string; @@ -442,6 +569,7 @@ function probeContainerRequest( terminal: false, stdin: false, remove: false, + stop_signal: 15, publish_image_ports: false, networks: { [network]: {} }, secrets: [{ source: secret, target: "d645-canary", uid: 65532, gid: 65532, mode: 0o444 }], @@ -454,6 +582,42 @@ function probeContainerRequest( }; } +function cancellationContainerRequest( + name: string, + network: string, + image: string, + command: string, +): Record { + return { + name, + image, + entrypoint: [...PROBE_ENTRYPOINT], + command: [command], + user: "65532:65532", + env: {}, + env_host: false, + httpproxy: false, + image_volume_mode: "ignore", + read_only_filesystem: true, + read_write_tmpfs: false, + privileged: false, + cap_drop: ["all"], + no_new_privileges: true, + terminal: false, + stdin: false, + remove: false, + stop_signal: 15, + publish_image_ports: false, + networks: { [network]: {} }, + labels: { "dev.graphrefly.boundary": BOUNDARY_LABEL }, + resource_limits: { + memory: { limit: 128 * 1024 * 1024 }, + cpu: { period: 100_000, quota: 50_000 }, + pids: { limit: 64 }, + }, + }; +} + function inspectMatches( response: JsonResponse, containerId: string, @@ -498,6 +662,50 @@ function inspectMatches( ); } +function cancellationInspectMatches( + response: JsonResponse, + containerId: string, + containerName: string, + networkName: string, + imageRef: string, + command: string, +): boolean { + if (response.status !== 200 || !isRecord(response.body)) return false; + const body = response.body; + const config = isRecord(body.Config) ? body.Config : undefined; + const host = isRecord(body.HostConfig) ? body.HostConfig : undefined; + const settings = isRecord(body.NetworkSettings) ? body.NetworkSettings : undefined; + const networks = settings && isRecord(settings.Networks) ? settings.Networks : undefined; + const labels = config && isRecord(config.Labels) ? config.Labels : undefined; + return ( + body.Id === containerId && + body.Name === containerName && + body.Path === PROBE_ENTRYPOINT[0] && + exactStrings(body.Args, [PROBE_ENTRYPOINT[1], command]) && + !!config && + config.Image === imageRef && + config.User === "65532:65532" && + exactStrings(config.Entrypoint, PROBE_ENTRYPOINT) && + exactStrings(config.Cmd, [command]) && + !!labels && + labels["dev.graphrefly.boundary"] === BOUNDARY_LABEL && + !!host && + host.ReadonlyRootfs === true && + host.Privileged === false && + Array.isArray(host.SecurityOpt) && + host.SecurityOpt.includes("no-new-privileges") && + host.Memory === 128 * 1024 * 1024 && + host.CpuPeriod === 100_000 && + host.CpuQuota === 50_000 && + host.PidsLimit === 64 && + !!networks && + Object.keys(networks).length === 1 && + networkName in networks && + Array.isArray(body.Mounts) && + body.Mounts.length === 0 + ); +} + async function cleanup( socketPath: string, containerId?: string, From e0f00e80f904db319140c4627cb92d10ab663671 Mon Sep 17 00:00:00 2001 From: David Chen Date: Fri, 24 Jul 2026 15:59:48 -0700 Subject: [PATCH 3/7] feat(ts): certify rootless Podman host profile --- .changeset/fresh-podman-candidate.md | 7 +- ...n-libpod-api-v0-rootless-node.live.test.ts | 16 +- ...esql-podman-libpod-api-v0-rootless.test.ts | 6 + .../node.ts | 349 ++++++++++++++++-- 4 files changed, 341 insertions(+), 37 deletions(-) diff --git a/.changeset/fresh-podman-candidate.md b/.changeset/fresh-podman-candidate.md index fb96b601..257864b2 100644 --- a/.changeset/fresh-podman-candidate.md +++ b/.changeset/fresh-podman-candidate.md @@ -3,6 +3,7 @@ --- Add the independent rootless native Libpod API v0 PostgreSQL backend family and -its Node-local candidate certifier. The certifier keeps socket discovery and -resource handles private and remains unavailable until the remaining D645 -network and cancellation effect probes are certified. +its Node-local certifier. The certifier keeps socket discovery and resource +handles private and admits only the package-owned exact host profile after all +containment, network, secret, cancellation, cleanup, and zero-residue probes +succeed. diff --git a/packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless-node.live.test.ts b/packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless-node.live.test.ts index 7eda63e4..c74433d5 100644 --- a/packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless-node.live.test.ts +++ b/packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless-node.live.test.ts @@ -11,8 +11,8 @@ const live = process.env.GRAPHREFLY_D645_LIVE_PODMAN === "1"; const digest = "sha256:d13105efe29040feb046f1c5fc9f0a98e58d8980c85300306a325c80df9a45c4"; const imageRef = `docker.io/library/postgres@${digest}`; -describe.runIf(live)("Node-local Podman Libpod API v0 rootless candidate (D645 live)", () => { - it("proves containment, secret destruction, and both cancellation paths without claiming full readiness", async () => { +describe.runIf(live)("Node-local Podman Libpod API v0 rootless certifier (D645 live)", () => { + it("certifies the exact host only after every containment, network, secret, cancellation, and cleanup proof", async () => { const manifest = localContainerPostgresqlManifest({ kind: "local-container-postgresql-manifest", manifestId: "manifest:pg-d645-live", @@ -21,7 +21,7 @@ describe.runIf(live)("Node-local Podman Libpod API v0 rootless candidate (D645 l imageDigest: digest, engineCompatibilityRevision: LOCAL_CONTAINER_POSTGRESQL_COMPATIBILITY, backendFamily: LOCAL_CONTAINER_POSTGRESQL_PODMAN_LIBPOD_API_V0_ROOTLESS_BACKEND_FAMILY, - backendCertificationRevision: "podman-certification:d645-candidate-v0", + backendCertificationRevision: "podman-certification:d645-v0", recipeRevision: "postgresql-read-only-query-v1", sandboxRevision: "sandbox:d645-live", mountPolicyRevision: "mount:d645-live", @@ -40,10 +40,11 @@ describe.runIf(live)("Node-local Podman Libpod API v0 rootless candidate (D645 l localContainerPostgresqlPodmanLibpodApiV0RootlessPreflightReadiness(preflight); expect(readiness).toMatchObject({ - state: "unavailable", + state: "ready", backendFamily: "podman-libpod-api-v0-rootless", engineReachable: true, backendFamilyVerified: true, + compatibilityVerified: true, imageDigestVerified: true, isolationVerified: true, nonRootUserVerified: true, @@ -55,7 +56,12 @@ describe.runIf(live)("Node-local Podman Libpod API v0 rootless candidate (D645 l cpuMemoryPidsTimeBoundsVerified: true, secretDestructionVerified: true, cleanupVerified: true, - dnsRebindingResistanceVerified: false, + destinationPinnedEgressDenyVerified: true, + metadataEgressDenyVerified: true, + linkLocalEgressDenyVerified: true, + loopbackEgressDenyVerified: true, + hostGatewayEgressDenyVerified: true, + dnsRebindingResistanceVerified: true, cancellationVerified: true, }); expect(JSON.stringify(preflight)).not.toContain("podman-machine-default-api.sock"); diff --git a/packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless.test.ts b/packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless.test.ts index 8a63d107..dec125ad 100644 --- a/packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless.test.ts +++ b/packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless.test.ts @@ -172,10 +172,16 @@ describe("Podman native Libpod API v0 rootless PostgreSQL contract (D645)", () = const surface = await import( "../executors/local-container-postgresql-podman-libpod-api-v0-rootless.js" ); + const nodeSurface = await import( + "../executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.js" + ); expect(Object.keys(surface).sort()).toEqual([ "PODMAN_LIBPOD_API_V0_ROOTLESS_BROKER_COMPATIBILITY", "PODMAN_LIBPOD_API_V0_ROOTLESS_CERTIFIER_COMPATIBILITY", "podmanLibpodApiV0RootlessLocalContainerPostgresqlDriver", ]); + expect(Object.keys(nodeSurface)).toEqual([ + "certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWithNode", + ]); }); }); diff --git a/packages/ts/src/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.ts b/packages/ts/src/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.ts index 7ee5b17f..a313dfb2 100644 --- a/packages/ts/src/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.ts +++ b/packages/ts/src/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.ts @@ -24,9 +24,10 @@ const ID = /^[a-f0-9]{64}$/; const SECRET_ID = /^[a-f0-9]{24,64}$/; const DIGEST = /^sha256:[a-f0-9]{64}$/; const SAFE_IMAGE = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,254}$/; +const IPV4 = /^(?:\d{1,3}\.){3}\d{1,3}$/; const PROBE_ENTRYPOINT = ["/bin/bash", "-ec"] as const; -const PROBE_COMMAND = - 'test "$(id -u)" != "0" && test "$(cat /run/secrets/d645-canary)" = "d645-canary-value" && test -z "$(timeout 2 getent hosts example.com || true)"'; +const PEER_PORT = 15432; +const PEER_COMMAND = `exec nc -l -p ${PEER_PORT} >/dev/null`; const LIMITATION_REFS = Object.freeze([ { kind: "limitation", id: "podman-libpod-api-v0-rootless-only" }, @@ -48,12 +49,12 @@ const LIMITATION_REFS = Object.freeze([ { kind: "readiness", id: "ephemeral-auth-material-destruction-verified" }, ]); const ATTESTATION_REFS = Object.freeze([ - { kind: "attestation", id: "podman-libpod-api-v0-rootless:readiness:d645-candidate-v0" }, - { kind: "attestation", id: "podman-libpod-api-v0-rootless:containment:d645-candidate-v0" }, - { kind: "attestation", id: "podman-libpod-api-v0-rootless:network:d645-candidate-v0" }, + { kind: "attestation", id: "podman-libpod-api-v0-rootless:readiness:d645-v0" }, + { kind: "attestation", id: "podman-libpod-api-v0-rootless:containment:d645-v0" }, + { kind: "attestation", id: "podman-libpod-api-v0-rootless:network:d645-v0" }, { kind: "attestation", - id: "podman-libpod-api-v0-rootless:cancellation-cleanup:d645-candidate-v0", + id: "podman-libpod-api-v0-rootless:cancellation-cleanup:d645-v0", }, ]); @@ -66,9 +67,8 @@ export interface NodeLocalPodmanLibpodApiV0RootlessCertificationOptions { } /** - * Runs the currently implemented candidate probes. It intentionally remains - * unavailable until the D645 network-rebinding and cancellation effect canaries - * are implemented and the exact host profile is promoted to the certified set. + * Certifies only the package-owned exact D645 host profile after every bounded + * live effect probe succeeds. No caller can expand or override that profile. */ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWithNode( opts: NodeLocalPodmanLibpodApiV0RootlessCertificationOptions, @@ -143,6 +143,7 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi let networkName: string | undefined; let secretName: string | undefined; let containerId: string | undefined; + let peerContainerId: string | undefined; let patch: Partial = {}; try { const discovered = await discoverRootlessPodmanSocket(opts.signal); @@ -185,6 +186,7 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi networkName = `graphrefly-d645-${suffix}-network`; secretName = `graphrefly-d645-${suffix}-secret`; const containerName = `graphrefly-d645-${suffix}-container`; + const peerContainerName = `graphrefly-d645-${suffix}-peer`; const network = await jsonRequest( socketPath, `/v${API_REVISION}/libpod/networks/create`, @@ -227,12 +229,73 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi cleanupVerified: await cleanup(socketPath, containerId, secretName, networkName), }); + const peerCreated = await jsonRequest( + socketPath, + `/v${API_REVISION}/libpod/containers/create`, + opts.signal, + "POST", + peerContainerRequest(peerContainerName, networkName, opts.imageRef), + ); + if ( + peerCreated.status !== 201 || + !isRecord(peerCreated.body) || + typeof peerCreated.body.Id !== "string" || + !ID.test(peerCreated.body.Id) || + !Array.isArray(peerCreated.body.Warnings) || + peerCreated.body.Warnings.length !== 0 + ) + return finish({ + ...patch, + cleanupVerified: await cleanup(socketPath, containerId, secretName, networkName), + }); + peerContainerId = peerCreated.body.Id; + const peerStarted = await rawRequest( + socketPath, + `/v${API_REVISION}/libpod/containers/${peerContainerId}/start`, + opts.signal, + "POST", + ); + if (peerStarted.status !== 204) + return finish({ + ...patch, + cleanupVerified: await cleanup( + socketPath, + containerId, + secretName, + networkName, + peerContainerId, + ), + }); + const peerInspected = await jsonRequest( + socketPath, + `/v${API_REVISION}/libpod/containers/${peerContainerId}/json`, + opts.signal, + ); + const peerIp = runningPeerIp( + peerInspected, + peerContainerId, + peerContainerName, + networkName, + opts.imageRef, + ); + if (peerIp === undefined) + return finish({ + ...patch, + cleanupVerified: await cleanup( + socketPath, + containerId, + secretName, + networkName, + peerContainerId, + ), + }); + const probeCommand = probeCommandForPeer(peerIp); const created = await jsonRequest( socketPath, `/v${API_REVISION}/libpod/containers/create`, opts.signal, "POST", - probeContainerRequest(containerName, networkName, secretName, opts.imageRef), + probeContainerRequest(containerName, networkName, secretName, opts.imageRef, probeCommand), ); if ( created.status !== 201 || @@ -244,7 +307,13 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi ) return finish({ ...patch, - cleanupVerified: await cleanup(socketPath, containerId, secretName, networkName), + cleanupVerified: await cleanup( + socketPath, + containerId, + secretName, + networkName, + peerContainerId, + ), }); containerId = created.body.Id; @@ -253,10 +322,25 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi `/v${API_REVISION}/libpod/containers/${containerId}/json`, opts.signal, ); - if (!inspectMatches(inspected, containerId, containerName, networkName, opts.imageRef)) + if ( + !inspectMatches( + inspected, + containerId, + containerName, + networkName, + opts.imageRef, + probeCommand, + ) + ) return finish({ ...patch, - cleanupVerified: await cleanup(socketPath, containerId, secretName, networkName), + cleanupVerified: await cleanup( + socketPath, + containerId, + secretName, + networkName, + peerContainerId, + ), }); patch = { ...patch, @@ -280,7 +364,13 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi if (started.status !== 204) return finish({ ...patch, - cleanupVerified: await cleanup(socketPath, containerId, secretName, networkName), + cleanupVerified: await cleanup( + socketPath, + containerId, + secretName, + networkName, + peerContainerId, + ), }); const waited = await rawRequest( socketPath, @@ -291,8 +381,40 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi if (waited.status !== 200 || waited.body.trim() !== "0") return finish({ ...patch, - cleanupVerified: await cleanup(socketPath, containerId, secretName, networkName), + cleanupVerified: await cleanup( + socketPath, + containerId, + secretName, + networkName, + peerContainerId, + ), + }); + const peerWaited = await rawRequest( + socketPath, + `/v${API_REVISION}/libpod/containers/${peerContainerId}/wait?condition=exited`, + opts.signal, + "POST", + ); + if (peerWaited.status !== 200 || peerWaited.body.trim() !== "0") + return finish({ + ...patch, + cleanupVerified: await cleanup( + socketPath, + containerId, + secretName, + networkName, + peerContainerId, + ), }); + patch = { + ...patch, + destinationPinnedEgressDenyVerified: true, + metadataEgressDenyVerified: true, + linkLocalEgressDenyVerified: true, + loopbackEgressDenyVerified: true, + hostGatewayEgressDenyVerified: true, + dnsRebindingResistanceVerified: true, + }; const cancellationVerified = await verifyCancellationCanaries( socketPath, networkName, @@ -302,8 +424,15 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi ); patch = { ...patch, cancellationVerified }; - const cleanupVerified = await cleanup(socketPath, containerId, secretName, networkName); + const cleanupVerified = await cleanup( + socketPath, + containerId, + secretName, + networkName, + peerContainerId, + ); containerId = undefined; + peerContainerId = undefined; secretName = undefined; networkName = undefined; patch = { @@ -314,15 +443,13 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi credentialResolverReady: true, secretDestructionVerified: cleanupVerified, cleanupVerified, - // D645 remains deliberately unavailable until independent live effect - // probes prove all network classes and both cancellation paths. }; return finish(patch); } catch { const cleanupVerified = socketPath === undefined ? false - : await cleanup(socketPath, containerId, secretName, networkName); + : await cleanup(socketPath, containerId, secretName, networkName, peerContainerId); return finish({ ...patch, secretDestructionVerified: cleanupVerified && patch.isolationVerified === true, @@ -453,6 +580,7 @@ async function runCancellationCanary(opts: { interface DiscoveredPodman { readonly socketPath: string; readonly machineName: string; + readonly clientRevision: string; } async function discoverRootlessPodmanSocket( @@ -469,6 +597,7 @@ async function discoverRootlessPodmanSocket( const parsed = parseJson(execution.stdout); if (!Array.isArray(parsed) || parsed.length !== 1 || !isRecord(parsed[0])) return undefined; const machine = parsed[0]; + const configDir = isRecord(machine.ConfigDir) ? machine.ConfigDir : undefined; const connection = isRecord(machine.ConnectionInfo) ? machine.ConnectionInfo : undefined; const socket = connection && isRecord(connection.PodmanSocket) ? connection.PodmanSocket : undefined; @@ -477,6 +606,9 @@ async function discoverRootlessPodmanSocket( machine.State !== "running" || machine.Rootful !== false || machine.UserModeNetworking !== true || + !configDir || + typeof configDir.Path !== "string" || + !configDir.Path.endsWith("/podman/machine/applehv") || !socket || typeof socket.Path !== "string" || !socket.Path.startsWith("/var/folders/") @@ -491,7 +623,31 @@ async function discoverRootlessPodmanSocket( (metadata.mode & 0o077) !== 0 ) return undefined; - return { socketPath: socket.Path, machineName: machine.Name }; + const versionExecution = await execFileAsync("podman", ["version", "--format", "{{json .}}"], { + encoding: "utf8", + timeout: DEFAULT_TIMEOUT_MS, + maxBuffer: 128 * 1024, + signal, + }); + const version = parseJson(versionExecution.stdout); + const client = isRecord(version) && isRecord(version.Client) ? version.Client : undefined; + const server = isRecord(version) && isRecord(version.Server) ? version.Server : undefined; + if ( + !client || + !server || + client.APIVersion !== "5.7.1" || + client.Version !== "5.7.1" || + client.OsArch !== "darwin/arm64" || + server.APIVersion !== API_REVISION || + server.Version !== API_REVISION || + server.OsArch !== "linux/arm64" + ) + return undefined; + return { + socketPath: socket.Path, + machineName: machine.Name, + clientRevision: client.Version, + }; } function exactCandidateFacts( @@ -511,12 +667,15 @@ function exactCandidateFacts( info.status !== 200 || !isRecord(version.body) || !isRecord(info.body) || - version.body.Version !== API_REVISION + version.body.Version !== API_REVISION || + discovered.clientRevision !== "5.7.1" ) return undefined; const host = isRecord(info.body.host) ? info.body.host : undefined; const security = host && isRecord(host.security) ? host.security : undefined; const oci = host && isRecord(host.ociRuntime) ? host.ociRuntime : undefined; + const network = host && isRecord(host.networkBackendInfo) ? host.networkBackendInfo : undefined; + const networkDns = network && isRecord(network.dns) ? network.dns : undefined; const infoVersion = isRecord(info.body.version) ? info.body.version : undefined; if ( !host || @@ -529,9 +688,16 @@ function exactCandidateFacts( host.os !== "linux" || host.arch !== "arm64" || security.rootless !== true || + security.seccompEnabled !== true || + security.selinuxEnabled !== true || host.cgroupVersion !== "v2" || host.cgroupManager !== "systemd" || host.networkBackend !== "netavark" || + !network || + network.backend !== "netavark" || + network.version !== "netavark 1.10.3" || + !networkDns || + networkDns.version !== "aardvark-dns 1.10.0" || oci.name !== "crun" || typeof oci.version !== "string" || !oci.version.startsWith("crun version 1.14.4") @@ -550,12 +716,13 @@ function probeContainerRequest( network: string, secret: string, image: string, + command: string, ): Record { return { name, image, entrypoint: [...PROBE_ENTRYPOINT], - command: [PROBE_COMMAND], + command: [command], user: "65532:65532", env: {}, env_host: false, @@ -582,6 +749,56 @@ function probeContainerRequest( }; } +function peerContainerRequest( + name: string, + network: string, + image: string, +): Record { + return { + name, + image, + entrypoint: [...PROBE_ENTRYPOINT], + command: [PEER_COMMAND], + user: "65532:65532", + env: {}, + env_host: false, + httpproxy: false, + image_volume_mode: "ignore", + read_only_filesystem: true, + read_write_tmpfs: false, + privileged: false, + cap_drop: ["all"], + no_new_privileges: true, + terminal: false, + stdin: false, + remove: false, + stop_signal: 15, + publish_image_ports: false, + networks: { [network]: {} }, + labels: { "dev.graphrefly.boundary": BOUNDARY_LABEL }, + resource_limits: { + memory: { limit: 128 * 1024 * 1024 }, + cpu: { period: 100_000, quota: 50_000 }, + pids: { limit: 64 }, + }, + }; +} + +function probeCommandForPeer(peerIp: string): string { + if (!validIpv4(peerIp)) throw new TypeError("Invalid private Podman probe peer address."); + return [ + 'test "$(id -u)" != "0"', + 'test "$(cat /run/secrets/d645-canary)" = "d645-canary-value"', + `printf d645 | timeout 2 nc -w 2 ${peerIp} ${PEER_PORT}`, + "! grep -Eq '^[^[:space:]]+[[:space:]]+00000000[[:space:]]' /proc/net/route", + "! timeout 1 nc -z -w 1 1.1.1.1 53", + "! timeout 1 nc -z -w 1 169.254.169.254 80", + "! timeout 1 nc -z -w 1 127.0.0.1 15432", + "! timeout 1 nc -z -w 1 host.containers.internal 15432", + 'test -z "$(timeout 2 getent hosts example.com || true)"', + ].join(" && "); +} + function cancellationContainerRequest( name: string, network: string, @@ -618,12 +835,65 @@ function cancellationContainerRequest( }; } +function runningPeerIp( + response: JsonResponse, + containerId: string, + containerName: string, + networkName: string, + imageRef: string, +): string | undefined { + if (response.status !== 200 || !isRecord(response.body)) return undefined; + const body = response.body; + const config = isRecord(body.Config) ? body.Config : undefined; + const host = isRecord(body.HostConfig) ? body.HostConfig : undefined; + const state = isRecord(body.State) ? body.State : undefined; + const settings = isRecord(body.NetworkSettings) ? body.NetworkSettings : undefined; + const networks = settings && isRecord(settings.Networks) ? settings.Networks : undefined; + const network = networks && isRecord(networks[networkName]) ? networks[networkName] : undefined; + const labels = config && isRecord(config.Labels) ? config.Labels : undefined; + const ip = network?.IPAddress; + if ( + body.Id !== containerId || + body.Name !== containerName || + body.Path !== PROBE_ENTRYPOINT[0] || + !exactStrings(body.Args, [PROBE_ENTRYPOINT[1], PEER_COMMAND]) || + !config || + config.Image !== imageRef || + config.User !== "65532:65532" || + !exactStrings(config.Entrypoint, PROBE_ENTRYPOINT) || + !exactStrings(config.Cmd, [PEER_COMMAND]) || + !labels || + labels["dev.graphrefly.boundary"] !== BOUNDARY_LABEL || + !host || + host.ReadonlyRootfs !== true || + host.Privileged !== false || + !Array.isArray(host.SecurityOpt) || + !host.SecurityOpt.includes("no-new-privileges") || + host.Memory !== 128 * 1024 * 1024 || + host.CpuPeriod !== 100_000 || + host.CpuQuota !== 50_000 || + host.PidsLimit !== 64 || + !state || + state.Running !== true || + !networks || + Object.keys(networks).length !== 1 || + !network || + typeof ip !== "string" || + !validIpv4(ip) || + !Array.isArray(body.Mounts) || + body.Mounts.length !== 0 + ) + return undefined; + return ip; +} + function inspectMatches( response: JsonResponse, containerId: string, containerName: string, networkName: string, imageRef: string, + command: string, ): boolean { if (response.status !== 200 || !isRecord(response.body)) return false; const body = response.body; @@ -637,12 +907,12 @@ function inspectMatches( body.Id === containerId && body.Name === containerName && body.Path === PROBE_ENTRYPOINT[0] && - exactStrings(body.Args, [PROBE_ENTRYPOINT[1], PROBE_COMMAND]) && + exactStrings(body.Args, [PROBE_ENTRYPOINT[1], command]) && !!config && config.Image === imageRef && config.User === "65532:65532" && exactStrings(config.Entrypoint, PROBE_ENTRYPOINT) && - exactStrings(config.Cmd, [PROBE_COMMAND]) && + exactStrings(config.Cmd, [command]) && !!labels && labels["dev.graphrefly.boundary"] === BOUNDARY_LABEL && !!host && @@ -711,16 +981,23 @@ async function cleanup( containerId?: string, secretName?: string, networkName?: string, + peerContainerId?: string, ): Promise { let verified = true; - if (containerId !== undefined) { + for (const privateContainerId of [containerId, peerContainerId]) { + if (privateContainerId === undefined) continue; const response = await rawRequest( socketPath, - `/v${API_REVISION}/libpod/containers/${containerId}?force=true&v=true`, + `/v${API_REVISION}/libpod/containers/${privateContainerId}?force=true&v=true`, undefined, "DELETE", ).catch(() => undefined); - verified = response?.status === 200 && verified; + const absent = await rawRequest( + socketPath, + `/v${API_REVISION}/libpod/containers/${privateContainerId}/json`, + ).catch(() => undefined); + verified = + (response?.status === 200 || response?.status === 404) && absent?.status === 404 && verified; } if (secretName !== undefined) { const removed = await rawRequest( @@ -733,7 +1010,8 @@ async function cleanup( socketPath, `/v${API_REVISION}/libpod/secrets/${encodeURIComponent(secretName)}/json`, ).catch(() => undefined); - verified = removed?.status === 204 && absent?.status === 404 && verified; + verified = + (removed?.status === 204 || removed?.status === 404) && absent?.status === 404 && verified; } if (networkName !== undefined) { const response = await rawRequest( @@ -742,7 +1020,12 @@ async function cleanup( undefined, "DELETE", ).catch(() => undefined); - verified = response?.status === 200 && verified; + const absent = await rawRequest( + socketPath, + `/v${API_REVISION}/libpod/networks/${encodeURIComponent(networkName)}/json`, + ).catch(() => undefined); + verified = + (response?.status === 200 || response?.status === 404) && absent?.status === 404 && verified; } return verified; } @@ -845,6 +1128,14 @@ function exactStrings(value: unknown, expected: readonly string[]): boolean { ); } +function validIpv4(value: string): boolean { + if (!IPV4.test(value)) return false; + return value.split(".").every((part) => { + const numeric = Number(part); + return Number.isInteger(numeric) && numeric >= 0 && numeric <= 255; + }); +} + function imageRefPinsDigest(imageRef: string, digest: string): boolean { return DIGEST.test(digest) && (imageRef === digest || imageRef.endsWith(`@${digest}`)); } From 985be5d871a76462b549128bc6504f7c5a1b2a10 Mon Sep 17 00:00:00 2001 From: David Chen Date: Fri, 24 Jul 2026 16:24:39 -0700 Subject: [PATCH 4/7] feat(ts): enforce strict zero-dependency package floor --- .changeset/strict-core-package-floor.md | 9 + README.md | 2 +- packages/ts/package.json | 124 - .../src/__tests__/adapters.nestjs.e2e.test.ts | 707 ---- packages/ts/src/__tests__/adapters.test.ts | 2900 ----------------- .../src/__tests__/framework-adapters.test.ts | 15 +- ...ctive-layout-solution-d181-part-01.test.ts | 63 - packages/ts/src/__tests__/subpaths.test.ts | 116 +- packages/ts/src/adapters/nestjs.ts | 2118 ------------ .../ts/src/adapters/nestjs/microservices.ts | 316 -- packages/ts/src/adapters/nestjs/native.ts | 1214 ------- packages/ts/src/adapters/nestjs/websockets.ts | 427 --- packages/ts/src/adapters/react.ts | 92 - packages/ts/src/adapters/solid.ts | 85 - packages/ts/src/adapters/svelte.ts | 89 - packages/ts/src/adapters/vue.ts | 59 - .../reactive-layout/node-canvas/index.ts | 122 - packages/ts/tsup.config.ts | 8 - pnpm-lock.yaml | 80 +- scripts/check-no-raw-async.ts | 10 - scripts/check-ts-package-exports.mjs | 145 +- .../src/content/docs/integrations/compat.md | 10 +- .../src/content/docs/integrations/matrix.md | 19 +- .../docs/recipes/from-callbag-recharge.md | 10 +- .../docs/recipes/nestjs-integration.md | 38 +- .../content/docs/solutions/reactive-layout.md | 2 + 26 files changed, 128 insertions(+), 8652 deletions(-) create mode 100644 .changeset/strict-core-package-floor.md delete mode 100644 packages/ts/src/__tests__/adapters.nestjs.e2e.test.ts delete mode 100644 packages/ts/src/__tests__/adapters.test.ts delete mode 100644 packages/ts/src/adapters/nestjs.ts delete mode 100644 packages/ts/src/adapters/nestjs/microservices.ts delete mode 100644 packages/ts/src/adapters/nestjs/native.ts delete mode 100644 packages/ts/src/adapters/nestjs/websockets.ts delete mode 100644 packages/ts/src/adapters/react.ts delete mode 100644 packages/ts/src/adapters/solid.ts delete mode 100644 packages/ts/src/adapters/svelte.ts delete mode 100644 packages/ts/src/adapters/vue.ts diff --git a/.changeset/strict-core-package-floor.md b/.changeset/strict-core-package-floor.md new file mode 100644 index 00000000..ce5a9082 --- /dev/null +++ b/.changeset/strict-core-package-floor.md @@ -0,0 +1,9 @@ +--- +"@graphrefly/ts": minor +--- + +Enforce the strict zero-dependency package floor. Framework and third-party +runtime integrations move to `@graphrefly/react`, `@graphrefly/vue`, +`@graphrefly/solid`, `@graphrefly/svelte`, `@graphrefly/nestjs`, and +`@graphrefly/reactive-layout-node-canvas`; the core manifest no longer declares +dependencies, optional dependencies, or peer dependencies. diff --git a/README.md b/README.md index 6feedd06..03e35275 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ import { map, switchMap } from "@graphrefly/ts/operators"; import { fromPromise, timer } from "@graphrefly/ts/sources"; import { memoryKv } from "@graphrefly/ts/storage"; import { describeToMermaid } from "@graphrefly/ts/render"; -import { useNodeValue } from "@graphrefly/ts/adapters/react"; +import { useNodeValue } from "@graphrefly/react"; ``` Node-only and browser-only helpers are split: diff --git a/packages/ts/package.json b/packages/ts/package.json index 4b3ec991..0426ae3d 100644 --- a/packages/ts/package.json +++ b/packages/ts/package.json @@ -36,46 +36,6 @@ "default": "./dist/adapters/index.cjs" } }, - "./adapters/nestjs": { - "import": { - "types": "./dist/adapters/nestjs.d.ts", - "default": "./dist/adapters/nestjs.js" - }, - "require": { - "types": "./dist/adapters/nestjs.d.cts", - "default": "./dist/adapters/nestjs.cjs" - } - }, - "./adapters/nestjs/microservices": { - "import": { - "types": "./dist/adapters/nestjs/microservices.d.ts", - "default": "./dist/adapters/nestjs/microservices.js" - }, - "require": { - "types": "./dist/adapters/nestjs/microservices.d.cts", - "default": "./dist/adapters/nestjs/microservices.cjs" - } - }, - "./adapters/nestjs/native": { - "import": { - "types": "./dist/adapters/nestjs/native.d.ts", - "default": "./dist/adapters/nestjs/native.js" - }, - "require": { - "types": "./dist/adapters/nestjs/native.d.cts", - "default": "./dist/adapters/nestjs/native.cjs" - } - }, - "./adapters/nestjs/websockets": { - "import": { - "types": "./dist/adapters/nestjs/websockets.d.ts", - "default": "./dist/adapters/nestjs/websockets.js" - }, - "require": { - "types": "./dist/adapters/nestjs/websockets.d.cts", - "default": "./dist/adapters/nestjs/websockets.cjs" - } - }, "./adapters/observe-storage": { "import": { "types": "./dist/adapters/observe-storage.d.ts", @@ -86,46 +46,6 @@ "default": "./dist/adapters/observe-storage.cjs" } }, - "./adapters/react": { - "import": { - "types": "./dist/adapters/react.d.ts", - "default": "./dist/adapters/react.js" - }, - "require": { - "types": "./dist/adapters/react.d.cts", - "default": "./dist/adapters/react.cjs" - } - }, - "./adapters/solid": { - "import": { - "types": "./dist/adapters/solid.d.ts", - "default": "./dist/adapters/solid.js" - }, - "require": { - "types": "./dist/adapters/solid.d.cts", - "default": "./dist/adapters/solid.cjs" - } - }, - "./adapters/svelte": { - "import": { - "types": "./dist/adapters/svelte.d.ts", - "default": "./dist/adapters/svelte.js" - }, - "require": { - "types": "./dist/adapters/svelte.d.cts", - "default": "./dist/adapters/svelte.cjs" - } - }, - "./adapters/vue": { - "import": { - "types": "./dist/adapters/vue.d.ts", - "default": "./dist/adapters/vue.js" - }, - "require": { - "types": "./dist/adapters/vue.d.cts", - "default": "./dist/adapters/vue.cjs" - } - }, "./composition": { "import": { "types": "./dist/composition/index.d.ts", @@ -862,50 +782,6 @@ "test:watch": "vitest" }, "license": "MIT", - "peerDependencies": { - "@nestjs/common": "^11.0.0", - "@nestjs/core": "^11.0.0", - "@nestjs/microservices": "^11.0.0", - "@nestjs/websockets": "^11.0.0", - "canvas": "^3.2.3", - "react": "^18.0.0 || ^19.0.0", - "rxjs": "^7.8.0", - "solid-js": "^1.9.0", - "svelte": "^5.0.0", - "vue": "^3.5.0" - }, - "peerDependenciesMeta": { - "@nestjs/common": { - "optional": true - }, - "@nestjs/core": { - "optional": true - }, - "@nestjs/microservices": { - "optional": true - }, - "@nestjs/websockets": { - "optional": true - }, - "canvas": { - "optional": true - }, - "react": { - "optional": true - }, - "rxjs": { - "optional": true - }, - "solid-js": { - "optional": true - }, - "svelte": { - "optional": true - }, - "vue": { - "optional": true - } - }, "devDependencies": { "tsup": "^8.5.1", "typescript": "^5.7.0", diff --git a/packages/ts/src/__tests__/adapters.nestjs.e2e.test.ts b/packages/ts/src/__tests__/adapters.nestjs.e2e.test.ts deleted file mode 100644 index f8e93489..00000000 --- a/packages/ts/src/__tests__/adapters.nestjs.e2e.test.ts +++ /dev/null @@ -1,707 +0,0 @@ -import "reflect-metadata"; -import type { AddressInfo } from "node:net"; -import { Controller, type INestApplication, Module, Post } from "@nestjs/common"; -import { NestFactory } from "@nestjs/core"; -import { - type ClientProxy, - ClientProxyFactory, - MessagePattern, - Transport, -} from "@nestjs/microservices"; -import { WsAdapter } from "@nestjs/platform-ws"; -import { SubscribeMessage, WebSocketGateway } from "@nestjs/websockets"; -import { firstValueFrom } from "rxjs"; -import { afterEach, describe, expect, it } from "vitest"; -import { - fromNestMessage, - GRAPHREFLY_NEST_MESSAGE_BRIDGE, - GraphMessage, - type GraphMessageBridge, - GraphMessageReply, - provideGraphMessageProviders, -} from "../adapters/nestjs/microservices.js"; -import { provideGraphBoundaryInterceptor } from "../adapters/nestjs/native.js"; -import { - fromNestWs, - GRAPHREFLY_NEST_WS_BRIDGE, - GraphWs, - GraphWsAck, - type GraphWsBridge, - GraphWsReply, - provideGraphWsProviders, -} from "../adapters/nestjs/websockets.js"; -import { - fromNestReq, - GraphHttpReply, - GraphReq, - type NestBoundaryEnvelope, - type NestReplyEnvelope, -} from "../adapters/nestjs.js"; -import { depLatest } from "../ctx/types.js"; -import { graph } from "../graph/index.js"; - -interface E2eHttpHost { - readonly requestId: string; - readonly body: { readonly orderId?: string; readonly hidden?: unknown }; - readonly headers?: Record; -} - -interface E2eWsHost { - readonly requestId: string; - readonly payload: { readonly orderId: string }; - readonly client: object; - readonly ack: (payload: unknown, envelope: NestReplyEnvelope) => void; -} - -interface E2eMessageHost { - readonly requestId: string; - readonly payload: { readonly orderId: string }; - readonly context: object; -} - -interface E2eNestHarness { - readonly app: INestApplication; - readonly url: string; - readonly tcpPort: number; - readonly wsGateway: { - handle( - bodyOrClient: WsMessageBody | object, - clientOrBody: object | WsMessageBody, - ack?: E2eWsHost["ack"], - ): unknown; - pending(body: WsMessageBody, client: object, ack: E2eWsHost["ack"]): unknown; - handleDisconnect(client: object): void; - }; - readonly messageController: { - handle(body: MessagePatternBody, context: object): unknown; - pending(body: MessagePatternBody, context: object): unknown; - }; - readonly wsBridge: GraphWsBridge; - readonly messageBridge: GraphMessageBridge; - readonly httpSeen: NestBoundaryEnvelope[]; - readonly wsSeen: NestBoundaryEnvelope[]; - readonly messageSeen: NestBoundaryEnvelope[]; - readonly graphJson: () => string; - readonly close: () => Promise; -} - -interface WsMessageBody { - readonly requestId: string; - readonly payload: { readonly orderId: string }; -} - -interface MessagePatternBody { - readonly requestId: string; - readonly payload: { readonly orderId: string }; -} - -const openHarnesses: E2eNestHarness[] = []; -const openClients: ClientProxy[] = []; - -afterEach(async () => { - for (const client of openClients.splice(0).reverse()) client.close(); - for (const harness of openHarnesses.splice(0).reverse()) await harness.close(); -}); - -describe("NestJS v1 e2e wiring (D488/D489)", () => { - it("runs HTTP, WebSocket, and message-pattern bridges without host handle DATA", async () => { - const harness = await createE2eNestHarness(); - openHarnesses.push(harness); - - const response = await fetch(`${harness.url}/orders`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-request-id": "req-http-1", - }, - body: JSON.stringify({ orderId: "ord-http-1", hidden: "selected-out" }), - }); - const body = await response.json(); - - expect(response.status).toBe(201); - expect(body).toEqual({ - ok: true, - kind: "http", - orderId: "ord-http-1", - requestId: "req-http-1", - }); - expect(harness.httpSeen).toEqual([ - { - bindingId: "http.e2e.in", - version: 1, - requestId: "req-http-1", - payload: { orderId: "ord-http-1" }, - }, - ]); - expect(JSON.stringify(harness.httpSeen[0])).not.toContain("headers"); - expect(JSON.stringify(harness.httpSeen[0])).not.toContain("hidden"); - - const ackCalls: unknown[] = []; - const socket = { id: "socket-e2e" }; - const wsReply = await harness.wsGateway.handle( - { requestId: "req-ws-1", payload: { orderId: "ord-ws-1" } }, - socket, - (payload, envelope) => ackCalls.push({ payload, envelope }), - ); - - expect(ackCalls).toEqual([ - { - payload: { accepted: true, orderId: "ord-ws-1" }, - envelope: { - bindingId: "ws.e2e.ack", - version: 1, - requestId: "req-ws-1", - payload: { accepted: true, orderId: "ord-ws-1" }, - }, - }, - ]); - expect(wsReply).toEqual({ ok: true, kind: "ws", orderId: "ord-ws-1" }); - expect(harness.wsSeen).toEqual([ - { - bindingId: "ws.e2e.in", - version: 1, - requestId: "req-ws-1", - payload: { orderId: "ord-ws-1" }, - }, - ]); - expect(JSON.stringify(harness.wsSeen[0])).not.toContain("socket-e2e"); - expect(JSON.stringify(harness.wsSeen[0])).not.toContain("ack"); - expect(harness.wsBridge.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual([ - "binding-mismatch", - "stale-egress", - ]); - - const messageReply = await harness.messageController.handle( - { requestId: "req-message-1", payload: { orderId: "ord-message-1" } }, - { id: "message-context" }, - ); - - expect(messageReply).toEqual({ - ok: true, - kind: "message", - orderId: "ord-message-1", - }); - expect(harness.messageSeen).toEqual([ - { - bindingId: "message.e2e.in", - version: 1, - requestId: "req-message-1", - payload: { orderId: "ord-message-1" }, - }, - ]); - expect(JSON.stringify(harness.messageSeen[0])).not.toContain("message-context"); - expect(harness.messageBridge.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual([ - "binding-mismatch", - "stale-egress", - ]); - expect(harness.graphJson()).not.toContain("socket-e2e"); - expect(harness.graphJson()).not.toContain("message-context"); - - const disconnectSocket = { id: "socket-disconnect" }; - const disconnected = harness.wsGateway.pending( - { requestId: "req-ws-disconnect", payload: { orderId: "ord-ws-disconnect" } }, - disconnectSocket, - () => undefined, - ); - harness.wsGateway.handleDisconnect(disconnectSocket); - await expect(disconnected).rejects.toThrow(/disconnected/); - expect(harness.wsBridge.diagnostics().map((diagnostic) => diagnostic.kind)).toContain( - "dispose-pending", - ); - - const wsPending = harness.wsGateway.pending( - { requestId: "req-ws-close", payload: { orderId: "ord-ws-close" } }, - { id: "socket-close" }, - () => undefined, - ); - const messagePending = harness.messageController.pending( - { requestId: "req-message-close", payload: { orderId: "ord-message-close" } }, - { id: "message-close-context" }, - ); - const wsPendingRejected = expect(wsPending).rejects.toThrow(/disposed/); - const messagePendingRejected = expect(messagePending).rejects.toThrow(/disposed/); - await harness.close(); - await wsPendingRejected; - await messagePendingRejected; - }); - - it("accepts live WebSocket and TCP transport traffic as test-only coverage over existing APIs", async () => { - const harness = await createE2eNestHarness(); - openHarnesses.push(harness); - - const socket = await openWebSocket(harness.url); - try { - const liveWsReply = nextWebSocketJson(socket); - socket.send( - JSON.stringify({ - event: "orders", - data: { - requestId: "req-ws-live", - payload: { orderId: "ord-ws-live" }, - }, - }), - ); - - await expect(liveWsReply).resolves.toEqual({ - ok: true, - kind: "ws", - orderId: "ord-ws-live", - }); - } finally { - socket.close(); - } - - expect(harness.wsSeen).toEqual([ - { - bindingId: "ws.e2e.in", - version: 1, - requestId: "req-ws-live", - payload: { orderId: "ord-ws-live" }, - }, - ]); - expect(harness.graphJson()).not.toContain("WebSocket"); - expect(harness.graphJson()).not.toContain("readyState"); - - const client = ClientProxyFactory.create({ - transport: Transport.TCP, - options: { host: "127.0.0.1", port: harness.tcpPort }, - }); - openClients.push(client); - await client.connect(); - const liveMessageReply = await firstValueFrom( - client.send("orders.e2e", { - requestId: "req-message-live", - payload: { orderId: "ord-message-live" }, - }), - ); - - expect(liveMessageReply).toEqual({ - ok: true, - kind: "message", - orderId: "ord-message-live", - }); - expect(harness.messageSeen).toEqual([ - { - bindingId: "message.e2e.in", - version: 1, - requestId: "req-message-live", - payload: { orderId: "ord-message-live" }, - }, - ]); - expect(harness.graphJson()).not.toContain("message-context"); - }); -}); - -async function createE2eNestHarness(): Promise { - const g = graph({ name: "nestjs-e2e" }); - const httpSeen: NestBoundaryEnvelope[] = []; - const wsSeen: NestBoundaryEnvelope[] = []; - const messageSeen: NestBoundaryEnvelope[] = []; - - const httpIn = fromNestReq(g, { - bindingId: "node.http.e2e.in", - }); - httpIn.node.subscribe((msg) => msg[0] === "DATA" && httpSeen.push(msg[1])); - const httpReply = g.node>( - [httpIn.node], - (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ readonly orderId?: string }>; - if (envelope.requestId === undefined) return; - ctx.down([ - [ - "DATA", - { - bindingId: "http.e2e.wrong", - version: 1, - requestId: envelope.requestId, - payload: { status: 599, body: { wrong: true } }, - }, - ], - [ - "DATA", - { - bindingId: "http.e2e.out", - version: 1, - requestId: envelope.requestId, - payload: { - status: 201, - body: { - ok: true, - kind: "http", - orderId: envelope.payload.orderId, - requestId: envelope.requestId, - }, - }, - }, - ], - ]); - }, - { name: "http.e2e.out" }, - ); - - const wsIn = fromNestWs(g, { - bindingId: "node.ws.e2e.in", - }); - wsIn.node.subscribe((msg) => msg[0] === "DATA" && wsSeen.push(msg[1])); - const wsAck = g.node>( - [wsIn.node], - (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ readonly orderId: string }>; - if (envelope.requestId === undefined) return; - ctx.down([ - [ - "DATA", - { - bindingId: "ws.e2e.other", - version: 1, - requestId: envelope.requestId, - payload: { ignored: true }, - }, - ], - [ - "DATA", - { - bindingId: "ws.e2e.ack", - version: 1, - requestId: "req-ws-stale", - payload: { stale: true }, - }, - ], - [ - "DATA", - { - bindingId: "ws.e2e.ack", - version: 1, - requestId: envelope.requestId, - payload: { accepted: true, orderId: envelope.payload.orderId }, - }, - ], - ]); - }, - { name: "ws.e2e.ack" }, - ); - const wsReply = g.node>( - [wsIn.node], - (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ readonly orderId: string }>; - if (envelope.requestId === undefined) return; - ctx.down([ - [ - "DATA", - { - bindingId: "ws.e2e.reply", - version: 1, - requestId: envelope.requestId, - payload: { ok: true, kind: "ws", orderId: envelope.payload.orderId }, - }, - ], - ]); - }, - { name: "ws.e2e.reply" }, - ); - - const messageIn = fromNestMessage(g, { - bindingId: "node.message.e2e.in", - }); - messageIn.node.subscribe((msg) => msg[0] === "DATA" && messageSeen.push(msg[1])); - const messageReply = g.node>( - [messageIn.node], - (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ readonly orderId: string }>; - if (envelope.requestId === undefined) return; - ctx.down([ - [ - "DATA", - { - bindingId: "message.e2e.other", - version: 1, - requestId: envelope.requestId, - payload: { ignored: true }, - }, - ], - [ - "DATA", - { - bindingId: "message.e2e.reply", - version: 1, - requestId: "req-message-stale", - payload: { stale: true }, - }, - ], - [ - "DATA", - { - bindingId: "message.e2e.reply", - version: 1, - requestId: envelope.requestId, - payload: { ok: true, kind: "message", orderId: envelope.payload.orderId }, - }, - ], - ]); - }, - { name: "message.e2e.reply" }, - ); - const wsPendingIn = fromNestWs(g, { - bindingId: "node.ws.e2e.pending.in", - }); - const wsPendingReply = g.node>([], null, { - name: "ws.e2e.pending.reply", - }); - const messagePendingIn = fromNestMessage(g, { - bindingId: "node.message.e2e.pending.in", - }); - const messagePendingReply = g.node>([], null, { - name: "message.e2e.pending.reply", - }); - - class E2eHttpController { - create(): void {} - } - - class E2eWsGateway { - bridge?: GraphWsBridge; - - handle( - bodyOrClient: WsMessageBody | object, - clientOrBody: object | WsMessageBody, - ack: E2eWsHost["ack"] = () => undefined, - ): unknown { - if (this.bridge === undefined) throw new Error("GraphWsBridge was not attached"); - const [client, body] = isWsMessageBody(bodyOrClient) - ? [clientOrBody as object, bodyOrClient] - : [bodyOrClient, clientOrBody as WsMessageBody]; - return this.bridge.handleMessage(E2eWsGateway, "handle", { - requestId: body.requestId, - payload: body.payload, - client, - ack, - }); - } - - pending(body: WsMessageBody, client: object, ack: E2eWsHost["ack"]): unknown { - if (this.bridge === undefined) throw new Error("GraphWsBridge was not attached"); - return this.bridge.handleMessage(E2eWsGateway, "pending", { - requestId: body.requestId, - payload: body.payload, - client, - ack, - }); - } - - handleDisconnect(client: object): void { - if (this.bridge === undefined) throw new Error("GraphWsBridge was not attached"); - this.bridge.handleDisconnect(client); - } - } - - class E2eMessageController { - bridge?: GraphMessageBridge; - - handle(body: MessagePatternBody, context: object): unknown { - if (this.bridge === undefined) throw new Error("GraphMessageBridge was not attached"); - return this.bridge.handleMessage(E2eMessageController, "handle", { - requestId: body.requestId, - payload: body.payload, - context, - }); - } - - pending(body: MessagePatternBody, context: object): unknown { - if (this.bridge === undefined) throw new Error("GraphMessageBridge was not attached"); - return this.bridge.handleMessage(E2eMessageController, "pending", { - requestId: body.requestId, - payload: body.payload, - context, - }); - } - } - - applyClassDecorator(Controller(), E2eHttpController); - applyMethodDecorators( - E2eHttpController.prototype, - "create", - Post("orders"), - GraphReq(httpIn, { - bindingId: "http.e2e.in", - requestId: (host: E2eHttpHost) => host.requestId, - payload: (host: E2eHttpHost) => ({ orderId: host.body.orderId }), - }), - GraphHttpReply(httpReply, { bindingId: "http.e2e.out" }), - ); - - applyClassDecorator(WebSocketGateway(), E2eWsGateway); - applyMethodDecorators( - E2eWsGateway.prototype, - "handle", - SubscribeMessage("orders"), - GraphWs(wsIn, { - bindingId: "ws.e2e.in", - requestId: (host: E2eWsHost) => host.requestId, - payload: (host: E2eWsHost) => host.payload, - }), - GraphWsAck(wsAck, { bindingId: "ws.e2e.ack" }), - GraphWsReply(wsReply, { bindingId: "ws.e2e.reply" }), - ); - applyMethodDecorators( - E2eWsGateway.prototype, - "pending", - SubscribeMessage("orders.pending"), - GraphWs(wsPendingIn, { - bindingId: "ws.e2e.pending.in", - requestId: (host: E2eWsHost) => host.requestId, - payload: (host: E2eWsHost) => host.payload, - }), - GraphWsReply(wsPendingReply, { bindingId: "ws.e2e.pending.reply" }), - ); - - applyClassDecorator(Controller(), E2eMessageController); - applyMethodDecorators( - E2eMessageController.prototype, - "handle", - MessagePattern("orders.e2e"), - GraphMessage(messageIn, { - bindingId: "message.e2e.in", - requestId: (host: E2eMessageHost) => host.requestId, - payload: (host: E2eMessageHost) => host.payload, - }), - GraphMessageReply(messageReply, { bindingId: "message.e2e.reply" }), - ); - applyMethodDecorators( - E2eMessageController.prototype, - "pending", - MessagePattern("orders.e2e.pending"), - GraphMessage(messagePendingIn, { - bindingId: "message.e2e.pending.in", - requestId: (host: E2eMessageHost) => host.requestId, - payload: (host: E2eMessageHost) => host.payload, - }), - GraphMessageReply(messagePendingReply, { bindingId: "message.e2e.pending.reply" }), - ); - - class E2eAppModule {} - applyClassDecorator( - Module({ - controllers: [E2eHttpController, E2eMessageController], - providers: [ - E2eWsGateway, - provideGraphBoundaryInterceptor({ - host: (context) => { - const req = context.switchToHttp().getRequest<{ - body?: E2eHttpHost["body"]; - headers?: E2eHttpHost["headers"]; - }>(); - const requestId = req.headers?.["x-request-id"]; - return { - requestId: Array.isArray(requestId) ? requestId[0] : (requestId ?? "req-http"), - body: req.body ?? {}, - headers: req.headers, - }; - }, - requestId: (host: E2eHttpHost) => host.requestId, - }), - ...provideGraphWsProviders({ - bridge: { - ack: (host) => host.ack, - client: (host) => host.client, - }, - }), - ...provideGraphMessageProviders(), - ], - }), - E2eAppModule, - ); - - const app = await NestFactory.create(E2eAppModule, { logger: false }); - const attachWsAdapter = app.useWebSocketAdapter.bind(app); - attachWsAdapter(new WsAdapter(app)); - const microservice = app.connectMicroservice({ - transport: Transport.TCP, - options: { host: "127.0.0.1", port: 0 }, - }); - await app.startAllMicroservices(); - const tcpPort = tcpPortFor(microservice.unwrap()); - await app.listen(0, "127.0.0.1"); - const wsGateway = app.get(E2eWsGateway); - const messageController = app.get(E2eMessageController, { strict: false }); - const wsBridge = app.get>(GRAPHREFLY_NEST_WS_BRIDGE); - const messageBridge = app.get>(GRAPHREFLY_NEST_MESSAGE_BRIDGE); - wsGateway.bridge = wsBridge; - messageController.bridge = messageBridge; - - let closed = false; - return { - app, - url: await app.getUrl(), - tcpPort, - wsGateway, - messageController, - wsBridge, - messageBridge, - httpSeen, - wsSeen, - messageSeen, - graphJson: () => JSON.stringify(g.describe()), - close: async () => { - if (closed) return; - closed = true; - await app.close(); - }, - }; -} - -function isWsMessageBody(value: unknown): value is WsMessageBody { - return value !== null && typeof value === "object" && "requestId" in value && "payload" in value; -} - -function tcpPortFor(server: unknown): number { - const address = - server !== null && typeof server === "object" && "address" in server - ? (server as { address: () => AddressInfo | string | null }).address() - : undefined; - if (address === null || address === undefined || typeof address === "string") { - throw new Error("Nest TCP live acceptance test could not resolve a random TCP port"); - } - return address.port; -} - -async function openWebSocket(baseUrl: string): Promise { - const url = baseUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:"); - const socket = new WebSocket(url); - await new Promise((resolve, reject) => { - socket.addEventListener("open", () => resolve(), { once: true }); - socket.addEventListener("error", () => reject(new Error("WebSocket connection failed")), { - once: true, - }); - }); - return socket; -} - -function nextWebSocketJson(socket: WebSocket): Promise { - return new Promise((resolve, reject) => { - socket.addEventListener( - "message", - (event) => { - try { - resolve(JSON.parse(String(event.data))); - } catch (error) { - reject(error); - } - }, - { once: true }, - ); - }); -} - -function applyClassDecorator(decorator: ClassDecorator, target: abstract new () => unknown): void { - decorator(target); -} - -function applyMethodDecorators( - prototype: object, - methodKey: string, - ...decorators: MethodDecorator[] -): void { - const descriptor = Object.getOwnPropertyDescriptor(prototype, methodKey); - if (descriptor === undefined) throw new Error(`Missing descriptor for ${methodKey}`); - for (const decorator of decorators) decorator(prototype, methodKey, descriptor); -} diff --git a/packages/ts/src/__tests__/adapters.test.ts b/packages/ts/src/__tests__/adapters.test.ts deleted file mode 100644 index 1e325903..00000000 --- a/packages/ts/src/__tests__/adapters.test.ts +++ /dev/null @@ -1,2900 +0,0 @@ -import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core"; -import { describe, expect, it, vi } from "vitest"; -import { - externalStore, - jotaiAtom, - nanoAtom, - nodeSnapshot, - readableStore, - recordReadableStore, - signalFromNode, - subscribeNodeValues, - writableStore, - zustandStore, -} from "../adapters/index.js"; -import { - createGraphMessageBridge, - fromNestMessage, - GRAPHREFLY_NEST_MESSAGE_BRIDGE, - GraphMessage, - type GraphMessageBridge, - GraphMessageReply, - provideGraphMessageProviders, -} from "../adapters/nestjs/microservices.js"; -import { - createGraphCronController, - createGraphExceptionFilter, - createGraphGuardDeniedFilter, - createNestGraphGuardAwaitScope, - GraphGuardDeniedException, - GraphGuardDeniedFilter, - graphCronTarget, - graphLifecycleTarget, - isGraphGuardDeniedException, - provideGraphBoundaryInterceptor, - provideGraphCronScheduler, - provideGraphExceptionFilter, - provideGraphGuard, - provideGraphGuardDeniedFilter, - provideGraphLifecycleHooks, - provideGraphNativeHttpProviders, - provideGraphNativeProviders, -} from "../adapters/nestjs/native.js"; -import { - createGraphWsBridge, - fromNestWs, - GRAPHREFLY_NEST_WS_BRIDGE, - GraphWs, - GraphWsAck, - type GraphWsBridge, - GraphWsReply, - provideGraphWsProviders, -} from "../adapters/nestjs/websockets.js"; -import { - createNestGraphBoundaryInterceptor, - createNestGraphBoundaryRunner, - fromNestCron, - fromNestDiagnostics, - fromNestError, - fromNestGuard, - fromNestIntercept, - fromNestLifecycle, - fromNestReq, - GRAPHREFLY_REQUEST_GRAPH, - GRAPHREFLY_ROOT_GRAPH, - GraphCron, - GraphError, - GraphFilter, - GraphGuard, - GraphGuardDecision, - type GraphGuardDecision as GraphGuardDecisionPayload, - GraphHttpReply, - GraphInterval, - GraphLifecycle, - GraphReq, - getGraphToken, - getNestBoundaryBindings, - getNestBoundaryToken, - getNodeToken, - type HttpDataIssue, - issueResponse, - lowerHttpReplyPayload, - lowerProtocolError, - NEST_BOUNDARY_BINDINGS, - CRON_HANDLERS as NEST_CRON_HANDLERS, - EVENT_HANDLERS as NEST_EVENT_HANDLERS, - INTERVAL_HANDLERS as NEST_INTERVAL_HANDLERS, - type NestBoundaryEnvelope, - type NestDiagnosticIngressBoundary, - type NestReplyEnvelope, - nestProvider, - OnGraphEvent, - protocolError, - sanitizeNestDiagnostic, - toNestHttp, -} from "../adapters/nestjs.js"; -import { depLatest } from "../ctx/types.js"; -import { graph } from "../graph/index.js"; - -describe("framework-neutral store adapters (B61)", () => { - it("adapts a node to a readable store with one immediate snapshot", () => { - const g = graph(); - const count = g.state(1); - const store = readableStore(count); - const seen: Array = []; - - const unsubscribe = store.subscribe((value) => seen.push(value)); - count.set(2); - unsubscribe(); - count.set(3); - - expect(store.get()).toBe(3); - expect(seen).toEqual([1, 2]); - expect(nodeSnapshot(count)).toBe(3); - }); - - it("adapts a StateNode to a writable store", () => { - const g = graph(); - const count = g.state(1); - const store = writableStore(count); - const seen: Array = []; - - const unsubscribe = store.subscribe((value) => seen.push(value)); - store.set(2); - store.update((value) => (value ?? 0) + 3); - unsubscribe(); - - expect(store.get()).toBe(5); - expect(count.cache).toBe(5); - expect(seen).toEqual([1, 2, 5]); - }); - - it("supports change-only value subscriptions", () => { - const g = graph(); - const count = g.state(1); - const seen: Array = []; - - const unsubscribe = subscribeNodeValues(count, (value) => seen.push(value), { - changesOnly: true, - }); - count.set(2); - unsubscribe(); - count.set(3); - - expect(seen).toEqual([2]); - }); - - it("keeps activation DATA while suppressing cached and replayed subscribe history", () => { - const g = graph(); - const count = g.state(2); - const doubled = g.derived([count], (value) => value * 2); - const coldSeen: Array = []; - - const unsubscribeCold = readableStore(doubled).subscribe((value) => coldSeen.push(value)); - unsubscribeCold(); - - const replayed = g.node([], null, { replayBuffer: 3 }); - replayed.down([ - ["DATA", 1], - ["DATA", 2], - ["DATA", 3], - ]); - const changes: Array = []; - const unsubscribeChanges = subscribeNodeValues(replayed, (value) => changes.push(value), { - changesOnly: true, - }); - replayed.down([["DATA", 4]]); - unsubscribeChanges(); - - expect(coldSeen).toEqual([undefined, 4]); - expect(changes).toEqual([4]); - }); - - it("routes ERROR and COMPLETE lifecycle messages without exposing protocol internals as values", () => { - const g = graph(); - const source = g.node([], null, { resubscribable: true }); - const values: Array = []; - const errors: unknown[] = []; - const complete = vi.fn(); - - const unsubscribe = subscribeNodeValues(source, (value) => values.push(value), { - onError: (error) => errors.push(error), - onComplete: complete, - }); - - source.down([["DATA", 1]]); - const err = new Error("boom"); - source.down([["ERROR", err]]); - - expect(values).toEqual([1]); - expect(errors).toEqual([err]); - expect(complete).not.toHaveBeenCalled(); - - unsubscribe(); - - const next = g.node([], null); - subscribeNodeValues(next, (value) => values.push(value), { onComplete: complete }); - next.down([["COMPLETE"]]); - expect(complete).toHaveBeenCalledTimes(1); - }); - - it("builds a React-compatible external-store shape without importing React", () => { - const g = graph(); - const count = g.state(1); - const store = externalStore(count); - const changed = vi.fn(); - - const unsubscribe = store.subscribe(changed); - expect(store.getSnapshot()).toBe(1); - expect(store.getServerSnapshot()).toBe(1); - - count.set(2); - unsubscribe(); - count.set(3); - - expect(changed).toHaveBeenCalledTimes(1); - expect(store.getSnapshot()).toBe(3); - }); - - it("builds a keyed record store without framework hooks", () => { - const g = graph(); - const keys = g.state(["a"]); - const a = g.state(1); - const b = g.state(2); - const values: Record = { a, b }; - const store = recordReadableStore(keys, (key) => ({ value: values[key] })); - const seen: Array> = []; - - const unsubscribe = store.subscribe((snapshot) => { - seen.push(snapshot as Record); - }); - values.a.set(3); - keys.set(["a", "b"]); - values.b.set(4); - unsubscribe(); - values.a.set(5); - - expect(seen).toEqual([ - { a: { value: 1 } }, - { a: { value: 3 } }, - { a: { value: 3 }, b: { value: 2 } }, - { a: { value: 3 }, b: { value: 4 } }, - ]); - }); - - it("builds Zustand/Jotai/Nanostores/signals-style facades over caller-owned nodes", () => { - const g = graph(); - const state = g.state({ count: 1 }); - const zustand = zustandStore(state); - const zustandSeen: Array = []; - const unsubZustand = zustand.subscribe((next, prev) => { - zustandSeen.push([next.count, prev.count]); - }); - - zustand.setState((prev) => ({ count: prev.count + 1 })); - zustand.setState({ count: 10 }, true); - unsubZustand(); - - const jotai = jotaiAtom(state); - const nano = nanoAtom(state); - const signal = signalFromNode(state); - const jotaiSeen: Array = []; - const nanoSeen: Array = []; - const signalSeen: Array = []; - - const unsubJotai = jotai.subscribe((value) => jotaiSeen.push(value?.count)); - const unsubNano = nano.listen((value) => nanoSeen.push(value?.count)); - const unsubSignal = signal.subscribe((value) => signalSeen.push(value?.count)); - - jotai.set({ count: 11 }); - nano.update((value) => ({ count: (value?.count ?? 0) + 1 })); - signal.set({ count: 13 }); - unsubJotai(); - unsubNano(); - unsubSignal(); - zustand.destroy(); - - expect(zustandSeen).toEqual([ - [2, 1], - [10, 2], - ]); - expect(jotai.get()?.count).toBe(13); - expect(nano.get()?.count).toBe(13); - expect(signal.get()?.count).toBe(13); - expect(jotaiSeen).toEqual([11, 12, 13]); - expect(nanoSeen).toEqual([11, 12, 13]); - expect(signalSeen).toEqual([11, 12, 13]); - }); - - it("delivers custom Zustand snapshots to subscribers instead of raw node DATA", () => { - const g = graph(); - const state = g.state({ count: 1 }); - const store = zustandStore( - state, - { count: 1, inc: () => {} }, - { - getSnapshot: () => ({ - count: state.cache?.count ?? 0, - inc: () => store.setState((prev) => ({ count: prev.count + 1 })), - }), - write: (_node, value) => state.set({ count: value.count }), - }, - ); - const seen: Array<{ count: number; hasInc: boolean }> = []; - const unsub = store.subscribe((next) => { - seen.push({ count: next.count, hasInc: typeof next.inc === "function" }); - }); - - store.setState((prev) => ({ count: prev.count + 1 })); - unsub(); - store.destroy(); - - expect(seen).toEqual([{ count: 2, hasInc: true }]); - }); - - it("requires writable facades to use StateNode.set or an explicit write bridge", () => { - const g = graph(); - const readonly = g.node([], null); - const readonlyObject = g.node<{ count: number }>([], null); - const unsafeWritableStore = writableStore as unknown as (node: typeof readonly) => unknown; - const unsafeZustandStore = zustandStore as unknown as ( - node: typeof readonlyObject, - initialState: { count: number }, - ) => unknown; - - expect(() => unsafeWritableStore(readonly)).toThrow(/set\(value\) or opts\.write/); - expect(() => unsafeZustandStore(readonlyObject, { count: 0 })).toThrow( - /set\(value\) or opts\.write/, - ); - - const written: number[] = []; - const explicit = writableStore(readonly, { - write: (_node, value) => { - written.push(value); - }, - }); - explicit.set(7); - - expect(written).toEqual([7]); - }); - - it("exposes dependency-free NestJS tokens and method metadata helpers", () => { - class Service { - handle() {} - interval() {} - } - const eventInitializers: Array<(this: unknown) => void> = []; - - OnGraphEvent("orders::created")(Service.prototype.handle, { - name: "handle", - addInitializer(fn: (this: unknown) => void) { - eventInitializers.push(fn); - }, - } as ClassMethodDecoratorContext); - GraphInterval(1000)(Service.prototype, "interval", { - value: Service.prototype.interval, - }); - - const service = new Service(); - eventInitializers.forEach((fn) => { - fn.call(service); - fn.call(service); - }); - - expect(GRAPHREFLY_ROOT_GRAPH).toBe(Symbol.for("graphrefly:root-graph")); - expect(GRAPHREFLY_REQUEST_GRAPH).toBe(Symbol.for("graphrefly:request-graph")); - expect(getGraphToken("orders")).toBe(Symbol.for("graphrefly:graph:orders")); - expect(getNodeToken("orders::created")).toBe(Symbol.for("graphrefly:node:orders::created")); - expect(NEST_EVENT_HANDLERS.get(Service)).toEqual([ - { nodeName: "orders::created", methodKey: "handle" }, - ]); - expect(NEST_CRON_HANDLERS.get(Service)).toBeUndefined(); - expect(NEST_INTERVAL_HANDLERS.get(Service)).toEqual([{ ms: 1000, methodKey: "interval" }]); - }); - - it("records concrete D478 GraphReq and GraphHttpReply binding metadata", () => { - const g = graph(); - const req = fromNestReq(g, { bindingId: "node.http.in" }); - const reply = g.node>([], null, { - name: "reply/node", - }); - class Controller { - post() {} - } - const initializers: Array<(this: unknown) => void> = []; - - GraphReq(req, { bindingId: "http.orders.create.in" })(Controller.prototype.post, { - name: "post", - addInitializer(fn: (this: unknown) => void) { - initializers.push(fn); - }, - } as ClassMethodDecoratorContext); - GraphHttpReply(reply, { bindingId: "http.orders.create.out" })(Controller.prototype.post, { - name: "post", - addInitializer(fn: (this: unknown) => void) { - initializers.push(fn); - }, - } as ClassMethodDecoratorContext); - - const controller = new Controller(); - initializers.forEach((fn) => { - fn.call(controller); - }); - - const token = getNestBoundaryToken("orders.http"); - expect(token).toBe(Symbol.for("graphrefly:nest-boundary:orders.http")); - expect(nestProvider(token, "value")).toEqual({ provide: token, useValue: "value" }); - expect(NEST_BOUNDARY_BINDINGS.get(Controller)).toEqual([ - expect.objectContaining({ - direction: "ingress", - kind: "request", - bindingId: "http.orders.create.in", - methodKey: "post", - boundary: req, - }), - expect.objectContaining({ - direction: "egress", - kind: "http", - bindingId: "http.orders.create.out", - methodKey: "post", - replyNode: reply, - }), - ]); - expect(() => GraphReq(fromNestGuard(g))).toThrow(/expected a request boundary/); - expect(() => GraphHttpReply(reply, {} as { readonly bindingId: string })).toThrow(/bindingId/); - }); - - it("builds keyed Nest ingress envelopes with stable explicit binding ids", () => { - const g = graph(); - const req = fromNestReq< - { requestId: string; body: { readonly orderId: string } }, - { readonly orderId: string } - >(g, { - bindingId: "orders.create", - payload: (host) => host.body, - }); - const seen: NestBoundaryEnvelope<{ readonly orderId: string }>[] = []; - const unsubscribe = req.node.subscribe((msg) => { - if (msg[0] === "DATA") - seen.push(msg[1] as NestBoundaryEnvelope<{ readonly orderId: string }>); - }); - - const envelope = req.emit({ requestId: "req-1", body: { orderId: "o-1" } }); - unsubscribe(); - - expect(req.bindingId).toBe("orders.create"); - expect(envelope).toEqual({ - requestId: "req-1", - bindingId: "orders.create", - version: 1, - payload: { orderId: "o-1" }, - }); - expect(seen).toEqual([envelope]); - expect(g.describe().nodes.some((node) => node.meta?.bindingId === "orders.create")).toBe(true); - }); - - it("allows lifecycle and cron ingress envelopes without fake request ids", () => { - const g = graph(); - const lifecycle = fromNestLifecycle(g, { - bindingId: "lifecycle.app.in", - payload: (host: { readonly event: string }) => ({ event: host.event }), - }); - const cron = fromNestCron(g, { - bindingId: "cron.daily.in", - payload: (host: { readonly tick: string }) => ({ tick: host.tick }), - }); - - expect(lifecycle.emit({ event: "module-destroy" })).toEqual({ - bindingId: "lifecycle.app.in", - version: 1, - payload: { event: "module-destroy" }, - }); - expect(cron.emit({ tick: "midnight" })).toEqual({ - bindingId: "cron.daily.in", - version: 1, - payload: { tick: "midnight" }, - }); - }); - - it("uses deterministic non-random binding ids for Nest ingress fallbacks", () => { - const g = graph(); - - expect(fromNestGuard(g).bindingId).toBe("nestjs.guard"); - expect(fromNestIntercept(g, { name: "orders.intercept" }).bindingId).toBe("orders.intercept"); - expect(fromNestError(g, { bindingId: "orders.error" }).bindingId).toBe("orders.error"); - expect(fromNestLifecycle(g, { bindingId: "app.lifecycle" }).bindingId).toBe("app.lifecycle"); - }); - - it("keeps host-private HTTP handles out of graph DATA and resolves only matching request ids", () => { - const g = graph(); - const egress = g.node>( - [], - null, - { name: "nestjs/http/orders.out" }, - ); - const http = toNestHttp(egress, { bindingId: "orders.http" }); - const resolved: unknown[] = []; - const handle = { - secret: { socket: true }, - resolve(payload: unknown) { - resolved.push(payload); - }, - reject: vi.fn(), - }; - - http.attach({ requestId: "req-1", handle }); - egress.down([ - [ - "DATA", - { - requestId: "req-stale", - bindingId: "orders.http", - version: 1, - payload: { status: 200, body: "stale" }, - }, - ], - ]); - egress.down([ - [ - "DATA", - { - requestId: "req-1", - bindingId: "other.http", - version: 1, - payload: { status: 200, body: "wrong binding" }, - }, - ], - ]); - egress.down([ - [ - "DATA", - { - requestId: "req-1", - bindingId: "orders.http", - version: 1, - payload: { status: 201, body: "created" }, - }, - ], - ]); - - expect(resolved).toEqual([{ status: 201, body: "created" }]); - expect(http.pendingCount()).toBe(0); - expect(http.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual([ - "stale-egress", - "binding-mismatch", - ]); - (http.diagnostics() as unknown[]).length = 0; - expect(http.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual([ - "stale-egress", - "binding-mismatch", - ]); - expect(JSON.stringify(resolved)).not.toContain("socket"); - http.dispose(); - }); - - it("matches Nest HTTP egress by request id by default and scopes by binding id only when requested", () => { - const g = graph(); - const egress = g.node>([], null, { - name: "nestjs/http/default.out", - }); - const http = toNestHttp(egress); - const resolved: unknown[] = []; - - http.attach({ - requestId: "req-1", - handle: { - resolve(payload) { - resolved.push(payload); - }, - reject: vi.fn(), - }, - }); - egress.down([ - [ - "DATA", - { requestId: "req-1", bindingId: "caller.binding", version: 1, payload: { ok: true } }, - ], - ]); - - expect(resolved).toEqual([{ ok: true }]); - expect(http.diagnostics()).toEqual([]); - http.dispose(); - }); - - it("rejects future Nest HTTP attaches after terminal egress", () => { - const g = graph(); - const egress = g.node>([], null, { - name: "nestjs/http/terminal.out", - }); - const http = toNestHttp(egress, { bindingId: "terminal.http" }); - const error = new Error("terminal boom"); - const firstRejected: unknown[] = []; - const laterRejected: unknown[] = []; - - http.attach({ - requestId: "req-1", - handle: { - resolve: vi.fn(), - reject(rejected) { - firstRejected.push(rejected); - }, - }, - }); - egress.down([["ERROR", error]]); - const cleanup = http.attach({ - requestId: "req-2", - handle: { - resolve: vi.fn(), - reject(rejected) { - laterRejected.push(rejected); - }, - }, - }); - - expect(firstRejected).toEqual([error]); - expect(laterRejected).toEqual([error]); - expect(cleanup()).toBe(false); - expect(http.pendingCount()).toBe(0); - expect(http.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual([ - "terminal-egress", - "terminal-egress", - ]); - http.dispose(); - }); - - it("brackets decorator-bound attach, emit, and cleanup in the high-level runner", async () => { - const g = graph(); - const req = fromNestReq< - { readonly requestId: string; readonly body: { readonly ok: true }; readonly fail?: boolean }, - { readonly ok: true } - >(g, { - bindingId: "node.orders.in", - requestId: (host) => host.requestId, - payload: (host) => { - if (host.fail) throw new Error("payload failed"); - return host.body; - }, - }); - const reply = g.node>( - [req.node], - (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ readonly ok: true }>; - if (envelope.requestId === undefined) return; - ctx.down([ - [ - "DATA", - { - requestId: envelope.requestId, - bindingId: "http.orders.out", - version: 1, - payload: envelope.payload, - }, - ], - ]); - }, - { name: "http.orders.out" }, - ); - class Controller { - post() {} - } - GraphReq(req, { bindingId: "http.orders.in" })(Controller.prototype, "post", { - value: Controller.prototype.post, - }); - GraphHttpReply(reply, { bindingId: "http.orders.out" })(Controller.prototype, "post", { - value: Controller.prototype.post, - }); - const runner = createNestGraphBoundaryRunner(); - - expect(() => - runner.run(Controller, "post", { requestId: "req-1", body: { ok: true }, fail: true }), - ).toThrow(/payload failed/); - await expect( - runner.run(Controller, "post", { requestId: "req-1", body: { ok: true } }), - ).resolves.toEqual({ ok: true }); - expect(() => runner.run(Controller, "post", { body: { ok: true } })).toThrow( - /GraphHttpReply requires/, - ); - runner.dispose(); - }); - - it("uses binding-level request ids when attaching high-level HTTP replies", async () => { - const g = graph(); - const req = fromNestReq< - { readonly routeRequestId: string; readonly body: { readonly ok: true } }, - { readonly ok: true } - >(g, { - bindingId: "node.binding-request.in", - payload: (host) => host.body, - }); - const reply = g.node>([req.node], (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ readonly ok: true }>; - if (envelope.requestId === undefined) return; - ctx.down([ - [ - "DATA", - { - requestId: envelope.requestId, - bindingId: "http.binding-request.out", - version: 1, - payload: envelope.payload, - }, - ], - ]); - }); - class Controller { - post() {} - } - GraphReq(req, { - bindingId: "http.binding-request.in", - requestId: (host) => host.routeRequestId, - })(Controller.prototype, "post", { value: Controller.prototype.post }); - GraphHttpReply(reply, { bindingId: "http.binding-request.out" })(Controller.prototype, "post", { - value: Controller.prototype.post, - }); - const runner = createNestGraphBoundaryRunner(); - - await expect( - runner.run(Controller, "post", { - routeRequestId: "req-binding", - body: { ok: true }, - }), - ).resolves.toEqual({ ok: true }); - runner.dispose(); - }); - - it("limits the high-level interceptor runner to request/interceptor ingress and HTTP egress", () => { - const g = graph(); - const req = fromNestReq<{ readonly requestId: string }, { readonly ok: true }>(g, { - bindingId: "node.phase.request.in", - payload: () => ({ ok: true }), - }); - const guard = fromNestGuard<{ readonly requestId: string }, { readonly guard: true }>(g, { - bindingId: "node.phase.guard.in", - payload: () => ({ guard: true }), - }); - const decision = g.node>([], null); - const requestSeen: unknown[] = []; - const guardSeen: unknown[] = []; - req.node.subscribe((msg) => msg[0] === "DATA" && requestSeen.push(msg[1])); - guard.node.subscribe((msg) => msg[0] === "DATA" && guardSeen.push(msg[1])); - class Controller { - post() {} - } - GraphReq(req, { bindingId: "http.phase.request.in" })(Controller.prototype, "post", { - value: Controller.prototype.post, - }); - GraphGuard(guard, { bindingId: "guard.phase.in" })(Controller.prototype, "post", { - value: Controller.prototype.post, - }); - GraphGuardDecision(decision, { bindingId: "guard.phase.out" })(Controller.prototype, "post", { - value: Controller.prototype.post, - }); - const runner = createNestGraphBoundaryRunner(); - - expect(runner.run(Controller, "post", { requestId: "req-phase" })).toBeUndefined(); - expect(requestSeen).toHaveLength(1); - expect(guardSeen).toHaveLength(0); - runner.dispose(); - }); - - it("fails fast when GraphHttpReply is configured without a matching ingress binding", () => { - const g = graph(); - const reply = g.node>([], null, { - name: "http.reply-only.out", - }); - class Controller { - post() {} - } - GraphHttpReply(reply, { bindingId: "http.reply-only.out" })(Controller.prototype, "post", { - value: Controller.prototype.post, - }); - const runner = createNestGraphBoundaryRunner(); - - expect(() => runner.run(Controller, "post", { requestId: "req-reply-only" })).toThrow( - /requires at least one ingress/, - ); - runner.dispose(); - }); - - it("does not emit ingress when high-level reply attach fails", () => { - const g = graph(); - const req = fromNestReq<{ readonly requestId: string }, { readonly ok: true }>(g, { - bindingId: "node.duplicate.in", - payload: () => ({ ok: true }), - }); - const reply = g.node>([], null, { - name: "http.duplicate.out", - }); - const seen: unknown[] = []; - const unsubscribe = req.node.subscribe((msg) => { - if (msg[0] === "DATA") seen.push(msg[1]); - }); - class Controller { - post() {} - } - GraphReq(req, { bindingId: "http.duplicate.in" })(Controller.prototype, "post", { - value: Controller.prototype.post, - }); - GraphHttpReply(reply, { bindingId: "http.duplicate.out" })(Controller.prototype, "post", { - value: Controller.prototype.post, - }); - const runner = createNestGraphBoundaryRunner(); - const pending = runner.run(Controller, "post", { requestId: "req-dup" }); - if (pending) pending.catch(() => undefined); - - expect(() => runner.run(Controller, "post", { requestId: "req-dup" })).toThrow( - /duplicate pending/, - ); - expect(seen).toHaveLength(1); - unsubscribe(); - runner.dispose(); - }); - - it("resolves inherited Nest boundary metadata for subclass controllers", async () => { - const g = graph(); - const req = fromNestReq<{ readonly requestId: string }, { readonly ok: true }>(g, { - bindingId: "node.inherited.in", - payload: () => ({ ok: true }), - }); - const reply = g.node>( - [req.node], - (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ readonly ok: true }>; - if (envelope.requestId === undefined) return; - ctx.down([ - [ - "DATA", - { - requestId: envelope.requestId, - bindingId: "http.inherited.out", - version: 1, - payload: envelope.payload, - }, - ], - ]); - }, - { name: "http.inherited.out" }, - ); - class BaseController { - post() {} - } - class ChildController extends BaseController {} - GraphReq(req, { bindingId: "http.inherited.in" })(BaseController.prototype, "post", { - value: BaseController.prototype.post, - }); - GraphHttpReply(reply, { bindingId: "http.inherited.out" })(BaseController.prototype, "post", { - value: BaseController.prototype.post, - }); - const runner = createNestGraphBoundaryRunner(); - - await expect( - runner.run(ChildController, "post", { requestId: "req-inherited" }), - ).resolves.toEqual({ ok: true }); - runner.dispose(); - }); - - it("derives a default request id in the high-level interceptor for plain Nest HTTP requests", async () => { - const g = graph(); - const req = fromNestReq<{ readonly requestId: string }, { readonly ok: true }>(g, { - bindingId: "node.default-interceptor.in", - payload: () => ({ ok: true }), - }); - const reply = g.node>( - [req.node], - (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ readonly ok: true }>; - if (envelope.requestId === undefined) return; - ctx.down([ - [ - "DATA", - { - requestId: envelope.requestId, - bindingId: "http.default-interceptor.out", - version: 1, - payload: envelope.payload, - }, - ], - ]); - }, - { name: "http.default-interceptor.out" }, - ); - class Controller { - post() {} - } - GraphReq(req, { bindingId: "http.default-interceptor.in" })(Controller.prototype, "post", { - value: Controller.prototype.post, - }); - GraphHttpReply(reply, { bindingId: "http.default-interceptor.out" })( - Controller.prototype, - "post", - { value: Controller.prototype.post }, - ); - const interceptor = createNestGraphBoundaryInterceptor(); - const context = { - getClass: () => Controller, - getHandler: () => Controller.prototype.post, - switchToHttp: () => ({ - getRequest: () => ({ headers: { "x-request-id": "header-req" } }), - }), - }; - - await expect(interceptor.intercept(context)).resolves.toEqual({ ok: true }); - interceptor.dispose(); - }); - - it("ignores lifecycle-only metadata in the interceptor phase bridge", () => { - const g = graph(); - const lifecycle = fromNestLifecycle(g, { - bindingId: "node.lifecycle.only", - payload: () => ({ event: "teardown" }), - }); - const seen: NestBoundaryEnvelope<{ readonly event: string }>[] = []; - const unsubscribe = lifecycle.node.subscribe((msg) => { - if (msg[0] === "DATA") seen.push(msg[1] as NestBoundaryEnvelope<{ readonly event: string }>); - }); - class Controller { - teardown() {} - } - GraphLifecycle(lifecycle, { bindingId: "lifecycle.only" })(Controller.prototype, "teardown", { - value: Controller.prototype.teardown, - }); - const interceptor = createNestGraphBoundaryInterceptor(); - const context = { - getClass: () => Controller, - getHandler: () => Controller.prototype.teardown, - switchToHttp: () => ({ - getRequest: () => ({ headers: {} }), - }), - }; - - expect(interceptor.intercept(context, { handle: () => "next" })).toBe("next"); - expect(seen).toEqual([]); - unsubscribe(); - interceptor.dispose(); - }); - - it("guards Nest HTTP pending lifecycle and low-level diagnostic retention", () => { - const g = graph(); - const egress = g.node>([], null, { - name: "nestjs/http/guarded.out", - }); - const http = toNestHttp(egress, { - bindingId: "orders.http", - maxDiagnostics: 2, - }); - const handle = { resolve: vi.fn(), reject: vi.fn() }; - - http.attach({ requestId: "req-1", handle }); - expect(() => http.attach({ requestId: "req-1", handle })).toThrow(/duplicate pending/); - - egress.down([ - [ - "DATA", - { requestId: "stale-1", bindingId: "orders.http", version: 1, payload: { ok: false } }, - ], - [ - "DATA", - { requestId: "stale-2", bindingId: "orders.http", version: 1, payload: { ok: false } }, - ], - [ - "DATA", - { requestId: "stale-3", bindingId: "orders.http", version: 1, payload: { ok: false } }, - ], - ]); - - expect(http.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual([ - "stale-egress", - "stale-egress", - ]); - const cleanup = http.attach({ requestId: "req-2", handle }); - expect(cleanup()).toBe(true); - expect(cleanup()).toBe(false); - http.dispose(); - expect(() => http.attach({ requestId: "req-3", handle })).toThrow(/disposed/); - }); - - it("rejects pending Nest HTTP handles on terminal egress and dispose", () => { - const g = graph(); - const egress = g.node>([], null, { - name: "nestjs/http/terminal.out", - }); - const http = toNestHttp(egress, { bindingId: "orders.http" }); - const terminalHandle = { resolve: vi.fn(), reject: vi.fn() }; - const disposeHandle = { resolve: vi.fn(), reject: vi.fn() }; - - http.attach({ requestId: "req-terminal", handle: terminalHandle }); - egress.down([["ERROR", new Error("egress closed")]]); - - expect(terminalHandle.resolve).not.toHaveBeenCalled(); - expect(terminalHandle.reject).toHaveBeenCalledTimes(1); - expect(http.pendingCount()).toBe(0); - expect(http.diagnostics().map((diagnostic) => diagnostic.kind)).toContain("terminal-egress"); - - const terminalCleanup = http.attach({ requestId: "req-after-terminal", handle: disposeHandle }); - http.dispose(); - - expect(disposeHandle.resolve).not.toHaveBeenCalled(); - expect(disposeHandle.reject).toHaveBeenCalledTimes(1); - expect(terminalCleanup()).toBe(false); - expect(http.pendingCount()).toBe(0); - expect(http.diagnostics().map((diagnostic) => diagnostic.kind)).not.toContain( - "dispose-pending", - ); - - const disposeEgress = g.node>([], null, { - name: "nestjs/http/dispose.out", - }); - const disposeHttp = toNestHttp(disposeEgress, { bindingId: "orders.dispose" }); - const liveDisposeHandle = { resolve: vi.fn(), reject: vi.fn() }; - - disposeHttp.attach({ requestId: "req-dispose", handle: liveDisposeHandle }); - disposeHttp.dispose(); - - expect(liveDisposeHandle.resolve).not.toHaveBeenCalled(); - expect(liveDisposeHandle.reject).toHaveBeenCalledTimes(1); - expect(disposeHttp.pendingCount()).toBe(0); - expect(disposeHttp.diagnostics().map((diagnostic) => diagnostic.kind)).toContain( - "dispose-pending", - ); - }); - - it("rejects non-data Nest boundary payload material on ingress and egress", () => { - const g = graph(); - const req = fromNestReq(g, { bindingId: "orders.strict", maxPayloadBytes: 24 }); - const sparse = [] as unknown[]; - sparse[1] = "hole"; - const hidden = { ok: true } as { ok: boolean; runtime?: unknown }; - Object.defineProperty(hidden, "runtime", { - enumerable: false, - value: () => undefined, - }); - const accessorArray: unknown[] = []; - Object.defineProperty(accessorArray, "0", { - enumerable: true, - get() { - throw new Error("getter executed"); - }, - }); - - expect(() => req.emit({ requestId: "req-1" }, { payload: sparse })).toThrow(/sparse/); - expect(() => req.emit({ requestId: "req-1" }, { payload: accessorArray })).toThrow( - /enumerable plain data/, - ); - expect(() => req.emit({ requestId: "req-1" }, { payload: hidden })).toThrow( - /enumerable plain data/, - ); - expect(() => - req.emit({ requestId: "req-1" }, { payload: { text: "this is too large" } }), - ).toThrow(/exceeds/); - expect(() => req.emit({ requestId: "req-1" }, { payload: Number.NaN })).toThrow(/finite/); - expect(() => fromNestReq(g, { bindingId: "bad.version", version: 0 })).toThrow(/must be 1/); - expect(() => fromNestReq(g, { bindingId: "future.version", version: 2 })).toThrow(/must be 1/); - expect(() => req.emit({ requestId: "req-1" }, { version: Number.NaN, payload: null })).toThrow( - /must be 1/, - ); - - const egress = g.node>([], null, { - name: "nestjs/http/strict.out", - }); - const http = toNestHttp(egress, { bindingId: "orders.strict", maxPayloadBytes: 24 }); - const handle = { resolve: vi.fn(), reject: vi.fn() }; - - http.attach({ requestId: "req-1", handle }); - egress.down([ - ["DATA", { bindingId: "orders.strict", version: 1, payload: { ok: true } }], - ["DATA", { requestId: "req-1", bindingId: "orders.strict", version: 1, payload: undefined }], - [ - "DATA", - { - requestId: "req-1", - bindingId: "orders.strict", - version: 1, - payload: { socket: () => undefined }, - }, - ], - [ - "DATA", - { - requestId: "req-1", - bindingId: "orders.strict", - version: 2, - payload: { ok: true }, - }, - ], - ]); - - expect(handle.resolve).not.toHaveBeenCalled(); - expect(handle.reject).toHaveBeenCalledTimes(1); - expect(http.pendingCount()).toBe(0); - expect(http.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual([ - "malformed-egress", - "malformed-egress", - "malformed-egress", - "malformed-egress", - ]); - http.dispose(); - }); - - it("guards Nest ingress payloads against host runtime objects", () => { - const g = graph(); - const req = fromNestReq(g, { bindingId: "orders.raw" }); - - expect(() => - req.emit({ requestId: "req-1" }, { payload: { body: "ok", response: () => undefined } }), - ).toThrow(/data-only/); - expect(() => - req.emit({ requestId: "req-1" }, { payload: { socket: new Map() } }), - ).toThrow(/plain data object/); - }); - - it("lets binding-level payload and requestId override factory defaults", () => { - const g = graph(); - const req = fromNestReq<{ requestId: string; body: { value: string } }, { value: string }>(g, { - bindingId: "node.shared.in", - payload: () => ({ value: "factory" }), - requestId: "factory-req", - }); - const seen: unknown[] = []; - req.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - class Controller { - a() {} - b() {} - } - GraphReq(req, { - bindingId: "route.a", - payload: (host) => host.body, - requestId: (host) => host.requestId, - order: 2, - })(Controller.prototype, "a", { value: Controller.prototype.a }); - GraphReq(req, { - bindingId: "route.b", - payload: () => ({ value: "binding-b" }), - requestId: "route-b-req", - order: 1, - })(Controller.prototype, "b", { value: Controller.prototype.b }); - - const runner = createNestGraphBoundaryRunner(); - runner.run(Controller, "a", { requestId: "route-a-req", body: { value: "binding-a" } }); - runner.run(Controller, "b", { requestId: "ignored", body: { value: "ignored" } }); - - expect(seen).toEqual([ - { - bindingId: "route.a", - version: 1, - requestId: "route-a-req", - payload: { value: "binding-a" }, - }, - { - bindingId: "route.b", - version: 1, - requestId: "route-b-req", - payload: { value: "binding-b" }, - }, - ]); - expect(getNestBoundaryBindings(Controller, "a")[0]).toMatchObject({ - bindingId: "route.a", - order: 2, - }); - runner.dispose(); - }); - - it("records GraphFilter, GraphError sugar, GraphGuardDecision, and lowerers", () => { - const g = graph(); - const error = fromNestError(g, { bindingId: "node.error.in" }); - const guard = fromNestGuard(g, { bindingId: "node.guard.in" }); - const decision = g.node>([], null); - class Controller { - filtered() {} - guarded() {} - } - - GraphFilter(error, { bindingId: "filter.generic", mode: "observe", order: 1 })( - Controller.prototype, - "filtered", - { value: Controller.prototype.filtered }, - ); - GraphError(error, { bindingId: "filter.error", mode: "handle", order: 2 })( - Controller.prototype, - "filtered", - { value: Controller.prototype.filtered }, - ); - GraphGuard(guard, { bindingId: "guard.in" })(Controller.prototype, "guarded", { - value: Controller.prototype.guarded, - }); - GraphGuardDecision(decision, { bindingId: "guard.out" })(Controller.prototype, "guarded", { - value: Controller.prototype.guarded, - }); - - expect( - getNestBoundaryBindings(Controller, "filtered").map((binding) => binding.bindingId), - ).toEqual(["filter.generic", "filter.error"]); - expect(getNestBoundaryBindings(Controller, "guarded").map((binding) => binding.kind)).toEqual([ - "guard", - "guard-decision", - ]); - - const httpIssue: HttpDataIssue = { - kind: "issue", - code: "orders.closed", - message: "Orders are closed.", - status: 409, - body: { ok: false }, - headers: { "x-graphrefly-issue": "orders.closed" }, - }; - expect(issueResponse(httpIssue)).toEqual({ - status: 409, - body: { ok: false }, - headers: { "x-graphrefly-issue": "orders.closed" }, - }); - expect(lowerHttpReplyPayload({ status: 202, body: { ok: true } }, {})).toEqual({ - status: 202, - body: { ok: true }, - }); - expect(lowerHttpReplyPayload({ kind: "issue", code: "bad", message: "Bad" }, {})).toEqual({ - status: 400, - body: { code: "bad", message: "Bad" }, - }); - expect(protocolError(new Error("secret")).status).toBe(500); - expect( - lowerProtocolError("boom", {}, { protocolError: () => ({ status: 599, body: "masked" }) }), - ).toEqual({ status: 599, body: "masked" }); - }); - - it("exports Nest-native provider bridge objects without leaking them through the generic barrel", () => { - expect(provideGraphBoundaryInterceptor()).toMatchObject({ provide: expect.anything() }); - expect(provideGraphGuard()).toMatchObject({ provide: expect.anything() }); - expect(typeof createGraphExceptionFilter({ target: () => undefined }).catch).toBe("function"); - expect(provideGraphExceptionFilter({ target: () => undefined })).toMatchObject({ - provide: expect.anything(), - }); - const guardDeniedProvider = provideGraphGuardDeniedFilter(); - expect(createGraphGuardDeniedFilter()).toBeInstanceOf(GraphGuardDeniedFilter); - expect(guardDeniedProvider).toBe(GraphGuardDeniedFilter); - expect(guardDeniedProvider).not.toBe(APP_FILTER); - expect(provideGraphExceptionFilter({ target: () => undefined }).provide).not.toBe(APP_FILTER); - expect(provideGraphCronScheduler({ targets: [] })).toMatchObject({ - provide: expect.any(Symbol), - }); - expect(provideGraphLifecycleHooks({ targets: [] })).toMatchObject({ - provide: expect.any(Symbol), - }); - expect("GraphReq" in ({} as typeof import("../adapters/index.js"))).toBe(false); - }); - - it("builds explicit native provider bundles without scanning or creating graphs", () => { - const target = () => ({ target: class Target {}, methodKey: "handle" }); - const httpProviders = provideGraphNativeHttpProviders({ - boundaryInterceptor: { host: () => ({ requestId: "req-1" }) }, - guard: {}, - exceptionFilter: { target }, - }); - - expect( - httpProviders.map((provider) => - typeof provider === "function" ? provider : provider.provide, - ), - ).toEqual([APP_INTERCEPTOR, APP_GUARD, GraphGuardDeniedFilter, expect.any(Symbol)]); - expect(provideGraphNativeHttpProviders({ guardDeniedFilter: false })).toHaveLength(2); - - class Controller { - tick() {} - stop() {} - } - const cronTarget = graphCronTarget(Controller, "tick", { - expr: "* * * * *", - timezone: "UTC", - target: class WrongCronTarget {}, - methodKey: "wrong", - } as Parameters[2]); - const lifecycleTarget = graphLifecycleTarget(Controller, "stop", { - event: "module-destroy", - target: class WrongLifecycleTarget {}, - methodKey: "wrong", - }); - const nativeProviders = provideGraphNativeProviders({ - http: false, - cronScheduler: { targets: [cronTarget] }, - lifecycleHooks: { targets: [lifecycleTarget] }, - }); - - expect(cronTarget).toMatchObject({ target: Controller, methodKey: "tick" }); - expect(lifecycleTarget).toMatchObject({ target: Controller, methodKey: "stop" }); - expect(nativeProviders).toHaveLength(2); - expect(nativeProviders.every((provider) => typeof provider !== "function")).toBe(true); - }); - - it("builds D495 focused WebSocket and message provider bundles over explicit bridge options", async () => { - vi.useFakeTimers(); - try { - const g = graph(); - const diagnostics = fromNestDiagnostics(g, { - bindingId: "node.transport.bundle.diagnostics", - }); - const seenDiagnostics: unknown[] = []; - diagnostics.node.subscribe((msg) => { - if (msg[0] === "DATA") seenDiagnostics.push((msg[1] as NestBoundaryEnvelope).payload); - }); - - const wsIngress = fromNestWs(g, { - bindingId: "node.bundle.ws.in", - payload: (host: { readonly payload: unknown }) => host.payload, - }); - const wsReply = g.node>([], null, { - name: "bundle.ws.reply", - }); - class BundleGateway { - handle() {} - } - GraphWs(wsIngress, { - bindingId: "bundle.ws.in", - requestId: (host: { readonly requestId: string }) => host.requestId, - payload: (host: { readonly payload: unknown }) => host.payload, - })(BundleGateway.prototype, "handle", { value: BundleGateway.prototype.handle }); - GraphWsReply(wsReply, { bindingId: "bundle.ws.reply" })(BundleGateway.prototype, "handle", { - value: BundleGateway.prototype.handle, - }); - - const wsProviders = provideGraphWsProviders({ - bridge: { - diagnosticBoundary: diagnostics, - maxDiagnostics: 1, - timeoutMs: 20, - }, - }); - expect(wsProviders.map((provider) => provider.provide)).toEqual([GRAPHREFLY_NEST_WS_BRIDGE]); - expect(provideGraphWsProviders({ bridge: false })).toEqual([]); - const wsProvider = wsProviders[0]; - if (wsProvider === undefined || !("useValue" in wsProvider)) { - throw new Error("Expected GraphWs provider bundle to return an explicit useValue provider"); - } - const wsBridge = wsProvider.useValue as GraphWsBridge<{ - readonly requestId: string; - readonly payload: unknown; - }>; - const wsPending = wsBridge.handleMessage(BundleGateway, "handle", { - requestId: "req-bundle-ws", - payload: { ok: true }, - }); - vi.advanceTimersByTime(20); - await expect(wsPending).rejects.toThrow(/timed out/); - expect(wsBridge.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual(["timeout"]); - - const messageIngress = fromNestMessage(g, { - bindingId: "node.bundle.message.in", - payload: (host: { readonly payload: unknown }) => host.payload, - }); - const messageReply = g.node>([], null, { - name: "bundle.message.reply", - }); - class BundleMessageController { - handle() {} - } - GraphMessage(messageIngress, { - bindingId: "bundle.message.in", - requestId: (host: { readonly requestId: string }) => host.requestId, - payload: (host: { readonly payload: unknown }) => host.payload, - })(BundleMessageController.prototype, "handle", { - value: BundleMessageController.prototype.handle, - }); - GraphMessageReply(messageReply, { bindingId: "bundle.message.reply" })( - BundleMessageController.prototype, - "handle", - { value: BundleMessageController.prototype.handle }, - ); - - const messageProviders = provideGraphMessageProviders({ - bridge: { - diagnosticBoundary: diagnostics, - maxDiagnostics: 1, - timeoutMs: 20, - }, - }); - expect(messageProviders.map((provider) => provider.provide)).toEqual([ - GRAPHREFLY_NEST_MESSAGE_BRIDGE, - ]); - expect(provideGraphMessageProviders({ bridge: false })).toEqual([]); - const messageProvider = messageProviders[0]; - if (messageProvider === undefined || !("useValue" in messageProvider)) { - throw new Error( - "Expected GraphMessage provider bundle to return an explicit useValue provider", - ); - } - const messageBridge = messageProvider.useValue as GraphMessageBridge<{ - readonly requestId: string; - readonly payload: unknown; - }>; - const messagePending = messageBridge.handleMessage(BundleMessageController, "handle", { - requestId: "req-bundle-message", - payload: { ok: true }, - }); - vi.advanceTimersByTime(20); - await expect(messagePending).rejects.toThrow(/timed out/); - expect(messageBridge.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual(["timeout"]); - - expect(seenDiagnostics).toEqual([ - expect.objectContaining({ - kind: "timeout", - phase: "ws", - requestId: "req-bundle-ws", - }), - expect.objectContaining({ - kind: "timeout", - phase: "message", - requestId: "req-bundle-message", - }), - ]); - wsBridge.dispose(); - messageBridge.dispose(); - } finally { - vi.useRealTimers(); - } - }); - - it("emits graph-visible Nest diagnostics only through an explicit sanitized boundary", () => { - const g = graph(); - const diagnostics = fromNestDiagnostics(g, { - bindingId: "node.nest.diagnostics", - phase: "http", - }); - const seen: unknown[] = []; - diagnostics.node.subscribe((msg) => { - if (msg[0] === "DATA") seen.push((msg[1] as NestBoundaryEnvelope).payload); - }); - const reply = g.node>([], null, { - name: "nestjs/diagnostic/reply", - }); - const hiddenHandle = { socket: { id: "raw" }, callback: () => undefined }; - const error = Object.assign(new Error("private failure"), hiddenHandle); - const http = toNestHttp(reply, { - bindingId: "http.diagnostics.out", - diagnosticBoundary: diagnostics, - }); - http.attach({ - requestId: "req-diagnostic", - bindingId: "http.diagnostics.out", - handle: { - resolve: vi.fn(), - reject: vi.fn(), - }, - }); - - reply.down([["ERROR", error]]); - - expect(http.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual(["terminal-egress"]); - expect(seen).toEqual([ - { - kind: "terminal-egress", - phase: "http", - bindingId: "http.diagnostics.out", - message: "toNestHttp(http.diagnostics.out) rejected pending requests after ERROR", - error: { name: "Error", message: "private failure" }, - }, - ]); - expect(JSON.stringify(seen)).not.toContain("socket"); - expect(JSON.stringify(seen)).not.toContain("callback"); - }); - - it("keeps host cleanup alive when explicit diagnostic ingress rejects DATA", () => { - const g = graph(); - const diagnostics = fromNestDiagnostics(g, { - bindingId: "node.nest.tight-diagnostics", - maxPayloadBytes: 1, - }); - const reply = g.node>([], null); - const reject = vi.fn(); - const http = toNestHttp(reply, { - bindingId: "http.tight-diagnostics.out", - diagnosticBoundary: diagnostics, - }); - http.attach({ - requestId: "req-tight", - bindingId: "http.tight-diagnostics.out", - handle: { - resolve: vi.fn(), - reject, - }, - }); - - expect(() => - reply.down([["ERROR", new Error("too large for diagnostic ingress")]]), - ).not.toThrow(); - expect(reject).toHaveBeenCalledOnce(); - expect(http.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual(["terminal-egress"]); - }); - - it("passes sanitized diagnostics into structural diagnostic boundaries", () => { - const g = graph(); - const reply = g.node>([], null); - const emitted: Array<{ readonly host: unknown; readonly payload: unknown }> = []; - const diagnosticBoundary: NestDiagnosticIngressBoundary = { - kind: "diagnostics", - bindingId: "custom.diagnostics", - version: 1, - node: fromNestDiagnostics(g).node, - envelope(host, opts) { - return { - bindingId: opts?.bindingId ?? "custom.diagnostics", - version: opts?.version ?? 1, - payload: opts?.payload ?? host, - }; - }, - emit(host, opts) { - emitted.push({ host, payload: opts?.payload }); - return this.envelope(host, opts); - }, - }; - const http = toNestHttp(reply, { - bindingId: "http.custom-diagnostics.out", - diagnosticBoundary, - }); - - reply.down([["ERROR", Object.assign(new Error("masked"), { socket: { id: "raw" } })]]); - - expect(http.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual(["terminal-egress"]); - expect(emitted).toEqual([ - { - host: { - kind: "terminal-egress", - phase: "http", - bindingId: "http.custom-diagnostics.out", - message: "toNestHttp(http.custom-diagnostics.out) rejected pending requests after ERROR", - error: { name: "Error", message: "masked" }, - }, - payload: { - kind: "terminal-egress", - phase: "http", - bindingId: "http.custom-diagnostics.out", - message: "toNestHttp(http.custom-diagnostics.out) rejected pending requests after ERROR", - error: { name: "Error", message: "masked" }, - }, - }, - ]); - expect(JSON.stringify(emitted)).not.toContain("socket"); - }); - - it("keeps Nest diagnostics as host snapshots by default", () => { - const g = graph(); - const diagnostics = fromNestDiagnostics(g, { bindingId: "node.unwired.diagnostics" }); - const seen: unknown[] = []; - diagnostics.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - const reply = g.node>([], null); - const http = toNestHttp(reply, { bindingId: "http.host-snapshot.out" }); - - reply.down([ - [ - "DATA", - { - requestId: "stale", - bindingId: "http.host-snapshot.out", - version: 1, - payload: { ok: true }, - }, - ], - ]); - - expect(http.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual(["stale-egress"]); - expect(seen).toEqual([]); - expect(sanitizeNestDiagnostic({ kind: "timeout", message: "late", error: "deadline" })).toEqual( - { - kind: "timeout", - phase: "adapter", - message: "late", - error: { message: "deadline" }, - }, - ); - expect( - sanitizeNestDiagnostic({ - kind: "timeout", - message: "opaque", - error: { - get message() { - throw new Error("hostile getter"); - }, - toString() { - throw new Error("hostile toString"); - }, - }, - }), - ).toEqual({ - kind: "timeout", - phase: "adapter", - message: "opaque", - error: { message: "opaque diagnostic error" }, - }); - function hiddenCallback() { - return "host-private source"; - } - hiddenCallback.toString = () => { - throw new Error("hostile function toString"); - }; - expect( - sanitizeNestDiagnostic({ - kind: "timeout", - message: "function", - error: hiddenCallback, - }), - ).toEqual({ - kind: "timeout", - phase: "adapter", - message: "function", - error: { message: "opaque diagnostic function" }, - }); - }); - - it("targeted guard-denial filter rethrows ordinary exceptions", () => { - const filter = createGraphGuardDeniedFilter(); - const host = { - switchToHttp: () => ({ - getResponse: () => ({ - status: vi.fn(), - json: vi.fn(), - }), - }), - } as Parameters[1]; - - expect(() => filter.catch(new Error("ordinary"), host)).toThrow("ordinary"); - const denial = new GraphGuardDeniedException({ status: 403, body: { denied: true } }); - expect(isGraphGuardDeniedException(denial)).toBe(true); - }); - - it("native guard provider consumes GraphGuard and GraphGuardDecision metadata", async () => { - const g = graph(); - const guard = fromNestGuard< - { readonly requestId: string; readonly allow: boolean }, - { allow: boolean } - >(g, { bindingId: "node.native.guard.in" }); - const decision = g.node>([guard.node], (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ allow: boolean }>; - if (envelope.requestId === undefined) return; - ctx.down([ - [ - "DATA", - { - requestId: envelope.requestId, - bindingId: "native.guard.out", - version: 1, - payload: envelope.payload.allow - ? { kind: "allow" } - : { - kind: "deny", - status: 409, - body: { accepted: false }, - headers: { "x-graphrefly-guard": "denied" }, - }, - }, - ], - ]); - }); - class Controller { - guarded() {} - } - GraphGuard(guard, { - bindingId: "native.guard.in", - payload: (host) => ({ allow: host.allow }), - requestId: (host) => host.requestId, - })(Controller.prototype, "guarded", { value: Controller.prototype.guarded }); - GraphGuardDecision(decision, { bindingId: "native.guard.out" })( - Controller.prototype, - "guarded", - { - value: Controller.prototype.guarded, - }, - ); - const bridge = provideGraphGuard({ - host: () => ({ requestId: "req-allow", allow: true }), - requestId: (host) => host.requestId, - }).useValue as { canActivate(context: unknown): Promise; onModuleDestroy(): void }; - const context = { - getClass: () => Controller, - getHandler: () => Controller.prototype.guarded, - switchToHttp: () => ({ getRequest: () => ({}) }), - }; - - await expect(bridge.canActivate(context)).resolves.toBe(true); - const denyBridge = provideGraphGuard({ - host: () => ({ requestId: "req-deny", allow: false }), - requestId: (host) => host.requestId, - }).useValue as typeof bridge; - try { - await denyBridge.canActivate(context); - throw new Error("expected guard denial to throw"); - } catch (error) { - expect(isGraphGuardDeniedException(error)).toBe(true); - expect((error as { getStatus?: () => number }).getStatus?.()).toBe(409); - expect((error as { getResponse?: () => unknown }).getResponse?.()).toEqual({ - accepted: false, - }); - const headers: Record = {}; - const statuses: number[] = []; - const bodies: unknown[] = []; - const guardHost = { - switchToHttp: () => ({ - getResponse: () => ({ - setHeader(name: string, value: string) { - headers[name] = value; - }, - status(value: number) { - statuses.push(value); - }, - json(value: unknown) { - bodies.push(value); - return value; - }, - }), - }), - } as Parameters[1]; - createGraphGuardDeniedFilter().catch(error, guardHost); - expect(headers).toEqual({ "x-graphrefly-guard": "denied" }); - expect(statuses).toEqual([409]); - expect(bodies).toEqual([{ accepted: false }]); - } - bridge.onModuleDestroy(); - denyBridge.onModuleDestroy(); - }); - - it("native guard await mode correlates a later decision with adapter-owned identity", async () => { - const g = graph({ name: "nestjs-guard-await-test" }); - const scope = createNestGraphGuardAwaitScope(); - const guard = fromNestGuard<{ readonly traceId: string }, { readonly traceId: string }>(g, { - bindingId: "node.native.guard.await.in", - }); - const decision = g.state>({ - requestId: "initial-unused", - bindingId: "native.guard.await.out", - version: 1, - payload: { kind: "deny" }, - }); - let invocationId: string | undefined; - guard.node.subscribe((msg) => { - if (msg[0] !== "DATA") return; - const envelope = msg[1] as NestBoundaryEnvelope<{ readonly traceId: string }>; - invocationId = envelope.requestId; - expect(envelope.payload.traceId).toBe("caller-trace-id"); - expect(envelope.requestId).not.toBe(envelope.payload.traceId); - expect(scope.lookupAbortSignal(envelope.requestId ?? "")?.aborted).toBe(false); - queueMicrotask(() => { - decision.set({ - requestId: envelope.requestId ?? "missing", - bindingId: "native.guard.await.out", - version: 1, - payload: { kind: "allow" }, - }); - }); - }); - class Controller { - guarded() {} - } - GraphGuard(guard, { - bindingId: "native.guard.await.in", - payload: (host) => ({ traceId: host.traceId }), - requestId: (host) => host.traceId, - })(Controller.prototype, "guarded", { value: Controller.prototype.guarded }); - GraphGuardDecision(decision, { bindingId: "native.guard.await.out" })( - Controller.prototype, - "guarded", - { value: Controller.prototype.guarded }, - ); - const bridge = provideGraphGuard({ - host: () => ({ traceId: "caller-trace-id" }), - decisionWait: { mode: "await", timeoutMs: 100, maxPending: 1, scope }, - }).useValue as { canActivate(context: unknown): Promise; onModuleDestroy(): void }; - const context = { - getClass: () => Controller, - getHandler: () => Controller.prototype.guarded, - switchToHttp: () => ({ getRequest: () => ({}) }), - }; - - await expect(bridge.canActivate(context)).resolves.toBe(true); - expect(invocationId).toMatch(/^graphrefly:nest-guard:/); - expect(scope.lookupAbortSignal(invocationId ?? "missing")).toBeUndefined(); - expect(g.topology().nodes).toHaveLength(2); - bridge.onModuleDestroy(); - scope.dispose(); - }); - - it("native guard await mode bounds timeout, overload, host abort, and disposal", async () => { - vi.useFakeTimers(); - try { - const g = graph(); - const scope = createNestGraphGuardAwaitScope(); - const guard = fromNestGuard(g, { bindingId: "node.native.guard.pending.in" }); - const decision = g.node>([], null); - class Controller { - guarded() {} - } - GraphGuard(guard, { bindingId: "native.guard.pending.in" })(Controller.prototype, "guarded", { - value: Controller.prototype.guarded, - }); - GraphGuardDecision(decision, { bindingId: "native.guard.pending.out" })( - Controller.prototype, - "guarded", - { value: Controller.prototype.guarded }, - ); - const abortController = new AbortController(); - const bridge = provideGraphGuard({ - decisionWait: { - mode: "await", - timeoutMs: 20, - maxPending: 1, - scope, - hostAbortSignal: () => abortController.signal, - }, - }).useValue as { canActivate(context: unknown): Promise; onModuleDestroy(): void }; - const context = { - getClass: () => Controller, - getHandler: () => Controller.prototype.guarded, - switchToHttp: () => ({ getRequest: () => ({}) }), - }; - - const pending = bridge.canActivate(context); - await expect(bridge.canActivate(context)).rejects.toMatchObject({ - getStatus: expect.any(Function), - }); - abortController.abort(); - await expect(pending).resolves.toBe(false); - - const timeoutBridge = provideGraphGuard({ - decisionWait: { mode: "await", timeoutMs: 20, maxPending: 1, scope }, - }).useValue as typeof bridge; - const timedOut = timeoutBridge.canActivate(context); - vi.advanceTimersByTime(20); - await expect(timedOut).resolves.toBe(false); - - const disposedPending = timeoutBridge.canActivate(context); - timeoutBridge.onModuleDestroy(); - await expect(disposedPending).resolves.toBe(false); - await expect(timeoutBridge.canActivate(context)).resolves.toBe(false); - timeoutBridge.onModuleDestroy(); - bridge.onModuleDestroy(); - scope.dispose(); - } finally { - vi.useRealTimers(); - } - }); - - it("native guard provider correlates each guard binding with its own request id", async () => { - const g = graph(); - type GuardHost = { - readonly leftRequestId: string; - readonly rightRequestId: string; - }; - const leftGuard = fromNestGuard(g, { - bindingId: "node.native.guard.left.in", - }); - const rightGuard = fromNestGuard(g, { - bindingId: "node.native.guard.right.in", - }); - const decision = g.node>( - [leftGuard.node, rightGuard.node], - (ctx) => { - const envelopes = [depLatest(ctx, 0), depLatest(ctx, 1)] as Array< - NestBoundaryEnvelope<{ readonly side: "left" | "right" }> | undefined - >; - const messages = envelopes - .filter( - (envelope): envelope is NestBoundaryEnvelope<{ readonly side: "left" | "right" }> => - envelope?.requestId !== undefined, - ) - .map( - (envelope) => - [ - "DATA", - { - requestId: envelope.requestId, - bindingId: "native.guard.multi.out", - version: 1, - payload: { kind: "allow", metadata: { side: envelope.payload.side } }, - }, - ] as const, - ); - if (messages.length > 0) ctx.down(messages); - }, - ); - class Controller { - guarded() {} - } - GraphGuard(leftGuard, { - bindingId: "native.guard.left.in", - payload: () => ({ side: "left" }), - requestId: (host) => host.leftRequestId, - })(Controller.prototype, "guarded", { value: Controller.prototype.guarded }); - GraphGuard(rightGuard, { - bindingId: "native.guard.right.in", - payload: () => ({ side: "right" }), - requestId: (host) => host.rightRequestId, - })(Controller.prototype, "guarded", { value: Controller.prototype.guarded }); - GraphGuardDecision(decision, { bindingId: "native.guard.multi.out" })( - Controller.prototype, - "guarded", - { value: Controller.prototype.guarded }, - ); - const bridge = provideGraphGuard({ - host: () => ({ leftRequestId: "req-left", rightRequestId: "req-right" }), - requestId: () => "provider-fallback", - }).useValue as { canActivate(context: unknown): Promise; onModuleDestroy(): void }; - const context = { - getClass: () => Controller, - getHandler: () => Controller.prototype.guarded, - switchToHttp: () => ({ getRequest: () => ({}) }), - }; - - await expect(bridge.canActivate(context)).resolves.toBe(true); - bridge.onModuleDestroy(); - }); - - it("native guard denial lowers HttpDataIssue headers through the targeted filter", async () => { - const g = graph(); - const guard = fromNestGuard<{ readonly requestId: string }, { readonly apiKey: string }>(g, { - bindingId: "node.native.guard.issue.in", - }); - const issue: HttpDataIssue = { - kind: "issue", - code: "orders.forbidden", - message: "Orders require a valid key.", - status: 451, - body: { accepted: false, code: "orders.forbidden" }, - headers: { "x-graphrefly-issue": "orders.forbidden" }, - }; - const decision = g.node>([guard.node], (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ readonly apiKey: string }>; - if (envelope.requestId === undefined) return; - ctx.down([ - [ - "DATA", - { - requestId: envelope.requestId, - bindingId: "native.guard.issue.out", - version: 1, - payload: { kind: "deny", issue }, - }, - ], - ]); - }); - class Controller { - guarded() {} - } - GraphGuard(guard, { - bindingId: "native.guard.issue.in", - payload: () => ({ apiKey: "bad" }), - requestId: (host) => host.requestId, - })(Controller.prototype, "guarded", { value: Controller.prototype.guarded }); - GraphGuardDecision(decision, { bindingId: "native.guard.issue.out" })( - Controller.prototype, - "guarded", - { value: Controller.prototype.guarded }, - ); - const bridge = provideGraphGuard({ - host: () => ({ requestId: "req-issue" }), - requestId: (host) => host.requestId, - }).useValue as { canActivate(context: unknown): Promise; onModuleDestroy(): void }; - const context = { - getClass: () => Controller, - getHandler: () => Controller.prototype.guarded, - switchToHttp: () => ({ getRequest: () => ({}) }), - }; - const statuses: number[] = []; - const bodies: unknown[] = []; - const headers: Record = {}; - const host = { - switchToHttp: () => ({ - getResponse: () => ({ - header(name: string, value: string) { - headers[name] = value; - }, - status(value: number) { - statuses.push(value); - }, - json(value: unknown) { - bodies.push(value); - return value; - }, - }), - }), - } as Parameters[1]; - - await expect(bridge.canActivate(context)).rejects.toBeInstanceOf(GraphGuardDeniedException); - try { - await bridge.canActivate(context); - throw new Error("expected guard denial to throw"); - } catch (error) { - createGraphGuardDeniedFilter().catch(error, host); - } - - expect(statuses).toEqual([451]); - expect(headers).toEqual({ "x-graphrefly-issue": "orders.forbidden" }); - expect(bodies).toEqual([{ accepted: false, code: "orders.forbidden" }]); - bridge.onModuleDestroy(); - }); - - it("native guard decision protocol ERROR uses binding-level protocol-error lowering", async () => { - const g = graph(); - const guard = fromNestGuard<{ readonly requestId: string }, { readonly value: string }>(g, { - bindingId: "node.native.guard.protocol.in", - }); - const decision = g.node>([guard.node], (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ readonly value: string }>; - if (envelope.requestId === undefined) return; - ctx.down([["ERROR", new Error(`secret:${envelope.payload.value}`)]]); - }); - class Controller { - guarded() {} - } - GraphGuard(guard, { - bindingId: "native.guard.protocol.in", - payload: () => ({ value: "hidden" }), - requestId: (host) => host.requestId, - })(Controller.prototype, "guarded", { value: Controller.prototype.guarded }); - GraphGuardDecision(decision, { - bindingId: "native.guard.protocol.out", - protocolError: () => ({ - status: 599, - body: { code: "guard.binding.protocol", message: "binding wins" }, - }), - })(Controller.prototype, "guarded", { value: Controller.prototype.guarded }); - const bridge = provideGraphGuard({ - host: () => ({ requestId: "req-guard-protocol" }), - protocolError: () => ({ - status: 598, - body: { code: "guard.provider.protocol", message: "provider loses" }, - }), - requestId: (host) => host.requestId, - }).useValue as { canActivate(context: unknown): Promise; onModuleDestroy(): void }; - const context = { - getClass: () => Controller, - getHandler: () => Controller.prototype.guarded, - switchToHttp: () => ({ getRequest: () => ({}) }), - }; - - try { - await bridge.canActivate(context); - throw new Error("expected guard protocol error to throw"); - } catch (error) { - expect(isGraphGuardDeniedException(error)).toBe(false); - expect((error as { getStatus?: () => number }).getStatus?.()).toBe(599); - expect((error as { getResponse?: () => unknown }).getResponse?.()).toEqual({ - code: "guard.binding.protocol", - message: "binding wins", - }); - } - bridge.onModuleDestroy(); - }); - - it("native exception filter handles GraphError with HTTP DATA lowering", async () => { - const g = graph(); - const errorIn = fromNestError< - { readonly requestId: string; readonly exception: Error }, - { message: string } - >(g, { bindingId: "node.native.error.in" }); - const errorOut = g.node>( - [errorIn.node], - (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ message: string }>; - if (envelope.requestId === undefined) return; - ctx.down([ - [ - "DATA", - { - requestId: envelope.requestId, - bindingId: "native.error.out", - version: 1, - payload: { status: 418, body: { message: envelope.payload.message } }, - }, - ], - ]); - }, - ); - class Controller { - handled() {} - } - GraphError(errorIn, { - bindingId: "native.error.in", - payload: (host) => ({ message: host.exception.message }), - requestId: (host) => host.requestId, - })(Controller.prototype, "handled", { value: Controller.prototype.handled }); - GraphHttpReply(errorOut, { bindingId: "native.error.out" })(Controller.prototype, "handled", { - value: Controller.prototype.handled, - }); - const statuses: number[] = []; - const bodies: unknown[] = []; - const filter = provideGraphExceptionFilter({ - target: () => ({ target: Controller, methodKey: "handled" }), - host: (_host, exception) => ({ - requestId: "req-error", - exception: exception instanceof Error ? exception : new Error(String(exception)), - }), - requestId: (host) => host.requestId, - }).useValue as { - catch(exception: unknown, host: unknown): Promise; - onModuleDestroy(): void; - }; - const host = { - switchToHttp: () => ({ - getRequest: () => ({}), - getResponse: () => ({ - status(value: number) { - statuses.push(value); - }, - json(value: unknown) { - bodies.push(value); - return value; - }, - }), - }), - }; - - await filter.catch(new Error("handled"), host); - - expect(statuses).toEqual([418]); - expect(bodies).toEqual([{ message: "handled" }]); - filter.onModuleDestroy(); - }); - - it("native exception filter lowers reply protocol ERROR through the safe 500 fallback", () => { - const g = graph(); - const errorIn = fromNestError< - { readonly requestId: string; readonly exception: Error }, - { message: string } - >(g, { bindingId: "node.native.error.protocol.in" }); - const errorOut = g.node>([errorIn.node], (ctx) => { - const envelope = depLatest(ctx, 0) as NestBoundaryEnvelope<{ message: string }>; - if (envelope.requestId === undefined) return; - ctx.down([["ERROR", new Error(`secret:${envelope.payload.message}`)]]); - }); - class Controller { - handled() {} - } - GraphError(errorIn, { - bindingId: "native.error.protocol.in", - payload: (host) => ({ message: host.exception.message }), - requestId: (host) => host.requestId, - })(Controller.prototype, "handled", { value: Controller.prototype.handled }); - GraphHttpReply(errorOut, { bindingId: "native.error.protocol.out" })( - Controller.prototype, - "handled", - { value: Controller.prototype.handled }, - ); - const statuses: number[] = []; - const bodies: unknown[] = []; - const filter = createGraphExceptionFilter({ - target: () => ({ target: Controller, methodKey: "handled" }), - host: (_host, exception) => ({ - requestId: "req-error-protocol", - exception: exception instanceof Error ? exception : new Error(String(exception)), - }), - requestId: (host) => host.requestId, - }) as { catch(exception: unknown, host: unknown): unknown; onModuleDestroy(): void }; - const host = { - switchToHttp: () => ({ - getRequest: () => ({}), - getResponse: () => ({ - status(value: number) { - statuses.push(value); - }, - json(value: unknown) { - bodies.push(value); - return value; - }, - }), - }), - }; - - filter.catch(new Error("handled"), host); - - expect(statuses).toEqual([500]); - expect(bodies).toEqual([ - { code: "graphrefly.protocol_error", message: "GraphReFly reply pipeline failed" }, - ]); - filter.onModuleDestroy(); - }); - - it("native exception filter lowers directly when no handling filter has a request id", () => { - const g = graph(); - const errorIn = fromNestError<{ readonly exception: Error }, { message: string }>(g, { - bindingId: "node.native.error.no-request.in", - }); - class Controller { - handled() {} - } - GraphError(errorIn, { - bindingId: "native.error.no-request.in", - payload: (host) => ({ message: host.exception.message }), - })(Controller.prototype, "handled", { value: Controller.prototype.handled }); - const statuses: number[] = []; - const bodies: unknown[] = []; - const filter = createGraphExceptionFilter({ - target: () => ({ target: Controller, methodKey: "handled" }), - host: (_host, exception) => ({ - exception: exception instanceof Error ? exception : new Error(String(exception)), - }), - }) as { catch(exception: unknown, host: unknown): unknown; onModuleDestroy(): void }; - const host = { - switchToHttp: () => ({ - getRequest: () => ({}), - getResponse: () => ({ - status(value: number) { - statuses.push(value); - }, - json(value: unknown) { - bodies.push(value); - return value; - }, - }), - }), - }; - - filter.catch(new Error("handled"), host); - - expect(statuses).toEqual([500]); - expect(bodies).toEqual([ - { code: "graphrefly.protocol_error", message: "GraphReFly reply pipeline failed" }, - ]); - filter.onModuleDestroy(); - }); - - it("native exception filter emits request-correlated observe filters before direct lowering", () => { - const g = graph(); - const errorIn = fromNestError< - { readonly requestId: string; readonly exception: Error }, - { readonly message: string } - >(g, { - bindingId: "node.native.error.observe.in", - }); - const seen: string[] = []; - errorIn.node.subscribe((msg) => { - if (msg[0] === "DATA") seen.push(msg[1].bindingId); - }); - class Controller { - handled() {} - } - GraphFilter(errorIn, { - bindingId: "native.error.observe.in", - mode: "observe", - payload: (host) => ({ message: host.exception.message }), - requestId: (host) => host.requestId, - order: 1, - })(Controller.prototype, "handled", { value: Controller.prototype.handled }); - GraphError(errorIn, { - bindingId: "native.error.handle.in", - payload: (host) => ({ message: host.exception.message }), - requestId: (host) => host.requestId, - order: 2, - })(Controller.prototype, "handled", { value: Controller.prototype.handled }); - const statuses: number[] = []; - const filter = createGraphExceptionFilter({ - target: () => ({ target: Controller, methodKey: "handled" }), - host: (_host, exception) => ({ - requestId: "err-observe", - exception: exception instanceof Error ? exception : new Error(String(exception)), - }), - }) as { catch(exception: unknown, host: unknown): unknown; onModuleDestroy(): void }; - const host = { - switchToHttp: () => ({ - getRequest: () => ({}), - getResponse: () => ({ - status(value: number) { - statuses.push(value); - }, - json(value: unknown) { - return value; - }, - }), - }), - }; - - filter.catch(new Error("handled"), host); - - expect(seen).toEqual(["native.error.observe.in", "native.error.handle.in"]); - expect(statuses).toEqual([500]); - filter.onModuleDestroy(); - }); - - it("native websocket bridge correlates ack/reply by requestId and bindingId without handle DATA", async () => { - const g = graph(); - const ingress = fromNestWs< - { - readonly requestId: string; - readonly body: string; - readonly socket: unknown; - readonly ack: unknown; - }, - { readonly body: string } - >(g, { bindingId: "node.ws.orders.in" }); - const ack = g.node>([], null, { - name: "nestjs/ws/orders.ack", - }); - const reply = g.node>([], null, { - name: "nestjs/ws/orders.reply", - }); - const seen: NestBoundaryEnvelope[] = []; - ingress.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - class Gateway { - handle() {} - } - GraphWs(ingress, { - bindingId: "ws.orders.in", - requestId: (host) => host.requestId, - payload: (host) => ({ body: host.body }), - })(Gateway.prototype, "handle", { value: Gateway.prototype.handle }); - GraphWsAck(ack, { bindingId: "ws.orders.ack" })(Gateway.prototype, "handle", { - value: Gateway.prototype.handle, - }); - GraphWsReply(reply, { bindingId: "ws.orders.reply" })(Gateway.prototype, "handle", { - value: Gateway.prototype.handle, - }); - const ackFn = vi.fn(); - const bridge = createGraphWsBridge({ - ack: (host) => host.ack as (payload: unknown) => void, - }); - - const result = bridge.handleMessage(Gateway, "handle", { - requestId: "req-ws-1", - body: "create", - socket: { send: vi.fn() }, - ack: ackFn, - }); - - expect(seen).toEqual([ - { - bindingId: "ws.orders.in", - version: 1, - requestId: "req-ws-1", - payload: { body: "create" }, - }, - ]); - expect(JSON.stringify(seen[0])).not.toContain("socket"); - expect(JSON.stringify(seen[0])).not.toContain("ack"); - - ack.down([ - [ - "DATA", - { - bindingId: "ws.orders.ack", - version: 1, - requestId: "req-ws-1", - payload: { accepted: true }, - }, - ], - ]); - expect(ackFn).toHaveBeenCalledWith({ accepted: true }, expect.any(Object)); - reply.down([ - [ - "DATA", - { - bindingId: "ws.orders.reply", - version: 1, - requestId: "req-ws-1", - payload: { ok: true }, - }, - ], - ]); - - await expect(result).resolves.toEqual({ ok: true }); - expect(bridge.diagnostics()).toEqual([]); - bridge.dispose(); - }); - - it("native websocket bridge diagnoses wrong/stale/malformed/terminal egress and timeout cleanup", async () => { - vi.useFakeTimers(); - try { - const g = graph(); - const ingress = fromNestWs(g, { - bindingId: "node.ws.strict.in", - payload: (host: { readonly payload: unknown }) => host.payload, - }); - const reply = g.node>([], null, { - name: "nestjs/ws/strict.reply", - }); - class Gateway { - handle() {} - } - GraphWs(ingress, { - bindingId: "ws.strict.in", - requestId: (host: { readonly requestId: string }) => host.requestId, - payload: (host: { readonly payload: unknown }) => host.payload, - })(Gateway.prototype, "handle", { value: Gateway.prototype.handle }); - GraphWsReply(reply, { bindingId: "ws.strict.reply" })(Gateway.prototype, "handle", { - value: Gateway.prototype.handle, - }); - const bridge = createGraphWsBridge({ timeoutMs: 20 }); - const terminal = bridge.handleMessage(Gateway, "handle", { - requestId: "req-ws-terminal", - payload: { ok: true }, - }); - reply.down([ - [ - "DATA", - { - bindingId: "ws.other.reply", - version: 1, - requestId: "req-ws-terminal", - payload: { wrong: true }, - }, - ], - [ - "DATA", - { - bindingId: "ws.strict.reply", - version: 1, - requestId: "req-stale", - payload: { stale: true }, - }, - ], - [ - "DATA", - { - bindingId: "ws.strict.reply", - version: 1, - requestId: "req-ws-terminal", - payload: { socket: () => undefined }, - }, - ], - ["COMPLETE"], - ]); - await expect(terminal).rejects.toThrow(/data-only/); - expect(bridge.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual([ - "binding-mismatch", - "stale-egress", - "malformed-egress", - "terminal-egress", - ]); - bridge.dispose(); - - const timeoutReply = g.node>([], null, { - name: "nestjs/ws/timeout.reply", - }); - class TimeoutGateway { - handle() {} - } - GraphWs(ingress, { - bindingId: "ws.strict.in", - requestId: (host: { readonly requestId: string }) => host.requestId, - payload: (host: { readonly payload: unknown }) => host.payload, - })(TimeoutGateway.prototype, "handle", { value: TimeoutGateway.prototype.handle }); - GraphWsReply(timeoutReply, { bindingId: "ws.timeout.reply" })( - TimeoutGateway.prototype, - "handle", - { value: TimeoutGateway.prototype.handle }, - ); - const timeoutBridge = createGraphWsBridge({ timeoutMs: 20 }); - const timeout = timeoutBridge.handleMessage(TimeoutGateway, "handle", { - requestId: "req-ws-timeout", - payload: { ok: true }, - }); - vi.advanceTimersByTime(20); - await expect(timeout).rejects.toThrow(/timed out/); - expect(timeoutBridge.diagnostics().map((diagnostic) => diagnostic.kind)).toEqual(["timeout"]); - timeoutBridge.dispose(); - } finally { - vi.useRealTimers(); - } - }); - - it("native websocket bridge cleans earlier pending registrations when terminal setup settles", async () => { - const g = graph(); - const ingress = fromNestWs(g, { - bindingId: "node.ws.cleanup.in", - payload: (host: { readonly payload: unknown }) => host.payload, - }); - const ack = g.node>([], null, { - name: "nestjs/ws/cleanup.ack", - }); - const terminalReply = g.node>([], null, { - name: "nestjs/ws/cleanup.terminal", - }); - const seen: NestBoundaryEnvelope[] = []; - ingress.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - class Gateway { - handle() {} - } - GraphWs(ingress, { - bindingId: "ws.cleanup.in", - requestId: (host: { readonly requestId: string }) => host.requestId, - payload: (host: { readonly payload: unknown }) => host.payload, - })(Gateway.prototype, "handle", { value: Gateway.prototype.handle }); - GraphWsAck(ack, { bindingId: "ws.cleanup.ack" })(Gateway.prototype, "handle", { - value: Gateway.prototype.handle, - }); - GraphWsReply(terminalReply, { bindingId: "ws.cleanup.terminal" })(Gateway.prototype, "handle", { - value: Gateway.prototype.handle, - }); - const ackFn = vi.fn(); - const bridge = createGraphWsBridge({ - ack: (host: { readonly ack: (payload: unknown) => void }) => host.ack, - }); - terminalReply.down([["COMPLETE"]]); - - await expect( - bridge.handleMessage(Gateway, "handle", { - requestId: "req-ws-cleanup", - payload: { ok: true }, - ack: ackFn, - socket: {}, - }), - ).rejects.toThrow(); - expect(seen.filter((entry) => entry.requestId === "req-ws-cleanup")).toEqual([]); - - ack.down([ - [ - "DATA", - { - bindingId: "ws.cleanup.ack", - version: 1, - requestId: "req-ws-cleanup", - payload: { accepted: true }, - }, - ], - ]); - expect(ackFn).not.toHaveBeenCalled(); - expect(bridge.diagnostics().map((diagnostic) => diagnostic.kind)).toContain("stale-egress"); - bridge.dispose(); - }); - - it("native websocket bridge rejects unsafe defaults and cleans up on disconnect/dispose", async () => { - const g = graph(); - const rawIngress = fromNestWs(g, { bindingId: "node.ws.raw.in" }); - const safeIngress = fromNestWs(g, { - bindingId: "node.ws.safe.in", - payload: (host: { readonly payload: unknown }) => host.payload, - }); - const reply = g.node>([], null, { - name: "nestjs/ws/lifecycle.reply", - }); - const seen: NestBoundaryEnvelope[] = []; - rawIngress.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - safeIngress.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - class RawGateway { - handle() {} - } - class SafeGateway { - handle() {} - } - GraphWs(rawIngress, { - bindingId: "ws.raw.in", - requestId: (host: { readonly requestId: string }) => host.requestId, - })(RawGateway.prototype, "handle", { value: RawGateway.prototype.handle }); - GraphWs(safeIngress, { - bindingId: "ws.safe.in", - requestId: (host: { readonly requestId: string }) => host.requestId, - payload: (host: { readonly payload: unknown }) => host.payload, - })(SafeGateway.prototype, "handle", { value: SafeGateway.prototype.handle }); - GraphWsReply(reply, { bindingId: "ws.lifecycle.reply" })(SafeGateway.prototype, "handle", { - value: SafeGateway.prototype.handle, - }); - const bridge = createGraphWsBridge(); - expect(() => - bridge.handleMessage(RawGateway, "handle", { - requestId: "req-ws-raw", - socket: { id: "socket-1" }, - }), - ).toThrow(/payload selector/); - expect(seen).toEqual([]); - - const socket = { id: "socket-2" }; - const pending = bridge.handleMessage(SafeGateway, "handle", { - requestId: "req-ws-disconnect", - payload: { ok: true }, - socket, - }); - bridge.handleDisconnect(socket); - await expect(pending).rejects.toThrow(/disconnected/); - expect(bridge.diagnostics().map((diagnostic) => diagnostic.kind)).toContain("dispose-pending"); - - bridge.dispose(); - expect(() => - bridge.handleMessage(SafeGateway, "handle", { - requestId: "req-ws-after-dispose", - payload: { ok: true }, - socket: {}, - }), - ).toThrow(/disposed/); - expect(seen.filter((entry) => entry.requestId === "req-ws-after-dispose")).toEqual([]); - }); - - it("native message bridge correlates replies and dispose cleanup without message-context DATA", async () => { - const g = graph(); - const ingress = fromNestMessage< - { readonly requestId: string; readonly message: string; readonly context: unknown }, - { readonly message: string } - >(g, { bindingId: "node.message.orders.in" }); - const reply = g.node>([], null, { - name: "nestjs/message/orders.reply", - }); - const seen: NestBoundaryEnvelope[] = []; - ingress.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - class Controller { - handle() {} - } - GraphMessage(ingress, { - bindingId: "message.orders.in", - requestId: (host) => host.requestId, - payload: (host) => ({ message: host.message }), - })(Controller.prototype, "handle", { value: Controller.prototype.handle }); - GraphMessageReply(reply, { bindingId: "message.orders.reply" })( - Controller.prototype, - "handle", - { value: Controller.prototype.handle }, - ); - const bridge = createGraphMessageBridge(); - const result = bridge.handleMessage(Controller, "handle", { - requestId: "req-message-1", - message: "reserve", - context: { ack: vi.fn() }, - }); - - expect(seen).toEqual([ - { - bindingId: "message.orders.in", - version: 1, - requestId: "req-message-1", - payload: { message: "reserve" }, - }, - ]); - expect(JSON.stringify(seen[0])).not.toContain("context"); - reply.down([ - [ - "DATA", - { - bindingId: "message.orders.reply", - version: 1, - requestId: "req-message-1", - payload: { result: "ok" }, - }, - ], - ]); - await expect(result).resolves.toEqual({ result: "ok" }); - - const pending = bridge.handleMessage(Controller, "handle", { - requestId: "req-message-dispose", - message: "reserve", - context: {}, - }); - bridge.dispose(); - await expect(pending).rejects.toThrow(/disposed/); - expect(bridge.diagnostics().map((diagnostic) => diagnostic.kind)).toContain("dispose-pending"); - }); - - it("native message bridge rejects unsafe defaults and suppresses ingress after terminal reply setup", async () => { - const g = graph(); - const rawIngress = fromNestMessage(g, { bindingId: "node.message.raw.in" }); - const safeIngress = fromNestMessage(g, { - bindingId: "node.message.safe.in", - payload: (host: { readonly payload: unknown }) => host.payload, - }); - const terminalReply = g.node>([], null, { - name: "nestjs/message/terminal.reply", - }); - const seen: NestBoundaryEnvelope[] = []; - rawIngress.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - safeIngress.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - class RawController { - handle() {} - } - class SafeController { - handle() {} - } - GraphMessage(rawIngress, { - bindingId: "message.raw.in", - requestId: (host: { readonly requestId: string }) => host.requestId, - })(RawController.prototype, "handle", { value: RawController.prototype.handle }); - GraphMessage(safeIngress, { - bindingId: "message.safe.in", - requestId: (host: { readonly requestId: string }) => host.requestId, - payload: (host: { readonly payload: unknown }) => host.payload, - })(SafeController.prototype, "handle", { value: SafeController.prototype.handle }); - GraphMessageReply(terminalReply, { bindingId: "message.terminal.reply" })( - SafeController.prototype, - "handle", - { value: SafeController.prototype.handle }, - ); - const bridge = createGraphMessageBridge(); - expect(() => - bridge.handleMessage(RawController, "handle", { - requestId: "req-message-raw", - context: { pattern: "orders" }, - }), - ).toThrow(/payload selector/); - expect(seen).toEqual([]); - - terminalReply.down([["COMPLETE"]]); - await expect( - bridge.handleMessage(SafeController, "handle", { - requestId: "req-message-terminal", - payload: { ok: true }, - }), - ).rejects.toThrow(); - expect(seen.filter((entry) => entry.requestId === "req-message-terminal")).toEqual([]); - bridge.dispose(); - expect(() => - bridge.handleMessage(SafeController, "handle", { - requestId: "req-message-after-dispose", - payload: { ok: true }, - }), - ).toThrow(/disposed/); - }); - - it("native message bridge cleans earlier pending registrations when terminal setup settles", async () => { - const g = graph(); - const ingress = fromNestMessage(g, { - bindingId: "node.message.cleanup.in", - payload: (host: { readonly payload: unknown }) => host.payload, - }); - const firstReply = g.node>([], null, { - name: "nestjs/message/cleanup.first", - }); - const terminalReply = g.node>([], null, { - name: "nestjs/message/cleanup.terminal", - }); - const seen: NestBoundaryEnvelope[] = []; - ingress.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - class Controller { - handle() {} - } - GraphMessage(ingress, { - bindingId: "message.cleanup.in", - requestId: (host: { readonly requestId: string }) => host.requestId, - payload: (host: { readonly payload: unknown }) => host.payload, - })(Controller.prototype, "handle", { value: Controller.prototype.handle }); - GraphMessageReply(firstReply, { bindingId: "message.cleanup.first" })( - Controller.prototype, - "handle", - { value: Controller.prototype.handle }, - ); - GraphMessageReply(terminalReply, { bindingId: "message.cleanup.terminal" })( - Controller.prototype, - "handle", - { value: Controller.prototype.handle }, - ); - const bridge = createGraphMessageBridge(); - terminalReply.down([["COMPLETE"]]); - - await expect( - bridge.handleMessage(Controller, "handle", { - requestId: "req-message-cleanup", - payload: { ok: true }, - }), - ).rejects.toThrow(); - expect(seen.filter((entry) => entry.requestId === "req-message-cleanup")).toEqual([]); - - firstReply.down([ - [ - "DATA", - { - bindingId: "message.cleanup.first", - version: 1, - requestId: "req-message-cleanup", - payload: { ok: true }, - }, - ], - ]); - expect(bridge.diagnostics().map((diagnostic) => diagnostic.kind)).toContain("stale-egress"); - bridge.dispose(); - }); - - it("native cron provider starts and stops timers while emitting GraphCron ingress", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-01-05T08:30:00.000Z")); - const g = graph(); - const cron = fromNestCron<{ readonly timestamp_ns: string }, { tick: string }>(g, { - bindingId: "node.native.cron.in", - }); - const seen: unknown[] = []; - cron.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - class Controller { - tick() {} - } - GraphCron(cron, { - bindingId: "native.cron.in", - payload: (host) => ({ tick: host.timestamp_ns }), - })(Controller.prototype, "tick", { value: Controller.prototype.tick }); - const scheduler = provideGraphCronScheduler({ - targets: [ - { - target: Controller, - methodKey: "tick", - expr: "30 8 * * 1", - tickMs: 1000, - timezone: "UTC", - }, - ], - }).useValue as { onModuleInit(): void; onModuleDestroy(): void }; - - scheduler.onModuleInit(); - - expect(seen).toHaveLength(1); - expect(vi.getTimerCount()).toBe(1); - scheduler.onModuleDestroy(); - expect(vi.getTimerCount()).toBe(0); - vi.useRealTimers(); - }); - - it("native cron provider dedupes by current wall-clock minute without blocking later days", () => { - vi.useFakeTimers(); - try { - vi.setSystemTime(new Date("2026-01-05T08:30:00.000Z")); - const g = graph(); - const cron = fromNestCron<{ readonly timestamp_ms: number }, { readonly tick: number }>(g, { - bindingId: "node.native.cron.daily.in", - }); - const seen: unknown[] = []; - cron.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - class Controller { - tick() {} - } - GraphCron(cron, { - bindingId: "native.cron.daily.in", - payload: (host) => ({ tick: host.timestamp_ms }), - })(Controller.prototype, "tick", { value: Controller.prototype.tick }); - const scheduler = provideGraphCronScheduler({ - targets: [ - { - target: Controller, - methodKey: "tick", - expr: "30 8 * * *", - tickMs: 1000, - timezone: "UTC", - }, - ], - }).useValue as { onModuleInit(): void; onModuleDestroy(): void }; - - scheduler.onModuleInit(); - vi.advanceTimersByTime(30_000); - vi.setSystemTime(new Date("2026-01-06T08:30:00.000Z")); - vi.advanceTimersByTime(1_000); - scheduler.onModuleDestroy(); - - expect(seen).toHaveLength(2); - } finally { - vi.useRealTimers(); - } - }); - - it("manual cron controller checks current time deterministically without catch-up DATA", () => { - const g = graph(); - const cron = fromNestCron<{ readonly timestamp_ms: number }, { readonly tick: number }>(g, { - bindingId: "node.native.cron.manual.in", - }); - const seen: unknown[] = []; - cron.node.subscribe((msg) => msg[0] === "DATA" && seen.push(msg[1])); - class Controller { - tick() {} - } - GraphCron(cron, { - bindingId: "native.cron.manual.in", - payload: (host) => ({ tick: host.timestamp_ms }), - })(Controller.prototype, "tick", { value: Controller.prototype.tick }); - const controller = createGraphCronController({ - targets: [ - graphCronTarget(Controller, "tick", { - expr: "30 8 * * 1", - timezone: "UTC", - }), - ], - }); - - controller.check(new Date("2026-01-05T08:29:00.000Z")); - controller.check(new Date("2026-01-05T08:30:00.000Z")); - controller.check(new Date("2026-01-05T08:30:59.000Z")); - controller.check(new Date("2026-01-12T08:30:00.000Z")); - - expect(seen).toEqual([ - { - bindingId: "native.cron.manual.in", - version: 1, - payload: { tick: Date.parse("2026-01-05T08:30:00.000Z") }, - }, - { - bindingId: "native.cron.manual.in", - version: 1, - payload: { tick: Date.parse("2026-01-12T08:30:00.000Z") }, - }, - ]); - expect(() => - createGraphCronController({ - targets: [graphCronTarget(Controller, "tick", { expr: "0 30 8 * * 1" })], - }), - ).toThrow(/expected 5 fields/); - }); - - it("native cron provider rolls back timers when module init fails partway", () => { - vi.useFakeTimers(); - try { - vi.setSystemTime(new Date("2026-01-05T08:30:00.000Z")); - class Controller { - tick() {} - bad() {} - } - const scheduler = provideGraphCronScheduler({ - targets: [ - { target: Controller, methodKey: "tick", expr: "* * * * *", tickMs: 1000 }, - { target: Controller, methodKey: "bad", expr: "* * * * *", tickMs: 0 }, - ], - }).useValue as { onModuleInit(): void; onModuleDestroy(): void }; - - expect(() => scheduler.onModuleInit()).toThrow(/tickMs/); - expect(vi.getTimerCount()).toBe(0); - scheduler.onModuleDestroy(); - } finally { - vi.useRealTimers(); - } - }); -}); diff --git a/packages/ts/src/__tests__/framework-adapters.test.ts b/packages/ts/src/__tests__/framework-adapters.test.ts index abafa8f7..5770c52d 100644 --- a/packages/ts/src/__tests__/framework-adapters.test.ts +++ b/packages/ts/src/__tests__/framework-adapters.test.ts @@ -1,9 +1,8 @@ import { describe, expect, expectTypeOf, it } from "vitest"; -import { nodeWritable } from "../adapters/svelte.js"; import { graph } from "../graph/index.js"; import { type BoundaryCapabilityRef, boundaryManifest } from "../inspection/boundary.js"; -describe("D238 framework adapter subpaths", () => { +describe("framework-neutral boundary projection", () => { it("derives a framework-neutral boundary manifest from describe topology", () => { const g = graph({ name: "boundary" }); const amount = g.state(0, { name: "amount" }); @@ -100,16 +99,4 @@ describe("D238 framework adapter subpaths", () => { expect.objectContaining({ id: "config-form" }), ); }); - - it("does not write undefined as ordinary DATA through writable helpers", () => { - const g = graph(); - const count = g.state(1); - const writable = nodeWritable(count); - - expect(() => { - (writable.set as (value: undefined) => void)(undefined); - }).toThrow(/SENTINEL\/no DATA/); - - expect(count.cache).toBe(1); - }); }); diff --git a/packages/ts/src/__tests__/solutions-reactive-layout.reactive-layout-solution-d181-part-01.test.ts b/packages/ts/src/__tests__/solutions-reactive-layout.reactive-layout-solution-d181-part-01.test.ts index c543001d..eaaf2eab 100644 --- a/packages/ts/src/__tests__/solutions-reactive-layout.reactive-layout-solution-d181-part-01.test.ts +++ b/packages/ts/src/__tests__/solutions-reactive-layout.reactive-layout-solution-d181-part-01.test.ts @@ -694,69 +694,6 @@ describe("reactive-layout solution (D181) — part 1", () => { expect(contextValue.font).toBe("previous font"); nodeCanvasFacts.unsubscribe(); - const concreteGraph = graph({ name: "node-canvas-package-provider" }); - const concreteText = concreteGraph.state("abc", { name: "text" }); - const concreteFont = concreteGraph.state("10px package", { name: "font" }); - let createdCanvas: readonly [number, number] | null = null; - const concreteFacts = collect( - reactiveLayoutNodeCanvas.nodeCanvasPackageTextMeasurements({ - graph: concreteGraph, - text: concreteText, - font: concreteFont, - width: 2, - height: 3, - canvas: { - createCanvas(width, height) { - createdCanvas = [width, height]; - return { - getContext(type) { - expect(type).toBe("2d"); - return { - font: "old", - measureText(segment: string) { - return { - width: segment.length * (this.font.includes("package") ? 5 : 1), - }; - }, - }; - }, - }; - }, - }, - }), - ); - expect(createdCanvas).toEqual([2, 3]); - expect( - ( - data(concreteFacts.messages).at( - -1, - )?.[0] as MeasurementResult - ).value.segments[0]?.width, - ).toBe(15); - concreteFacts.unsubscribe(); - - const failingGraph = graph({ name: "node-canvas-package-failure" }); - const failingFacts = collect( - reactiveLayoutNodeCanvas.nodeCanvasPackageTextMeasurements({ - graph: failingGraph, - text: failingGraph.state("abc", { name: "text" }), - font: failingGraph.state("10px package", { name: "font" }), - canvas: { - createCanvas() { - throw new Error("canvas unavailable"); - }, - }, - }), - ); - expect(data(failingFacts.messages).at(-1)?.[0]).toMatchObject({ - kind: "issue", - code: "measurement.failed", - subjectId: "text", - measurementKind: "text-segments", - }); - expect(failingFacts.messages.some((message) => message[0] === "ERROR")).toBe(false); - failingFacts.unsubscribe(); - const capabilityGraph = graph({ name: "focused-platform-provider" }); const platformText = capabilityGraph.state("xy", { name: "text" }); const platformFont = capabilityGraph.state("font", { name: "font" }); diff --git a/packages/ts/src/__tests__/subpaths.test.ts b/packages/ts/src/__tests__/subpaths.test.ts index 72568191..d3b09fad 100644 --- a/packages/ts/src/__tests__/subpaths.test.ts +++ b/packages/ts/src/__tests__/subpaths.test.ts @@ -1,4 +1,4 @@ -import { readdirSync, readFileSync, statSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, expectTypeOf, it } from "vitest"; @@ -11,15 +11,7 @@ import type { AgenticMemoryRecordsPersistenceHandle, } from "../adapters/index.js"; import * as adapters from "../adapters/index.js"; -import * as nestjsMicroservicesAdapters from "../adapters/nestjs/microservices.js"; -import * as nestjsNativeAdapters from "../adapters/nestjs/native.js"; -import * as nestjsWebsocketsAdapters from "../adapters/nestjs/websockets.js"; -import * as nestjsAdapters from "../adapters/nestjs.js"; import * as observeStorage from "../adapters/observe-storage.js"; -import * as reactAdapters from "../adapters/react.js"; -import * as solidAdapters from "../adapters/solid.js"; -import * as svelteAdapters from "../adapters/svelte.js"; -import * as vueAdapters from "../adapters/vue.js"; import * as committedFacts from "../committed-facts/index.js"; import * as composition from "../composition/index.js"; import * as core from "../core/index.js"; @@ -233,20 +225,6 @@ const exportsJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { exports?: Record; }; -function listTsFiles(dir: string): string[] { - const files: string[] = []; - for (const entry of readdirSync(dir)) { - const path = join(dir, entry); - const stat = statSync(path); - if (stat.isDirectory()) { - files.push(...listTsFiles(path)); - continue; - } - if (entry.endsWith(".ts")) files.push(path); - } - return files; -} - function docsPath(...segments: string[]): string { return join( dirname(fileURLToPath(import.meta.url)), @@ -264,15 +242,7 @@ describe("package subpath barrels (D40/D41 intent parity)", () => { expect(Object.keys(exportsJson.exports ?? {}).sort()).toEqual([ ".", "./adapters", - "./adapters/nestjs", - "./adapters/nestjs/microservices", - "./adapters/nestjs/native", - "./adapters/nestjs/websockets", "./adapters/observe-storage", - "./adapters/react", - "./adapters/solid", - "./adapters/svelte", - "./adapters/vue", "./committed-facts", "./composition", "./core", @@ -444,18 +414,6 @@ describe("package subpath barrels (D40/D41 intent parity)", () => { expect(Object.hasOwn(adapters, "reactExternalStore")).toBe(false); expect(Object.hasOwn(adapters, "svelteReadableStore")).toBe(false); expect(Object.hasOwn(adapters, "svelteWritableStore")).toBe(false); - expect(typeof reactAdapters.useNodeValue).toBe("function"); - expect(typeof reactAdapters.useNodeInput).toBe("function"); - expect(typeof reactAdapters.useNodeRecord).toBe("function"); - expect(typeof vueAdapters.useNodeValue).toBe("function"); - expect(typeof vueAdapters.useNodeInput).toBe("function"); - expect(typeof vueAdapters.useNodeRecord).toBe("function"); - expect(typeof solidAdapters.createNodeValue).toBe("function"); - expect(typeof solidAdapters.createNodeInput).toBe("function"); - expect(typeof solidAdapters.createNodeRecord).toBe("function"); - expect(typeof svelteAdapters.nodeReadable).toBe("function"); - expect(typeof svelteAdapters.nodeWritable).toBe("function"); - expect(typeof svelteAdapters.nodeRecord).toBe("function"); expect(typeof boundaryInspection.boundaryManifest).toBe("function"); expect(typeof adapters.toHttp).toBe("function"); expect(typeof adapters.toProcess).toBe("function"); @@ -471,38 +429,6 @@ describe("package subpath barrels (D40/D41 intent parity)", () => { expect(Object.hasOwn(adapters, "dedupeReducer")).toBe(false); expect(typeof adapters.writableStore).toBe("function"); expect(typeof adapters.zustandStore).toBe("function"); - expect(typeof nestjsAdapters.fromNestReq).toBe("function"); - expect(typeof nestjsAdapters.fromNestGuard).toBe("function"); - expect(typeof nestjsAdapters.fromNestIntercept).toBe("function"); - expect(typeof nestjsAdapters.fromNestError).toBe("function"); - expect(typeof nestjsAdapters.fromNestLifecycle).toBe("function"); - expect(typeof nestjsAdapters.fromNestCron).toBe("function"); - expect(typeof nestjsAdapters.fromNestDiagnostics).toBe("function"); - expect(typeof nestjsAdapters.sanitizeNestDiagnostic).toBe("function"); - expect(typeof nestjsAdapters.toNestHttp).toBe("function"); - expect(typeof nestjsAdapters.GraphFilter).toBe("function"); - expect(typeof nestjsAdapters.GraphGuardDecision).toBe("function"); - expect(typeof nestjsAdapters.getGraphToken).toBe("function"); - expect(typeof nestjsAdapters.getNestBoundaryToken).toBe("function"); - expect(typeof nestjsNativeAdapters.createGraphCronController).toBe("function"); - expect(typeof nestjsNativeAdapters.createNestGraphGuardAwaitScope).toBe("function"); - expect(typeof nestjsNativeAdapters.graphCronTarget).toBe("function"); - expect(typeof nestjsNativeAdapters.graphLifecycleTarget).toBe("function"); - expect(typeof nestjsNativeAdapters.provideGraphBoundaryInterceptor).toBe("function"); - expect(typeof nestjsNativeAdapters.provideGraphGuard).toBe("function"); - expect(typeof nestjsNativeAdapters.createGraphExceptionFilter).toBe("function"); - expect(typeof nestjsNativeAdapters.provideGraphExceptionFilter).toBe("function"); - expect(typeof nestjsNativeAdapters.createGraphGuardDeniedFilter).toBe("function"); - expect(typeof nestjsNativeAdapters.provideGraphGuardDeniedFilter).toBe("function"); - expect(typeof nestjsNativeAdapters.provideGraphCronScheduler).toBe("function"); - expect(typeof nestjsNativeAdapters.provideGraphLifecycleHooks).toBe("function"); - expect(typeof nestjsNativeAdapters.provideGraphNativeHttpProviders).toBe("function"); - expect(typeof nestjsNativeAdapters.provideGraphNativeProviders).toBe("function"); - expect(typeof nestjsNativeAdapters.GRAPHREFLY_NEST_EXCEPTION_FILTER).toBe("symbol"); - expect(typeof nestjsWebsocketsAdapters.fromNestWs).toBe("function"); - expect(typeof nestjsWebsocketsAdapters.provideGraphWsProviders).toBe("function"); - expect(typeof nestjsMicroservicesAdapters.fromNestMessage).toBe("function"); - expect(typeof nestjsMicroservicesAdapters.provideGraphMessageProviders).toBe("function"); expect(typeof observeStorage.attachObserveEventLog).toBe("function"); expect(typeof observeStorage.attachObserveSink).toBe("function"); expect(typeof committedFacts.appendLogCommittedFactJournal).toBe("function"); @@ -1643,7 +1569,6 @@ describe("package subpath barrels (D40/D41 intent parity)", () => { expect(typeof reactiveLayoutBrowser.CanvasMeasureAdapter).toBe("function"); expect(typeof reactiveLayoutBrowser.canvasTextMeasurements).toBe("function"); expect(typeof reactiveLayoutNodeCanvas.nodeCanvasTextMeasurements).toBe("function"); - expect(typeof reactiveLayoutNodeCanvas.nodeCanvasPackageTextMeasurements).toBe("function"); expect(typeof reactiveLayoutSkia.skiaTextMeasurements).toBe("function"); expect(typeof reactiveLayoutSkia.skiaReadyTextMeasurements).toBe("function"); expect(typeof reactiveLayoutSkia.skiaParagraphTextMeasureCapability).toBe("function"); @@ -1682,45 +1607,6 @@ describe("package subpath barrels (D40/D41 intent parity)", () => { expect(Object.hasOwn(solutions, "Graph")).toBe(false); }); - it("keeps D488 NestJS WebSocket and microservice peers in focused subpaths", () => { - const sourceRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); - const nestRoot = join(sourceRoot, "adapters", "nestjs"); - const websocketFile = join(nestRoot, "websockets.ts"); - const microserviceFile = join(nestRoot, "microservices.ts"); - const nestAdapterFiles = [join(sourceRoot, "adapters", "nestjs.ts"), ...listTsFiles(nestRoot)]; - - for (const file of nestAdapterFiles) { - const source = readFileSync(file, "utf8"); - if (file === websocketFile) { - expect(source).toContain("@nestjs/websockets"); - expect(source).not.toContain("@nestjs/microservices"); - continue; - } - if (file === microserviceFile) { - expect(source).toContain("@nestjs/microservices"); - expect(source).not.toContain("@nestjs/websockets"); - continue; - } - expect(source, file).not.toContain("@nestjs/websockets"); - expect(source, file).not.toContain("@nestjs/microservices"); - } - expect(typeof nestjsAdapters.GraphWs).toBe("function"); - expect(typeof nestjsAdapters.GraphMessage).toBe("function"); - expect(typeof nestjsWebsocketsAdapters.createGraphWsBridge).toBe("function"); - expect(typeof nestjsWebsocketsAdapters.provideGraphWsBridge).toBe("function"); - expect(typeof nestjsWebsocketsAdapters.provideGraphWsProviders).toBe("function"); - expect(typeof nestjsWebsocketsAdapters.GraphWsAck).toBe("function"); - expect(typeof nestjsWebsocketsAdapters.GraphWsReply).toBe("function"); - expect(typeof nestjsMicroservicesAdapters.createGraphMessageBridge).toBe("function"); - expect(typeof nestjsMicroservicesAdapters.provideGraphMessageBridge).toBe("function"); - expect(typeof nestjsMicroservicesAdapters.provideGraphMessageProviders).toBe("function"); - expect(typeof nestjsMicroservicesAdapters.GraphMessageReply).toBe("function"); - expect(Object.hasOwn(nestjsAdapters, "provideGraphWsProviders")).toBe(false); - expect(Object.hasOwn(nestjsAdapters, "provideGraphMessageProviders")).toBe(false); - expect(Object.hasOwn(nestjsNativeAdapters, "provideGraphWsProviders")).toBe(false); - expect(Object.hasOwn(nestjsNativeAdapters, "provideGraphMessageProviders")).toBe(false); - }); - it("documents D486 cron misfire and catch-up default skip semantics", () => { const docsPath = join( dirname(fileURLToPath(import.meta.url)), diff --git a/packages/ts/src/adapters/nestjs.ts b/packages/ts/src/adapters/nestjs.ts deleted file mode 100644 index f921990a..00000000 --- a/packages/ts/src/adapters/nestjs.ts +++ /dev/null @@ -1,2118 +0,0 @@ -/** - * Focused NestJS boundary bindings for GraphReFly (D474/D478). - * - * This subpath stays dependency-light: it exposes graph boundary primitives, - * token/provider shapes, and decorator metadata without importing Nest itself. - * User-land Nest modules/controllers bind these helpers to real decorators and - * host lifecycle objects at the framework edge. - */ - -import type { DataIssue } from "../data/index.js"; -import type { Graph } from "../graph/graph.js"; -import { canonicalTupleKey } from "../identity.js"; -import type { Node } from "../node/node.js"; -import type { Message } from "../protocol/messages.js"; - -export const NEST_BOUNDARY_ENVELOPE_VERSION = 1; -export const NEST_BOUNDARY_PAYLOAD_MAX_BYTES = 64 * 1024; -export const NEST_HTTP_DIAGNOSTICS_MAX_RETAINED = 100; - -/** D478 minimal graph-visible transport envelope. Payload must be data-only. */ -export interface NestBoundaryEnvelope { - readonly bindingId: string; - readonly version: number; - readonly payload: T; - readonly requestId?: string; -} - -/** Reply-capable egress must carry host-private request correlation (D478). */ -export type NestReplyEnvelope = NestBoundaryEnvelope & { - readonly requestId: string; -}; - -export type NestBoundaryKind = - | "request" - | "guard" - | "interceptor" - | "error" - | "lifecycle" - | "cron" - | "diagnostics" - | "ws" - | "message"; - -export type NestEgressKind = "http" | "guard-decision" | "ws-ack" | "ws-reply" | "message-reply"; -export type NestFilterMode = "handle" | "observe"; -export type NestDiagnosticPhase = - | "adapter" - | "http" - | "guard" - | "filter" - | "cron" - | "lifecycle" - | "ws" - | "message" - | NestBoundaryKind - | NestEgressKind; - -export interface NestHttpResponsePayload { - readonly status: number; - readonly body?: TBody; - readonly headers?: Record; -} - -export interface HttpDataIssue extends DataIssue { - readonly status: number; - readonly body?: unknown; - readonly headers?: Record; -} - -export type NestIssueResponse = ( - issue: DataIssue, - host: THost, -) => NestHttpResponsePayload; - -export type NestProtocolErrorResponse = ( - errorPayload: unknown, - host: THost, -) => NestHttpResponsePayload; - -export type GraphGuardDecision = - | { - readonly kind: "allow"; - readonly reason?: string; - readonly metadata?: Record; - } - | { - readonly kind: "deny"; - readonly reason?: string; - readonly status?: number; - readonly body?: unknown; - readonly headers?: Record; - readonly issue?: DataIssue | HttpDataIssue; - readonly metadata?: Record; - }; - -export interface NestBoundaryDiagnostic { - readonly kind: - | "binding-mismatch" - | "dispose-pending" - | "malformed-egress" - | "stale-egress" - | "terminal-egress" - | "timeout" - | "resolve-threw" - | "reject-threw"; - readonly phase?: NestDiagnosticPhase; - readonly requestId?: string; - readonly bindingId?: string; - readonly expectedBindingId?: string; - readonly message: string; - readonly error?: unknown; -} - -export interface NestDiagnosticErrorPayload { - readonly name?: string; - readonly message: string; -} - -export interface NestDiagnosticPayload { - readonly kind: NestBoundaryDiagnostic["kind"]; - readonly phase: NestDiagnosticPhase; - readonly requestId?: string; - readonly bindingId?: string; - readonly expectedBindingId?: string; - readonly message: string; - readonly error?: NestDiagnosticErrorPayload; -} - -export interface NestDiagnosticInput extends NestBoundaryDiagnostic { - readonly phase?: NestDiagnosticPhase; -} - -export interface NestDiagnosticsOptions - extends Omit, "payload"> { - readonly phase?: NestDiagnosticPhase; -} - -export type NestDiagnosticIngressBoundary = NestIngressBoundary< - NestDiagnosticInput, - NestDiagnosticPayload ->; - -export interface NestIngressBoundary { - readonly kind: NestBoundaryKind; - readonly bindingId: string; - readonly version: number; - readonly node: Node>; - envelope(host: THost, opts?: NestIngressEmitOptions): NestBoundaryEnvelope; - emit(host: THost, opts?: NestIngressEmitOptions): NestBoundaryEnvelope; -} - -export interface NestIngressOptions { - readonly bindingId?: string; - readonly name?: string; - readonly version?: number; - readonly maxPayloadBytes?: number; - readonly requestId?: string | ((host: THost) => string | undefined); - readonly requireRequestId?: boolean; - readonly payload?: (host: THost) => TPayload; -} - -export interface NestIngressEmitOptions { - readonly requestId?: string; - readonly bindingId?: string; - readonly version?: number; - readonly payload?: TPayload; - readonly requireRequestId?: boolean; -} - -export interface NestReplyResponseHandle { - resolve(payload: TPayload, envelope: NestReplyEnvelope): void; - reject(error: unknown, envelope?: NestReplyEnvelope): void; -} - -export interface NestReplyPendingRegistration { - readonly requestId: string; - readonly handle: NestReplyResponseHandle; - readonly bindingId?: string; -} - -export interface NestReplyBoundary { - readonly kind: NestEgressKind; - readonly bindingId: string; - attach(registration: NestReplyPendingRegistration): () => boolean; - pendingCount(): number; - diagnostics(): readonly NestBoundaryDiagnostic[]; - dispose(): void; -} - -export interface NestHttpResponseHandle extends NestReplyResponseHandle {} - -export interface NestHttpPendingRegistration - extends NestReplyPendingRegistration {} - -export interface NestHttpBoundary extends NestReplyBoundary { - readonly kind: "http"; -} - -export interface ToNestHttpOptions { - readonly bindingId?: string; - readonly diagnosticBoundary?: NestDiagnosticIngressBoundary; - readonly diagnosticPhase?: NestDiagnosticPhase; - readonly maxDiagnostics?: number; - readonly maxPayloadBytes?: number; - readonly name?: string; - readonly transform?: (payload: TPayload, envelope: NestReplyEnvelope) => TPayload; - readonly label?: string; -} - -interface NestReplyPendingEntry { - readonly requestId: string; - readonly bindingId?: string; - readonly handle: NestReplyResponseHandle; -} - -interface NestHttpPendingEntry extends NestReplyPendingEntry {} - -export interface NestBoundaryDecoratorOptions { - readonly bindingId?: string; - readonly payload?: (host: THost) => TPayload; - readonly requestId?: string | ((host: THost) => string | undefined); - readonly order?: number; -} - -export interface NestFilterDecoratorOptions - extends NestBoundaryDecoratorOptions { - readonly mode?: NestFilterMode; - readonly issueResponse?: NestIssueResponse; - readonly protocolError?: NestProtocolErrorResponse; -} - -export interface NestHttpReplyDecoratorOptions { - readonly bindingId: string; - readonly order?: number; - readonly issueResponse?: NestIssueResponse; - readonly protocolError?: NestProtocolErrorResponse; -} - -export interface NestGuardDecisionDecoratorOptions { - readonly bindingId: string; - readonly order?: number; - readonly issueResponse?: NestIssueResponse; - readonly protocolError?: NestProtocolErrorResponse; -} - -export interface NestGraphRunOptions { - readonly requestId?: string | ((host: THost) => string | undefined); -} - -export interface NestGraphBoundaryRunnerOptions { - readonly diagnosticBoundary?: NestDiagnosticIngressBoundary; - readonly diagnosticPhase?: NestDiagnosticPhase; -} - -export interface NestGraphBoundaryRunner { - run( - target: DecoratorHostConstructor | object, - methodKey: string | symbol, - host: THost, - opts?: NestGraphRunOptions, - ): Promise | undefined; - dispose(): void; -} - -export interface NestExecutionContextLike { - getClass(): DecoratorHostConstructor; - getHandler(): DecoratorBoundMethod; - switchToHttp?(): { getRequest(): T }; -} - -export interface NestCallHandlerLike { - handle(): unknown; -} - -export interface NestGraphBoundaryInterceptorOptions - extends NestGraphRunOptions, - NestGraphBoundaryRunnerOptions { - readonly host?: (context: NestExecutionContextLike) => THost; - readonly runner?: NestGraphBoundaryRunner; -} - -export interface NestGraphBoundaryInterceptor { - intercept( - context: NestExecutionContextLike, - next?: NestCallHandlerLike, - ): Promise | unknown; - dispose(): void; -} - -/** Injection token for a root graph singleton. */ -export const GRAPHREFLY_ROOT_GRAPH = Symbol.for("graphrefly:root-graph"); - -/** Injection token for adapter module options. */ -export const GRAPHREFLY_MODULE_OPTIONS = Symbol.for("graphrefly:module-options"); - -/** Injection token for a request-scoped graph. */ -export const GRAPHREFLY_REQUEST_GRAPH = Symbol.for("graphrefly:request-graph"); - -export type DecoratorHostConstructor = abstract new (...args: unknown[]) => unknown; -export type DecoratorBoundMethod = (...args: unknown[]) => unknown; - -export interface OnGraphEventMeta { - nodeName: string; - methodKey: string | symbol; -} - -export interface GraphIntervalMeta { - ms: number; - methodKey: string | symbol; -} - -export interface GraphCronMeta { - expr: string; - methodKey: string | symbol; -} - -export type NestBoundaryBindingDirection = "ingress" | "egress"; - -export interface NestIngressBindingMeta { - readonly direction: "ingress"; - kind: NestBoundaryKind; - bindingId: string; - methodKey: string | symbol; - readonly boundary: NestIngressBoundary; - readonly payload?: (host: unknown) => unknown; - readonly requestId?: string | ((host: unknown) => string | undefined); - readonly order?: number; - readonly mode?: NestFilterMode; - readonly issueResponse?: NestIssueResponse; - readonly protocolError?: NestProtocolErrorResponse; -} - -export interface NestHttpReplyBindingMeta { - readonly direction: "egress"; - readonly kind: "http"; - readonly bindingId: string; - readonly methodKey: string | symbol; - readonly replyNode: Node>; - readonly order?: number; - readonly issueResponse?: NestIssueResponse; - readonly protocolError?: NestProtocolErrorResponse; -} - -export interface NestGuardDecisionBindingMeta { - readonly direction: "egress"; - readonly kind: "guard-decision"; - readonly bindingId: string; - readonly methodKey: string | symbol; - readonly decisionNode: Node>; - readonly order?: number; - readonly issueResponse?: NestIssueResponse; - readonly protocolError?: NestProtocolErrorResponse; -} - -export interface NestWsAckBindingMeta { - readonly direction: "egress"; - readonly kind: "ws-ack"; - readonly bindingId: string; - readonly methodKey: string | symbol; - readonly ackNode: Node>; - readonly order?: number; -} - -export interface NestWsReplyBindingMeta { - readonly direction: "egress"; - readonly kind: "ws-reply"; - readonly bindingId: string; - readonly methodKey: string | symbol; - readonly replyNode: Node>; - readonly order?: number; -} - -export interface NestMessageReplyBindingMeta { - readonly direction: "egress"; - readonly kind: "message-reply"; - readonly bindingId: string; - readonly methodKey: string | symbol; - readonly replyNode: Node>; - readonly order?: number; -} - -export type NestBoundaryBindingMeta = - | NestIngressBindingMeta - | NestHttpReplyBindingMeta - | NestGuardDecisionBindingMeta - | NestWsAckBindingMeta - | NestWsReplyBindingMeta - | NestMessageReplyBindingMeta; - -export const EVENT_HANDLERS = new WeakMap(); -export const INTERVAL_HANDLERS = new WeakMap(); -export const CRON_HANDLERS = new WeakMap(); -export const NEST_BOUNDARY_BINDINGS = new WeakMap< - DecoratorHostConstructor, - NestBoundaryBindingMeta[] ->(); - -export type GraphMethodDecorator = MethodDecorator & - ((value: DecoratorBoundMethod, context: ClassMethodDecoratorContext) => void); - -/** Minimal Nest provider object shape without importing @nestjs/common. */ -export type NestProviderBinding = - | { readonly provide: string | symbol; readonly useValue: T } - | { readonly provide: string | symbol; readonly useFactory: (...args: unknown[]) => T }; - -/** Get the injection token for a named feature graph. - * @param name - Stable name for the created node or helper. - * @returns A `symbol` value. - * @category adapters - * @example - * ```ts - * import { getGraphToken } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function getGraphToken(name: string): symbol { - assertNonEmptyString(name, "getGraphToken(name)"); - return Symbol.for(`graphrefly:graph:${name}`); -} - -/** Get the injection token for a node at a qualified path. - * @param path - path value used by the helper. - * @returns A `symbol` value. - * @category adapters - * @example - * ```ts - * import { getNodeToken } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function getNodeToken(path: string): symbol { - assertNonEmptyString(path, "getNodeToken(path)"); - return Symbol.for(`graphrefly:node:${path}`); -} - -/** Get the injection token for a named Nest boundary binding. - * @param bindingId - Stable identifier used by the emitted record. - * @returns A `symbol` value. - * @category adapters - * @example - * ```ts - * import { getNestBoundaryToken } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function getNestBoundaryToken(bindingId: string): symbol { - assertNonEmptyString(bindingId, "getNestBoundaryToken(bindingId)"); - return Symbol.for(`graphrefly:nest-boundary:${bindingId}`); -} - -/** Build a dependency-free provider binding shape for user-land Nest modules. - * @param provide - provide value used by the helper. - * @param useValue - use value value used by the helper. - * @returns A `NestProviderBinding` value. - * @category adapters - * @example - * ```ts - * import { nestProvider } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function nestProvider(provide: string | symbol, useValue: T): NestProviderBinding { - return { provide, useValue }; -} - -/** D474 P0 HTTP request ingress. - * @param graph - Graph that owns the created nodes or projector. - * @param opts - Options that configure the helper. - * @returns A NestIngressBoundary value for the boundary or adapter. - * @category adapters - * @example - * ```ts - * import { fromNestReq } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function fromNestReq( - graph: Graph, - opts: NestIngressOptions = {}, -): NestIngressBoundary { - return nestIngress(graph, "request", opts); -} - -/** D474 P0 guard/admission ingress. - * @param graph - Graph that owns the created nodes or projector. - * @param opts - Options that configure the helper. - * @returns A NestIngressBoundary value for the boundary or adapter. - * @category adapters - * @example - * ```ts - * import { fromNestGuard } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function fromNestGuard( - graph: Graph, - opts: NestIngressOptions = {}, -): NestIngressBoundary { - return nestIngress(graph, "guard", opts); -} - -/** D474 P1 interceptor ingress. - * @param graph - Graph that owns the created nodes or projector. - * @param opts - Options that configure the helper. - * @returns A NestIngressBoundary value for the boundary or adapter. - * @category adapters - * @example - * ```ts - * import { fromNestIntercept } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function fromNestIntercept( - graph: Graph, - opts: NestIngressOptions = {}, -): NestIngressBoundary { - return nestIngress(graph, "interceptor", opts); -} - -/** D474 P0 error/filter ingress. - * @param graph - Graph that owns the created nodes or projector. - * @param opts - Options that configure the helper. - * @returns A NestIngressBoundary value for the boundary or adapter. - * @category adapters - * @example - * ```ts - * import { fromNestError } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function fromNestError( - graph: Graph, - opts: NestIngressOptions = {}, -): NestIngressBoundary { - return nestIngress(graph, "error", opts); -} - -/** D474 P1 Nest lifecycle hook ingress. - * @param graph - Graph that owns the created nodes or projector. - * @param opts - Options that configure the helper. - * @returns A NestIngressBoundary value for the boundary or adapter. - * @category adapters - * @example - * ```ts - * import { fromNestLifecycle } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function fromNestLifecycle( - graph: Graph, - opts: NestIngressOptions = {}, -): NestIngressBoundary { - return nestIngress(graph, "lifecycle", opts); -} - -/** D474 P1 schedule/cron ingress. - * @param graph - Graph that owns the created nodes or projector. - * @param opts - Options that configure the helper. - * @returns A NestIngressBoundary value for the boundary or adapter. - * @category adapters - * @example - * ```ts - * import { fromNestCron } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function fromNestCron( - graph: Graph, - opts: NestIngressOptions = {}, -): NestIngressBoundary { - return nestIngress(graph, "cron", opts); -} - -/** D494 explicit graph-visible diagnostics ingress. Emits sanitized data-only payloads. - * @param graph - Graph that owns the created nodes or projector. - * @param opts - Options that configure the helper. - * @returns A NestDiagnosticIngressBoundary value for the boundary or adapter. - * @category adapters - * @example - * ```ts - * import { fromNestDiagnostics } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function fromNestDiagnostics( - graph: Graph, - opts: NestDiagnosticsOptions = {}, -): NestDiagnosticIngressBoundary { - return nestIngress(graph, "diagnostics", { - ...opts, - payload: (diagnostic) => sanitizeNestDiagnostic(diagnostic, opts.phase), - }); -} - -/** D474 later/optional WebSocket ingress. Kept thin for first-slice experiments. - * @param graph - Graph that owns the created nodes or projector. - * @param opts - Options that configure the helper. - * @returns A NestIngressBoundary value for the boundary or adapter. - * @category adapters - * @example - * ```ts - * import { fromNestWs } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function fromNestWs( - graph: Graph, - opts: NestIngressOptions = {}, -): NestIngressBoundary { - return nestIngress(graph, "ws", opts); -} - -/** D474 later/optional microservice/message ingress. - * @param graph - Graph that owns the created nodes or projector. - * @param opts - Options that configure the helper. - * @returns A NestIngressBoundary value for the boundary or adapter. - * @category adapters - * @example - * ```ts - * import { fromNestMessage } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function fromNestMessage( - graph: Graph, - opts: NestIngressOptions = {}, -): NestIngressBoundary { - return nestIngress(graph, "message", opts); -} - -/** - * D474 HTTP egress resolver. - * - * The returned boundary owns only a host-private pending map. It never stores - * response/socket/ack handles in graph DATA and only resolves handles whose - * requestId (and optional bindingId) match an egress envelope. - * @param egress - egress value used by the helper. - * @param opts - Options that configure the helper. - * @returns A NestHttpBoundary value for the boundary or adapter. - * @category adapters - * @example - * ```ts - * import { toNestHttp } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function toNestHttp( - egress: Node>, - opts: ToNestHttpOptions = {}, -): NestHttpBoundary { - const bindingId = stableBindingId("http", opts); - const scopedBindingId = opts.bindingId; - const maxDiagnostics = diagnosticsRetainedLimit(opts.maxDiagnostics); - const maxPayloadBytes = payloadByteLimit(opts.maxPayloadBytes); - const pending = new Map>(); - const diagnostics: NestBoundaryDiagnostic[] = []; - let active = true; - let terminal: - | { - readonly error: unknown; - readonly message: string; - } - | undefined; - - const report = (diagnostic: NestBoundaryDiagnostic) => { - pushDiagnostic(diagnostics, diagnostic, maxDiagnostics); - try { - const phase = diagnostic.phase ?? opts.diagnosticPhase ?? "http"; - const payload = sanitizeNestDiagnostic({ ...diagnostic, phase }, phase); - opts.diagnosticBoundary?.emit(payload, { payload }); - } catch { - // Graph-visible diagnostics are optional and must not interrupt host cleanup. - } - }; - const keyOf = (requestId: string, requestBindingId?: string) => - requestBindingId === undefined ? requestId : canonicalTupleKey([requestBindingId, requestId]); - const rejectEntry = ( - entry: NestHttpPendingEntry, - error: unknown, - envelope: NestReplyEnvelope | undefined, - message: string, - ) => { - try { - entry.handle.reject(error, envelope); - } catch (rejectError) { - report({ - kind: "reject-threw", - requestId: entry.requestId, - bindingId: entry.bindingId, - message, - error: rejectError, - }); - } - }; - const rejectPending = ( - kind: "dispose-pending" | "terminal-egress", - error: unknown, - message: string, - ): number => { - const entries = [...pending.values()]; - pending.clear(); - if (entries.length === 0) return 0; - report({ kind, bindingId: scopedBindingId, message, error }); - for (const entry of entries) { - rejectEntry(entry, error, undefined, `toNestHttp(${bindingId}) pending reject threw`); - } - return entries.length; - }; - - const unsubscribe = egress.subscribe((msg: Message) => { - if (!active) return; - if (msg[0] === "ERROR" || msg[0] === "COMPLETE" || msg[0] === "TEARDOWN") { - const error = - msg[0] === "ERROR" - ? msg[1] - : new Error(`toNestHttp(${bindingId}) egress received ${msg[0]}`); - const message = `toNestHttp(${bindingId}) rejected pending requests after ${msg[0]}`; - terminal = { error, message }; - const rejectedCount = rejectPending("terminal-egress", error, message); - if (rejectedCount === 0) - report({ kind: "terminal-egress", bindingId: scopedBindingId, message, error }); - return; - } - if (msg[0] !== "DATA") return; - const envelope = msg[1] as NestReplyEnvelope; - const malformed = validateEnvelope(envelope, maxPayloadBytes); - if (malformed !== undefined) { - const correlated = malformedCorrelation(msg[1], scopedBindingId); - if (correlated !== undefined) { - const pendingKey = keyOf(correlated.requestId, scopedBindingId); - const entry = pending.get(pendingKey); - if (entry !== undefined) { - pending.delete(pendingKey); - rejectEntry( - entry, - new Error(malformed), - undefined, - `toNestHttp(${bindingId}) rejected malformed correlated egress`, - ); - } - } - report({ - kind: "malformed-egress", - requestId: correlated?.requestId, - bindingId: correlated?.bindingId, - message: malformed, - }); - return; - } - if (scopedBindingId !== undefined && envelope.bindingId !== scopedBindingId) { - report({ - kind: "binding-mismatch", - requestId: envelope.requestId, - bindingId: envelope.bindingId, - expectedBindingId: scopedBindingId, - message: `toNestHttp(${bindingId}) ignored egress for binding ${envelope.bindingId}`, - }); - return; - } - const pendingKey = keyOf(envelope.requestId, scopedBindingId); - const entry = pending.get(pendingKey); - if (entry === undefined) { - report({ - kind: "stale-egress", - requestId: envelope.requestId, - bindingId: envelope.bindingId, - message: `toNestHttp(${bindingId}) ignored stale requestId ${envelope.requestId}`, - }); - return; - } - pending.delete(pendingKey); - try { - entry.handle.resolve( - opts.transform?.(envelope.payload, envelope) ?? envelope.payload, - envelope, - ); - } catch (error) { - report({ - kind: "resolve-threw", - requestId: envelope.requestId, - bindingId: envelope.bindingId, - message: `toNestHttp(${bindingId}) response handle threw while resolving`, - error, - }); - rejectEntry( - entry, - error, - envelope, - `toNestHttp(${bindingId}) response handle threw while rejecting`, - ); - } - }); - - return { - kind: "http", - bindingId, - attach(registration) { - if (!active) throw new Error(`toNestHttp(${bindingId}) is disposed`); - assertNonEmptyString(registration.requestId, "toNestHttp.attach(requestId)"); - const registrationBindingId = registration.bindingId ?? scopedBindingId; - if (scopedBindingId !== undefined && registrationBindingId !== scopedBindingId) { - throw new Error( - `toNestHttp.attach expected bindingId ${scopedBindingId}, got ${registrationBindingId}`, - ); - } - if (terminal !== undefined) { - const entry = { - requestId: registration.requestId, - bindingId: registrationBindingId, - handle: registration.handle, - }; - report({ - kind: "terminal-egress", - requestId: registration.requestId, - bindingId: registrationBindingId, - message: terminal.message, - error: terminal.error, - }); - rejectEntry( - entry, - terminal.error, - undefined, - `toNestHttp(${bindingId}) terminal response handle reject threw`, - ); - return () => false; - } - const key = keyOf(registration.requestId, scopedBindingId); - if (pending.has(key)) { - throw new Error(`toNestHttp.attach duplicate pending requestId ${registration.requestId}`); - } - const entry = { - requestId: registration.requestId, - bindingId: registrationBindingId, - handle: registration.handle, - }; - pending.set(key, entry); - return () => { - if (pending.get(key) !== entry) return false; - return pending.delete(key); - }; - }, - pendingCount: () => pending.size, - diagnostics: () => diagnostics.slice(), - dispose() { - if (!active) return; - active = false; - rejectPending( - "dispose-pending", - new Error(`toNestHttp(${bindingId}) disposed before response resolution`), - `toNestHttp(${bindingId}) rejected pending requests during dispose`, - ); - unsubscribe(); - }, - }; -} - -/** D478 route-request decorator over an existing ingress boundary. - * @param boundary - boundary value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphReq } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function GraphReq( - boundary: NestIngressBoundary, - opts: NestBoundaryDecoratorOptions = {}, -): GraphMethodDecorator { - return graphIngressBinding("request", boundary, opts); -} - -/** D478 route-guard decorator over an existing ingress boundary. - * @param boundary - boundary value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphGuard } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function GraphGuard( - boundary: NestIngressBoundary, - opts: NestBoundaryDecoratorOptions = {}, -): GraphMethodDecorator { - return graphIngressBinding("guard", boundary, opts); -} - -/** D478 route-interceptor decorator over an existing ingress boundary. - * @param boundary - boundary value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphIntercept } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function GraphIntercept( - boundary: NestIngressBoundary, - opts: NestBoundaryDecoratorOptions = {}, -): GraphMethodDecorator { - return graphIngressBinding("interceptor", boundary, opts); -} - -/** D484 generic filter decorator over an existing ingress boundary. - * @param boundary - boundary value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphFilter } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function GraphFilter( - boundary: NestIngressBoundary, - opts: NestFilterDecoratorOptions = {}, -): GraphMethodDecorator { - return graphIngressBinding("error", boundary, opts); -} - -/** D484 exception-oriented sugar over GraphFilter. - * @param boundary - boundary value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphError } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function GraphError( - boundary: NestIngressBoundary, - opts: NestFilterDecoratorOptions = {}, -): GraphMethodDecorator { - return GraphFilter(boundary, opts); -} - -/** D478 lifecycle decorator over an existing ingress boundary. - * @param boundary - boundary value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphLifecycle } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function GraphLifecycle( - boundary: NestIngressBoundary, - opts: NestBoundaryDecoratorOptions = {}, -): GraphMethodDecorator { - return graphIngressBinding("lifecycle", boundary, opts); -} - -/** D478 cron/schedule decorator over an existing ingress boundary. - * @param boundary - boundary value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphCron } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function GraphCron( - boundary: NestIngressBoundary, - opts: NestBoundaryDecoratorOptions = {}, -): GraphMethodDecorator { - return graphIngressBinding("cron", boundary, opts); -} - -/** D488 WebSocket message ingress decorator over an existing boundary. - * @param boundary - boundary value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphWs } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function GraphWs( - boundary: NestIngressBoundary, - opts: NestBoundaryDecoratorOptions = {}, -): GraphMethodDecorator { - return graphIngressBinding("ws", boundary, opts); -} - -/** D488 microservice/message ingress decorator over an existing boundary. - * @param boundary - boundary value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphMessage } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function GraphMessage( - boundary: NestIngressBoundary, - opts: NestBoundaryDecoratorOptions = {}, -): GraphMethodDecorator { - return graphIngressBinding("message", boundary, opts); -} - -/** D478 HTTP reply decorator over an existing reply node. - * @param replyNode - reply node value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphHttpReply } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function GraphHttpReply( - replyNode: Node>, - opts: NestHttpReplyDecoratorOptions, -): GraphMethodDecorator { - const bindingId = opts?.bindingId; - if (typeof bindingId !== "string" || bindingId.length === 0) { - throw new Error("GraphHttpReply requires a non-empty bindingId"); - } - return registerMeta(NEST_BOUNDARY_BINDINGS, (methodKey) => ({ - direction: "egress" as const, - kind: "http" as const, - bindingId, - methodKey, - replyNode: replyNode as Node>, - order: opts.order, - issueResponse: opts.issueResponse as NestIssueResponse | undefined, - protocolError: opts.protocolError as NestProtocolErrorResponse | undefined, - })); -} - -/** D484 guard decision egress decorator over an existing reply-correlated decision node. - * @param decisionNode - decision node value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphGuardDecision } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function GraphGuardDecision( - decisionNode: Node>, - opts: NestGuardDecisionDecoratorOptions, -): GraphMethodDecorator { - const bindingId = opts?.bindingId; - if (typeof bindingId !== "string" || bindingId.length === 0) { - throw new Error("GraphGuardDecision requires a non-empty bindingId"); - } - return registerMeta(NEST_BOUNDARY_BINDINGS, (methodKey) => ({ - direction: "egress" as const, - kind: "guard-decision" as const, - bindingId, - methodKey, - decisionNode, - order: opts.order, - issueResponse: opts.issueResponse as NestIssueResponse | undefined, - protocolError: opts.protocolError as NestProtocolErrorResponse | undefined, - })); -} - -/** D488 WebSocket acknowledgement egress decorator over an existing reply-correlated node. - * @param ackNode - ack node value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphWsAck } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function GraphWsAck( - ackNode: Node>, - opts: { readonly bindingId: string; readonly order?: number }, -): GraphMethodDecorator { - return graphReplyBinding("ws-ack", ackNode, opts, "GraphWsAck"); -} - -/** D488 WebSocket reply egress decorator over an existing reply-correlated node. - * @param replyNode - reply node value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphWsReply } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function GraphWsReply( - replyNode: Node>, - opts: { readonly bindingId: string; readonly order?: number }, -): GraphMethodDecorator { - return graphReplyBinding("ws-reply", replyNode, opts, "GraphWsReply"); -} - -/** D488 microservice/message reply egress decorator over an existing reply-correlated node. - * @param replyNode - reply node value used by the helper. - * @param opts - Options that configure the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphMessageReply } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function GraphMessageReply( - replyNode: Node>, - opts: { readonly bindingId: string; readonly order?: number }, -): GraphMethodDecorator { - return graphReplyBinding("message-reply", replyNode, opts, "GraphMessageReply"); -} - -/** - * Creates a nest graph boundary runner. - * - * @param opts - Options that configure the helper. - * @returns The create nest graph boundary runner result. - * @category adapters - * @example - * ```ts - * import { createNestGraphBoundaryRunner } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function createNestGraphBoundaryRunner( - opts: NestGraphBoundaryRunnerOptions = {}, -): NestGraphBoundaryRunner { - const httpBoundaries = new Map< - Node>, - Map> - >(); - const httpBoundaryFor = ( - node: Node>, - bindingId: string, - ): NestHttpBoundary => { - let byBinding = httpBoundaries.get(node); - if (byBinding === undefined) { - byBinding = new Map(); - httpBoundaries.set(node, byBinding); - } - let boundary = byBinding.get(bindingId); - if (boundary === undefined) { - boundary = toNestHttp(node, { - bindingId, - diagnosticBoundary: opts.diagnosticBoundary, - diagnosticPhase: opts.diagnosticPhase ?? "http", - }); - byBinding.set(bindingId, boundary); - } - return boundary; - }; - - return { - run(target, methodKey, host, opts = {}) { - const ctor = typeof target === "function" ? target : target.constructor; - const bindings = getNestBoundaryBindings(ctor as DecoratorHostConstructor, methodKey); - if (bindings.length === 0) return undefined; - const requestId = requestIdFromRunOptions(host, opts); - const replies = bindings.filter(isHttpReplyBinding); - const ingress = bindings.filter( - (binding): binding is NestIngressBindingMeta => - binding.direction === "ingress" && - (binding.kind === "request" || binding.kind === "interceptor"), - ); - if (replies.length > 0 && ingress.length === 0) { - throw new Error("Nest GraphHttpReply requires at least one ingress boundary"); - } - const ingressEmits = ingress.map((binding) => ({ - binding, - requestId: requestIdFromBinding(host, binding) ?? requestId, - })); - const replyRequestIds = uniqueDefinedStrings(ingressEmits.map((entry) => entry.requestId)); - if (replies.length > 0 && replyRequestIds.length === 0) { - throw new Error("Nest GraphHttpReply requires a stable requestId"); - } - const cleanups: Array<() => boolean> = []; - let resolveReply: ((value: unknown) => void) | undefined; - let rejectReply: ((reason?: unknown) => void) | undefined; - const replyPromise = - replies.length === 0 - ? undefined - : new Promise((resolve, reject) => { - resolveReply = resolve; - rejectReply = reject; - }); - try { - for (const reply of replies) { - const http = httpBoundaryFor(reply.replyNode, reply.bindingId); - for (const replyRequestId of replyRequestIds) { - cleanups.push( - http.attach({ - requestId: replyRequestId, - bindingId: reply.bindingId, - handle: { - resolve: resolveReply ?? (() => undefined), - reject: rejectReply ?? (() => undefined), - }, - }), - ); - } - } - for (const entry of ingressEmits) { - entry.binding.boundary.emit(host, { - bindingId: entry.binding.bindingId, - requestId: entry.requestId, - ...bindingPayloadEmitOption(host, entry.binding), - requireRequestId: requiresRequestId(entry.binding.kind), - }); - } - } catch (error) { - for (const cleanup of cleanups) cleanup(); - throw error; - } - return replyPromise?.finally(() => { - for (const cleanup of cleanups) cleanup(); - }); - }, - dispose() { - for (const byBinding of httpBoundaries.values()) { - for (const boundary of byBinding.values()) boundary.dispose(); - } - httpBoundaries.clear(); - }, - }; -} - -/** - * Creates a nest graph boundary interceptor. - * - * @param opts - Options that configure the helper. - * @returns The create nest graph boundary interceptor result. - * @category adapters - * @example - * ```ts - * import { createNestGraphBoundaryInterceptor } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function createNestGraphBoundaryInterceptor( - opts: NestGraphBoundaryInterceptorOptions = {}, -): NestGraphBoundaryInterceptor { - const runner = opts.runner ?? createNestGraphBoundaryRunner(opts); - let nextRequestSeq = 1; - return { - intercept(context, next) { - const ctor = context.getClass(); - const handler = context.getHandler(); - const methodKey = methodKeyForHandler(ctor, handler); - if (methodKey === undefined) return next?.handle(); - const bindings = getNestBoundaryBindings(ctor, methodKey); - const needsSyntheticRequestId = bindings.some( - (binding) => - binding.direction === "egress" || - (binding.direction === "ingress" && requiresRequestId(binding.kind)), - ); - const host = - opts.host?.(context) ?? - (defaultNestHttpHost(context, () => nextRequestSeq++, needsSyntheticRequestId) as THost) ?? - ({} as THost); - const result = runner.run(ctor, methodKey, host, opts); - return result ?? next?.handle(); - }, - dispose() { - runner.dispose(); - }, - }; -} - -/** - * Creates a get nest boundary bindings. - * - * @param target - Class or instance that owns the decorated method. - * @param methodKey - Method key on the decorated target. - * @returns The get nest boundary bindings result. - * @category adapters - * @example - * ```ts - * import { getNestBoundaryBindings } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function getNestBoundaryBindings( - target: DecoratorHostConstructor | object, - methodKey?: string | symbol, -): readonly NestBoundaryBindingMeta[] { - const ctor = - typeof target === "function" - ? (target as DecoratorHostConstructor) - : ((target as { constructor: DecoratorHostConstructor }) - .constructor as DecoratorHostConstructor); - const bindings = boundaryBindingsFor(ctor); - return sortBoundaryBindings( - methodKey === undefined - ? bindings - : bindings.filter((binding) => binding.methodKey === methodKey), - ); -} - -/** - * Resolves nest method key. - * - * @param ctor - Constructor that may own the decorated handler. - * @param handler - Handler function to match against metadata. - * @returns The stable key or reference string. - * @category adapters - * @example - * ```ts - * import { resolveNestMethodKey } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function resolveNestMethodKey( - ctor: DecoratorHostConstructor, - handler: DecoratorBoundMethod, -): string | symbol | undefined { - return methodKeyForHandler(ctor, handler); -} - -/** - * Creates a binding request ID. - * - * @param host - Host object from the framework boundary. - * @param binding - Nest boundary binding metadata. - * @param fallback - fallback value used by the helper. - * @returns The binding request ID result. - * @category adapters - * @example - * ```ts - * import { bindingRequestId } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function bindingRequestId( - host: THost, - binding: NestBoundaryBindingMeta, - fallback?: string | ((host: THost) => string | undefined), -): string | undefined { - if (binding.direction === "ingress") { - const requestId = requestIdFromBinding(host, binding); - if (requestId !== undefined) return requestId; - } - if (typeof fallback === "string") { - assertNonEmptyString(fallback, "requestId"); - return fallback; - } - if (typeof fallback === "function") { - const requestId = fallback(host); - if (requestId !== undefined) assertNonEmptyString(requestId, "requestId"); - return requestId; - } - return requestIdOf(host, {}, {}); -} - -/** - * Creates a binding emit options. - * - * @param host - Host object from the framework boundary. - * @param binding - Nest boundary binding metadata. - * @param requestId - Host request correlation id. - * @returns The binding emit options result. - * @category adapters - * @example - * ```ts - * import { bindingEmitOptions } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function bindingEmitOptions( - host: THost, - binding: NestIngressBindingMeta, - requestId?: string, -): NestIngressEmitOptions { - return { - bindingId: binding.bindingId, - requestId, - ...bindingPayloadEmitOption(host, binding), - requireRequestId: requiresRequestId(binding.kind), - }; -} - -/** - * Checks whether a value is a data issue. - * - * @param value - Unknown value to check or decode. - * @returns `true` when the value matches the expected shape. - * @category adapters - * @example - * ```ts - * import { isDataIssue } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function isDataIssue(value: unknown): value is DataIssue { - return ( - value !== null && - typeof value === "object" && - (value as { kind?: unknown }).kind === "issue" && - typeof (value as { code?: unknown }).code === "string" && - typeof (value as { message?: unknown }).message === "string" - ); -} - -/** - * Checks whether a value is a HTTP data issue. - * - * @param value - Unknown value to check or decode. - * @returns `true` when the value matches the expected shape. - * @category adapters - * @example - * ```ts - * import { isHttpDataIssue } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function isHttpDataIssue(value: unknown): value is HttpDataIssue { - return isDataIssue(value) && Number.isInteger((value as { status?: unknown }).status); -} - -/** - * Checks whether a value is an issue response. - * - * @param issue - Data issue to lower into a response. - * @param _host - Host object from the framework boundary. - * @returns `true` when the value matches the expected shape. - * @category adapters - * @example - * ```ts - * import { issueResponse } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function issueResponse( - issue: DataIssue, - _host?: THost, -): NestHttpResponsePayload { - if (isHttpDataIssue(issue)) { - return { - status: issue.status, - body: issue.body ?? { code: issue.code, message: issue.message }, - headers: issue.headers, - }; - } - return { - status: 400, - body: { code: issue.code, message: issue.message }, - }; -} - -/** - * Creates a protocol error. - * - * @param _errorPayload - Protocol error payload to lower into a response. - * @param _host - Host object from the framework boundary. - * @returns The protocol error result. - * @category adapters - * @example - * ```ts - * import { protocolError } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function protocolError( - _errorPayload: unknown, - _host?: THost, -): NestHttpResponsePayload { - return { - status: 500, - body: { code: "graphrefly.protocol_error", message: "GraphReFly reply pipeline failed" }, - }; -} - -/** - * Lowers HTTP reply payload. - * - * @param payload - Payload to lower or wrap. - * @param host - Host object from the framework boundary. - * @param opts - Options that configure the helper. - * @returns The lower HTTP reply payload result. - * @category adapters - * @example - * ```ts - * import { lowerHttpReplyPayload } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function lowerHttpReplyPayload( - payload: unknown, - host: THost, - opts: { readonly issueResponse?: NestIssueResponse } = {}, -): NestHttpResponsePayload { - if (isHttpResponsePayload(payload)) return payload; - if (isDataIssue(payload)) return (opts.issueResponse ?? issueResponse)(payload, host); - return { status: 200, body: payload }; -} - -/** - * Lowers protocol error. - * - * @param errorPayload - Protocol error payload to lower into a response. - * @param host - Host object from the framework boundary. - * @param opts - Options that configure the helper. - * @returns The lower protocol error result. - * @category adapters - * @example - * ```ts - * import { lowerProtocolError } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function lowerProtocolError( - errorPayload: unknown, - host: THost, - opts: { readonly protocolError?: NestProtocolErrorResponse } = {}, -): NestHttpResponsePayload { - return (opts.protocolError ?? protocolError)(errorPayload, host); -} - -/** - * Sanitizes nest diagnostic. - * - * @param diagnostic - Diagnostic input to sanitize. - * @param defaultPhase - Phase to use when the diagnostic does not provide one. - * @returns The sanitize nest diagnostic result. - * @category adapters - * @example - * ```ts - * import { sanitizeNestDiagnostic } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function sanitizeNestDiagnostic( - diagnostic: NestDiagnosticInput, - defaultPhase: NestDiagnosticPhase = "adapter", -): NestDiagnosticPayload { - const payload: NestDiagnosticPayload = { - kind: diagnostic.kind, - phase: diagnostic.phase ?? defaultPhase, - message: diagnostic.message, - ...optionalStringField("requestId", diagnostic.requestId), - ...optionalStringField("bindingId", diagnostic.bindingId), - ...optionalStringField("expectedBindingId", diagnostic.expectedBindingId), - ...optionalDiagnosticError(diagnostic.error), - }; - assertGraphVisibleData(payload, "NestDiagnosticPayload", NEST_BOUNDARY_PAYLOAD_MAX_BYTES); - return payload; -} - -function nestIngress( - graph: Graph, - kind: NestBoundaryKind, - opts: NestIngressOptions, -): NestIngressBoundary { - const bindingId = stableBindingId(kind, opts); - const version = parseEnvelopeVersion(opts.version ?? NEST_BOUNDARY_ENVELOPE_VERSION, "version"); - const maxPayloadBytes = payloadByteLimit(opts.maxPayloadBytes); - const node = graph.node>([], null, { - name: opts.name, - meta: { adapter: "nestjs", boundary: "ingress", kind, bindingId, version }, - }); - - return { - kind, - bindingId, - version, - node, - envelope(host, emitOpts = {}) { - const requestId = requestIdOf(host, opts, { - ...emitOpts, - requireRequestId: - emitOpts.requireRequestId ?? opts.requireRequestId ?? requiresRequestId(kind), - }); - const envelopeBindingId = emitOpts.bindingId ?? bindingId; - assertNonEmptyString(envelopeBindingId, "NestBoundaryEnvelope.bindingId"); - const envelopeVersion = parseEnvelopeVersion( - emitOpts.version ?? version, - "NestBoundaryEnvelope.version", - ); - const payload = - "payload" in emitOpts - ? (emitOpts.payload as TPayload) - : opts.payload !== undefined - ? opts.payload(host) - : (host as unknown as TPayload); - assertGraphVisibleData(payload, "NestBoundaryEnvelope.payload", maxPayloadBytes); - const envelope: NestBoundaryEnvelope = { - bindingId: envelopeBindingId, - version: envelopeVersion, - payload, - }; - return requestId === undefined ? envelope : { ...envelope, requestId }; - }, - emit(host, emitOpts = {}) { - const envelope = this.envelope(host, emitOpts); - node.down([["DATA", envelope]]); - return envelope; - }, - }; -} - -function stableBindingId( - kind: NestBoundaryKind | NestEgressKind, - opts: { readonly bindingId?: string; readonly name?: string }, -): string { - const bindingId = opts.bindingId ?? opts.name ?? `nestjs.${kind}`; - assertNonEmptyString(bindingId, "bindingId"); - return bindingId; -} - -function requiresRequestId(kind: NestBoundaryKind): boolean { - return kind === "request" || kind === "guard" || kind === "interceptor" || kind === "error"; -} - -function requestIdFromRunOptions( - host: THost, - opts: NestGraphRunOptions, -): string | undefined { - if (typeof opts.requestId === "string") { - assertNonEmptyString(opts.requestId, "requestId"); - return opts.requestId; - } - if (typeof opts.requestId === "function") { - const requestId = opts.requestId(host); - if (requestId !== undefined) assertNonEmptyString(requestId, "requestId"); - return requestId; - } - return requestIdOf(host, {}, {}); -} - -function requestIdFromBinding( - host: THost, - binding: NestIngressBindingMeta, -): string | undefined { - if (typeof binding.requestId === "string") { - assertNonEmptyString(binding.requestId, "requestId"); - return binding.requestId; - } - if (typeof binding.requestId === "function") { - const requestId = binding.requestId(host); - if (requestId !== undefined) assertNonEmptyString(requestId, "requestId"); - return requestId; - } - return undefined; -} - -function bindingPayloadEmitOption( - host: THost, - binding: NestIngressBindingMeta, -): { readonly payload: unknown } | Record { - return binding.payload === undefined ? {} : { payload: binding.payload(host) }; -} - -function isHttpResponsePayload(value: unknown): value is NestHttpResponsePayload { - if (value === null || typeof value !== "object") return false; - const status = (value as { status?: unknown }).status; - if (!Number.isInteger(status)) return false; - const headers = (value as { headers?: unknown }).headers; - return headers === undefined || isStringRecord(headers); -} - -function isStringRecord(value: unknown): value is Record { - if (value === null || typeof value !== "object" || Array.isArray(value)) return false; - for (const entry of Object.values(value)) if (typeof entry !== "string") return false; - return true; -} - -function isHttpReplyBinding(binding: NestBoundaryBindingMeta): binding is NestHttpReplyBindingMeta { - return binding.direction === "egress" && binding.kind === "http"; -} - -function uniqueDefinedStrings(values: readonly (string | undefined)[]): string[] { - const seen = new Set(); - for (const value of values) { - if (value !== undefined) seen.add(value); - } - return [...seen]; -} - -function defaultNestHttpHost( - context: NestExecutionContextLike, - nextSeq: () => number, - needsSyntheticRequestId: boolean, -): unknown | undefined { - const request = context.switchToHttp?.().getRequest>(); - if (request === undefined || request === null || typeof request !== "object") return request; - if (requestIdFromRecord(request) !== undefined) return request; - const headerId = requestIdFromHeaders(request.headers); - if (headerId !== undefined || needsSyntheticRequestId) { - return { ...request, requestId: headerId ?? `nestjs-request:${nextSeq()}` }; - } - return request; -} - -function requestIdFromRecord(record: Record): string | undefined { - for (const key of ["requestId", "id"]) { - const value = record[key]; - if (typeof value === "string" && value.length > 0) return value; - } - return undefined; -} - -function requestIdFromHeaders(headers: unknown): string | undefined { - if (headers === null || typeof headers !== "object") return undefined; - for (const key of ["x-request-id", "x-correlation-id"]) { - const value = (headers as Record)[key]; - const first = Array.isArray(value) ? value[0] : value; - if (typeof first === "string" && first.trim().length > 0) return first.trim(); - } - return undefined; -} - -function graphIngressBinding( - kind: NestBoundaryKind, - boundary: NestIngressBoundary, - opts: NestBoundaryDecoratorOptions & NestFilterDecoratorOptions, -): GraphMethodDecorator { - if (boundary.kind !== kind) { - throw new Error(`Graph${kind} expected a ${kind} boundary, got ${boundary.kind}`); - } - const bindingId = opts.bindingId ?? boundary.bindingId; - assertNonEmptyString(bindingId, "bindingId"); - return registerMeta(NEST_BOUNDARY_BINDINGS, (methodKey) => ({ - direction: "ingress" as const, - kind, - bindingId, - methodKey, - boundary: boundary as NestIngressBoundary, - payload: opts.payload as ((host: unknown) => unknown) | undefined, - requestId: opts.requestId as string | ((host: unknown) => string | undefined) | undefined, - order: opts.order, - mode: opts.mode, - issueResponse: opts.issueResponse as NestIssueResponse | undefined, - protocolError: opts.protocolError as NestProtocolErrorResponse | undefined, - })); -} - -function graphReplyBinding( - kind: "ws-ack" | "ws-reply" | "message-reply", - node: Node>, - opts: { readonly bindingId: string; readonly order?: number }, - decoratorName: string, -): GraphMethodDecorator { - const bindingId = opts?.bindingId; - if (typeof bindingId !== "string" || bindingId.length === 0) { - throw new Error(`${decoratorName} requires a non-empty bindingId`); - } - const replyNode = node as Node>; - return registerMeta(NEST_BOUNDARY_BINDINGS, (methodKey) => { - if (kind === "ws-ack") { - return { - direction: "egress" as const, - kind, - bindingId, - methodKey, - ackNode: replyNode, - order: opts.order, - }; - } - return { - direction: "egress" as const, - kind, - bindingId, - methodKey, - replyNode, - order: opts.order, - }; - }); -} - -function methodKeyForHandler( - ctor: DecoratorHostConstructor, - handler: DecoratorBoundMethod, -): string | symbol | undefined { - for (const binding of boundaryBindingsFor(ctor)) { - const candidate = methodOnPrototypeChain(ctor, binding.methodKey); - if (candidate === handler || String(binding.methodKey) === handler.name) - return binding.methodKey; - } - return undefined; -} - -function boundaryBindingsFor(ctor: DecoratorHostConstructor): NestBoundaryBindingMeta[] { - const bindings: NestBoundaryBindingMeta[] = []; - const seen = new Set(); - let current: unknown = ctor; - while (typeof current === "function" && current !== Function.prototype) { - for (const binding of NEST_BOUNDARY_BINDINGS.get(current as DecoratorHostConstructor) ?? []) { - if (seen.has(binding)) continue; - seen.add(binding); - bindings.push(binding); - } - current = Object.getPrototypeOf(current); - } - return bindings; -} - -function sortBoundaryBindings( - bindings: readonly NestBoundaryBindingMeta[], -): readonly NestBoundaryBindingMeta[] { - return bindings - .map((binding, index) => ({ binding, index })) - .sort((a, b) => (a.binding.order ?? 0) - (b.binding.order ?? 0) || a.index - b.index) - .map(({ binding }) => binding); -} - -function methodOnPrototypeChain( - ctor: DecoratorHostConstructor, - methodKey: string | symbol, -): unknown { - let proto: unknown = ctor.prototype; - while (proto !== null && typeof proto === "object") { - if (Object.hasOwn(proto, methodKey)) { - return (proto as Record)[methodKey]; - } - proto = Object.getPrototypeOf(proto); - } - return undefined; -} - -function requestIdOf( - host: THost, - opts: NestIngressOptions, - emitOpts: NestIngressEmitOptions, -): string | undefined { - const fromEmit = emitOpts.requestId; - if (fromEmit !== undefined) { - assertNonEmptyString(fromEmit, "requestId"); - return fromEmit; - } - if (typeof opts.requestId === "string") { - assertNonEmptyString(opts.requestId, "requestId"); - return opts.requestId; - } - if (typeof opts.requestId === "function") { - const requestId = opts.requestId(host); - if (requestId !== undefined) assertNonEmptyString(requestId, "requestId"); - return requestId; - } - const record = host as { requestId?: unknown; id?: unknown }; - if (typeof record?.requestId === "string" && record.requestId.length > 0) return record.requestId; - if (typeof record?.id === "string" && record.id.length > 0) return record.id; - if (emitOpts.requireRequestId ?? opts.requireRequestId ?? false) { - throw new Error("Nest boundary ingress requires a stable requestId"); - } - return undefined; -} - -function validateEnvelope(value: unknown, maxPayloadBytes: number): string | undefined { - try { - assertGraphVisibleData(value, "NestBoundaryEnvelope", maxPayloadBytes); - } catch (error) { - return error instanceof Error ? error.message : "egress envelope must be data-only material"; - } - if (value === null || typeof value !== "object") return "egress DATA is not an envelope object"; - const envelope = value as Partial; - if (typeof envelope.requestId !== "string" || envelope.requestId.length === 0) { - return "egress envelope requestId must be a non-empty string"; - } - if (typeof envelope.bindingId !== "string" || envelope.bindingId.length === 0) { - return "egress envelope bindingId must be a non-empty string"; - } - try { - parseEnvelopeVersion(envelope.version, "egress envelope version"); - } catch { - return `egress envelope version must be ${NEST_BOUNDARY_ENVELOPE_VERSION}`; - } - return undefined; -} - -function malformedCorrelation( - value: unknown, - scopedBindingId: string | undefined, -): { readonly requestId: string; readonly bindingId: string } | undefined { - if (value === null || typeof value !== "object") return undefined; - const requestId = (value as { readonly requestId?: unknown }).requestId; - const bindingId = (value as { readonly bindingId?: unknown }).bindingId; - if (typeof requestId !== "string" || requestId.length === 0) return undefined; - if (typeof bindingId !== "string" || bindingId.length === 0) return undefined; - if (scopedBindingId !== undefined && bindingId !== scopedBindingId) return undefined; - return { requestId, bindingId }; -} - -function parseEnvelopeVersion(value: unknown, label: string): number { - if (value !== NEST_BOUNDARY_ENVELOPE_VERSION) { - throw new Error(`${label} must be ${NEST_BOUNDARY_ENVELOPE_VERSION}`); - } - return value; -} - -function assertNonEmptyString(value: string, label: string): void { - if (value.length === 0) throw new Error(`${label} must be a non-empty string`); -} - -function optionalStringField( - key: K, - value: string | undefined, -): Record | Record { - return value === undefined ? {} : ({ [key]: value } as Record); -} - -function optionalDiagnosticError( - error: unknown, -): { readonly error: NestDiagnosticErrorPayload } | Record { - const summarized = summarizeDiagnosticError(error); - return summarized === undefined ? {} : { error: summarized }; -} - -function summarizeDiagnosticError(error: unknown): NestDiagnosticErrorPayload | undefined { - if (error === undefined) return undefined; - if (error instanceof Error) { - return { - ...optionalDiagnosticName(safeDiagnosticString(() => error.name)), - message: safeDiagnosticString(() => error.message) ?? "diagnostic error", - }; - } - if (typeof error === "string") return { message: error }; - if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") { - return { message: String(error) }; - } - if (typeof error === "symbol") return { message: error.description ?? "symbol" }; - if (typeof error === "function") return { message: "opaque diagnostic function" }; - if (error !== null && typeof error === "object") { - const record = error as { readonly name?: unknown; readonly message?: unknown }; - const message = safeDiagnosticString(() => record.message); - if (message !== undefined) { - return { - ...optionalDiagnosticName(safeDiagnosticString(() => record.name)), - message, - }; - } - return { message: "opaque diagnostic error" }; - } - return { message: String(error) }; -} - -function safeDiagnosticString(read: () => unknown): string | undefined { - try { - const value = read(); - return typeof value === "string" ? value : undefined; - } catch { - return undefined; - } -} - -function optionalDiagnosticName( - name: string | undefined, -): { readonly name: string } | Record { - return name === undefined || name.length === 0 ? {} : { name }; -} - -function payloadByteLimit(value: number | undefined): number { - if (value === undefined) return NEST_BOUNDARY_PAYLOAD_MAX_BYTES; - if (!Number.isSafeInteger(value) || value < 1) { - throw new Error("Nest boundary maxPayloadBytes must be a positive safe integer"); - } - return value; -} - -function diagnosticsRetainedLimit(value: number | undefined): number { - if (value === undefined) return NEST_HTTP_DIAGNOSTICS_MAX_RETAINED; - if (!Number.isSafeInteger(value) || value < 0) { - throw new Error("toNestHttp maxDiagnostics must be a non-negative safe integer"); - } - return value; -} - -function pushDiagnostic( - diagnostics: NestBoundaryDiagnostic[], - diagnostic: NestBoundaryDiagnostic, - maxDiagnostics: number, -): void { - if (maxDiagnostics === 0) return; - diagnostics.push(diagnostic); - if (diagnostics.length > maxDiagnostics) - diagnostics.splice(0, diagnostics.length - maxDiagnostics); -} - -function assertGraphVisibleData( - value: unknown, - path: string, - maxPayloadBytes: number, - seen = new WeakSet(), -): void { - if (value === undefined) { - throw new TypeError(`${path} cannot be undefined; undefined is SENTINEL/no DATA`); - } - if (value === null) return; - const type = typeof value; - if (type === "string" || type === "boolean") return; - if (type === "number") { - if (!Number.isFinite(value)) throw new TypeError(`${path} number must be finite`); - return; - } - if (type === "function" || type === "symbol" || type === "bigint") { - throw new TypeError(`${path} must be data-only; found ${type}`); - } - if (type !== "object") return; - if (seen.has(value as object)) throw new TypeError(`${path} must be acyclic data`); - seen.add(value as object); - if (Array.isArray(value)) { - try { - for (let i = 0; i < value.length; i += 1) { - const descriptor = Object.getOwnPropertyDescriptor(value, i); - if (descriptor === undefined) { - throw new TypeError(`${path}[${i}] cannot be a sparse array hole`); - } - if (!descriptor.enumerable || !("value" in descriptor)) { - throw new TypeError(`${path}[${i}] must be enumerable plain data`); - } - assertGraphVisibleData(descriptor.value, `${path}[${i}]`, maxPayloadBytes, seen); - } - for (const key of Reflect.ownKeys(value)) { - if (key === "length") continue; - if (typeof key === "symbol" || !/^(0|[1-9]\d*)$/.test(key)) { - throw new TypeError(`${path} arrays must not carry hidden or extra properties`); - } - } - assertPayloadSize(value, path, maxPayloadBytes); - } finally { - seen.delete(value as object); - } - return; - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) { - throw new TypeError(`${path} must be a plain data object or array`); - } - try { - for (const key of Reflect.ownKeys(value)) { - if (typeof key === "symbol") throw new TypeError(`${path} must not carry symbol keys`); - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor === undefined) continue; - if (!descriptor.enumerable || !("value" in descriptor)) { - throw new TypeError(`${path}.${key} must be enumerable plain data`); - } - assertGraphVisibleData(descriptor.value, `${path}.${key}`, maxPayloadBytes, seen); - } - assertPayloadSize(value, path, maxPayloadBytes); - } finally { - seen.delete(value as object); - } -} - -function assertPayloadSize(value: unknown, path: string, maxPayloadBytes: number): void { - const serialized = JSON.stringify(value); - if (serialized !== undefined && utf8ByteLength(serialized) > maxPayloadBytes) { - throw new TypeError(`${path} exceeds ${maxPayloadBytes} bytes`); - } -} - -function utf8ByteLength(value: string): number { - let bytes = 0; - for (let i = 0; i < value.length; i += 1) { - const code = value.charCodeAt(i); - if (code < 0x80) { - bytes += 1; - } else if (code < 0x800) { - bytes += 2; - } else if (code >= 0xd800 && code <= 0xdbff && i + 1 < value.length) { - const next = value.charCodeAt(i + 1); - if (next >= 0xdc00 && next <= 0xdfff) { - bytes += 4; - i += 1; - } else { - bytes += 3; - } - } else { - bytes += 3; - } - } - return bytes; -} - -function sameMeta(a: T, b: T): boolean { - const left = a as Record; - const right = b as Record; - const keys = Reflect.ownKeys(left); - if (keys.length !== Reflect.ownKeys(right).length) return false; - for (const key of keys) { - if (!Object.is(left[key], right[key])) return false; - } - return true; -} - -function pushUniqueMeta( - registry: WeakMap, - ctor: DecoratorHostConstructor, - item: T, -): void { - const existing = registry.get(ctor) ?? []; - if (existing.some((current) => sameMeta(current, item))) return; - registry.set(ctor, [...existing, item]); -} - -function registerMeta( - registry: WeakMap, - meta: (methodKey: string | symbol) => T, -): GraphMethodDecorator { - return ((targetOrValue: object, contextOrKey: ClassMethodDecoratorContext | string | symbol) => { - if (typeof contextOrKey === "object" && contextOrKey !== null) { - const methodKey = contextOrKey.name; - contextOrKey.addInitializer(function (this: unknown) { - const ctor = (this as { constructor: DecoratorHostConstructor }).constructor; - pushUniqueMeta(registry, ctor, meta(methodKey)); - }); - return; - } - - const ctor = (targetOrValue as { constructor: DecoratorHostConstructor }).constructor; - pushUniqueMeta(registry, ctor, meta(contextOrKey)); - }) as GraphMethodDecorator; -} - -/** Register a method as a DATA-event handler for a graph observe path. - * @param nodeName - node name value used by the helper. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { OnGraphEvent } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function OnGraphEvent(nodeName: string): GraphMethodDecorator { - return registerMeta(EVENT_HANDLERS, (methodKey) => ({ nodeName, methodKey })); -} - -/** Register fixed-interval metadata for a user-land NestJS scheduler bridge. - * @param ms - Duration or timestamp in milliseconds. - * @returns A `GraphMethodDecorator` value. - * @category adapters - * @example - * ```ts - * import { GraphInterval } from "@graphrefly/ts/adapters/nestjs"; - * ``` - */ -export function GraphInterval(ms: number): GraphMethodDecorator { - return registerMeta(INTERVAL_HANDLERS, (methodKey) => ({ ms, methodKey })); -} diff --git a/packages/ts/src/adapters/nestjs/microservices.ts b/packages/ts/src/adapters/nestjs/microservices.ts deleted file mode 100644 index f58ff95d..00000000 --- a/packages/ts/src/adapters/nestjs/microservices.ts +++ /dev/null @@ -1,316 +0,0 @@ -/** - * NestJS microservice/message native phase bridge (D488). - * - * This focused subpath may import `@nestjs/microservices`; the dependency-light - * structural layer and HTTP native bridge stay free of that optional peer. - */ - -import type { CustomTransportStrategy } from "@nestjs/microservices"; -import { - bindingEmitOptions, - bindingRequestId, - type DecoratorHostConstructor, - fromNestMessage, - GraphMessage, - GraphMessageReply, - getNestBoundaryBindings, - type NestBoundaryBindingMeta, - type NestBoundaryDiagnostic, - type NestBoundaryEnvelope, - type NestDiagnosticIngressBoundary, - type NestGraphRunOptions, - type NestIngressBindingMeta, - type NestIngressBoundary, - type NestIngressEmitOptions, - type NestIngressOptions, - type NestMessageReplyBindingMeta, - type NestProviderBinding, - type NestReplyEnvelope, - type NestReplyResponseHandle, - sanitizeNestDiagnostic, - toNestHttp, -} from "../nestjs.js"; - -/** D488 provider token for the focused Nest microservice/message native bridge. */ -export const GRAPHREFLY_NEST_MESSAGE_BRIDGE = Symbol.for("graphrefly:nest:message-bridge"); - -/** Options for the D488 Nest message bridge; transport contexts stay host-private. */ -export interface GraphMessageBridgeOptions extends NestGraphRunOptions { - readonly diagnosticBoundary?: NestDiagnosticIngressBoundary; - readonly timeoutMs?: number; - readonly maxDiagnostics?: number; -} - -/** D495 focused microservice/message provider bundle options. */ -export interface GraphMessageProviderBundleOptions { - readonly bridge?: GraphMessageBridgeOptions | false; -} - -/** Explicit Nest message-pattern phase bridge over `GraphMessage` and `GraphMessageReply` metadata. */ -export interface GraphMessageBridge - extends Pick { - handleMessage( - target: DecoratorHostConstructor | object, - methodKey: string | symbol, - host: THost, - opts?: NestGraphRunOptions, - ): Promise | undefined; - onModuleDestroy(): void; - diagnostics(): readonly NestBoundaryDiagnostic[]; - dispose(): void; -} - -type MessageBoundary = ReturnType>; - -/** Build a dependency-light Nest provider for the focused message bridge token. - * @param opts - Options that configure the helper. - * @returns A `NestProviderBinding>` value. - * @category adapters - * @example - * ```ts - * import { provideGraphMessageBridge } from "@graphrefly/ts/adapters/nestjs/microservices"; - * ``` - */ -export function provideGraphMessageBridge( - opts: GraphMessageBridgeOptions = {}, -): NestProviderBinding> { - return { provide: GRAPHREFLY_NEST_MESSAGE_BRIDGE, useValue: createGraphMessageBridge(opts) }; -} - -/** Build the D495 focused message provider bundle without adding a router or event bus. - * @param opts - Options that configure the helper. - * @returns A `NestProviderBinding>[]` value. - * @category adapters - * @example - * ```ts - * import { provideGraphMessageProviders } from "@graphrefly/ts/adapters/nestjs/microservices"; - * ``` - */ -export function provideGraphMessageProviders( - opts: GraphMessageProviderBundleOptions = {}, -): NestProviderBinding>[] { - return opts.bridge === false ? [] : [provideGraphMessageBridge(opts.bridge ?? {})]; -} - -/** Create a host-private message bridge instance without adding a router or event bus. - * @param opts - Options that configure the helper. - * @returns A `GraphMessageBridge` value. - * @category adapters - * @example - * ```ts - * import { createGraphMessageBridge } from "@graphrefly/ts/adapters/nestjs/microservices"; - * ``` - */ -export function createGraphMessageBridge( - opts: GraphMessageBridgeOptions = {}, -): GraphMessageBridge { - return new GraphMessageBridgeImpl(opts); -} - -class GraphMessageBridgeImpl implements GraphMessageBridge { - private readonly boundaries = new WeakMap>(); - private readonly disposable = new Set(); - private readonly localDiagnostics: NestBoundaryDiagnostic[] = []; - private active = true; - - constructor(private readonly opts: GraphMessageBridgeOptions) {} - - close(): void { - this.dispose(); - } - - onModuleDestroy(): void { - this.dispose(); - } - - handleMessage( - target: DecoratorHostConstructor | object, - methodKey: string | symbol, - host: THost, - runOpts: NestGraphRunOptions = {}, - ): Promise | undefined { - if (!this.active) throw new Error("GraphMessage native bridge is disposed"); - const bindings = getNestBoundaryBindings(target, methodKey); - const ingress = bindings.filter(isMessageIngress); - if (ingress.length === 0) return undefined; - const replies = bindings.filter(isMessageReply); - const needsRequestId = replies.length > 0; - for (const binding of ingress) assertExplicitNativePayload(binding, "GraphMessage"); - const ingressEmits = ingress.map((binding) => ({ - binding, - requestId: bindingRequestId(host, binding, runOpts.requestId ?? this.opts.requestId), - })); - if (needsRequestId && ingressEmits.some((entry) => entry.requestId === undefined)) { - throw new Error("GraphMessage native bridge requires a stable requestId for reply egress"); - } - const requestIds = uniqueDefinedStrings(ingressEmits.map((entry) => entry.requestId)); - const cleanups: Array<() => boolean> = []; - let timeout: ReturnType | undefined; - let settled = false; - let resolvePromise: ((payload: unknown) => void) | undefined; - let rejectPromise: ((error: unknown) => void) | undefined; - let cleaned = false; - const cleanupAll = () => { - if (cleaned) return; - cleaned = true; - for (const cleanup of cleanups) cleanup(); - this.clearTimeout(timeout); - }; - const handle: NestReplyResponseHandle = { - resolve(payload) { - if (settled) return; - settled = true; - resolvePromise?.(payload); - }, - reject(error) { - if (settled) return; - settled = true; - rejectPromise?.(error); - }, - }; - const promise = - replies.length === 0 - ? undefined - : new Promise((resolve, reject) => { - resolvePromise = resolve; - rejectPromise = reject; - }); - if (promise !== undefined) { - try { - for (const requestId of requestIds) { - for (const reply of replies) { - cleanups.push( - this.boundaryFor(reply).attach({ - requestId, - bindingId: reply.bindingId, - handle, - }), - ); - } - } - } catch (error) { - cleanupAll(); - handle.reject(error); - return promise; - } - if (settled) { - cleanupAll(); - return promise; - } - if (this.opts.timeoutMs !== undefined) { - timeout = setTimeout(() => { - cleanupAll(); - const error = new Error( - `GraphMessage native bridge timed out waiting for ${requestIds[0]}`, - ); - this.diagnose({ - kind: "timeout", - requestId: requestIds[0], - message: error.message, - error, - }); - handle.reject(error); - }, this.opts.timeoutMs); - } - } - try { - for (const { binding, requestId } of ingressEmits) { - binding.boundary.emit(host, { - ...bindingEmitOptions(host, binding, requestId), - requireRequestId: needsRequestId, - }); - } - } catch (error) { - cleanupAll(); - throw error; - } - return promise?.finally(() => { - cleanupAll(); - }); - } - - diagnostics(): readonly NestBoundaryDiagnostic[] { - return [ - ...this.localDiagnostics, - ...[...this.disposable].flatMap((boundary) => boundary.diagnostics()), - ]; - } - - dispose(): void { - if (!this.active) return; - this.active = false; - for (const boundary of this.disposable) boundary.dispose(); - } - - private boundaryFor(binding: NestMessageReplyBindingMeta): MessageBoundary { - let byBinding = this.boundaries.get(binding.replyNode); - if (byBinding === undefined) { - byBinding = new Map(); - this.boundaries.set(binding.replyNode, byBinding); - } - const existing = byBinding.get(binding.bindingId); - if (existing !== undefined) return existing; - const boundary = toNestHttp(binding.replyNode, { - bindingId: binding.bindingId, - diagnosticBoundary: this.opts.diagnosticBoundary, - diagnosticPhase: "message", - name: "nestjs.message-reply", - maxDiagnostics: this.opts.maxDiagnostics, - }); - byBinding.set(binding.bindingId, boundary); - this.disposable.add(boundary); - return boundary; - } - - private diagnose(diagnostic: NestBoundaryDiagnostic): void { - this.localDiagnostics.push(diagnostic); - try { - const phase = diagnostic.phase ?? "message"; - const payload = sanitizeNestDiagnostic({ ...diagnostic, phase }, phase); - this.opts.diagnosticBoundary?.emit(payload, { payload }); - } catch { - // Graph-visible diagnostics are optional and must not interrupt host cleanup. - } - if ( - this.opts.maxDiagnostics !== undefined && - this.localDiagnostics.length > this.opts.maxDiagnostics - ) { - this.localDiagnostics.splice(0, this.localDiagnostics.length - this.opts.maxDiagnostics); - } - } - - private clearTimeout(timeout: ReturnType | undefined): void { - if (timeout !== undefined) clearTimeout(timeout); - } -} - -function isMessageIngress(binding: NestBoundaryBindingMeta): binding is NestIngressBindingMeta { - return binding.direction === "ingress" && binding.kind === "message"; -} - -function isMessageReply(binding: NestBoundaryBindingMeta): binding is NestMessageReplyBindingMeta { - return binding.direction === "egress" && binding.kind === "message-reply"; -} - -function assertExplicitNativePayload(binding: NestIngressBindingMeta, label: string): void { - if (binding.payload === undefined) { - throw new Error(`${label} native bridge requires an explicit payload selector`); - } -} - -function uniqueDefinedStrings(values: readonly (string | undefined)[]): string[] { - const seen = new Set(); - for (const value of values) if (value !== undefined) seen.add(value); - return [...seen]; -} - -export { - fromNestMessage, - GraphMessage, - GraphMessageReply, - type NestBoundaryEnvelope, - type NestIngressBoundary, - type NestIngressEmitOptions, - type NestIngressOptions, - type NestReplyEnvelope, -}; diff --git a/packages/ts/src/adapters/nestjs/native.ts b/packages/ts/src/adapters/nestjs/native.ts deleted file mode 100644 index 67a7a8ac..00000000 --- a/packages/ts/src/adapters/nestjs/native.ts +++ /dev/null @@ -1,1214 +0,0 @@ -/** - * Nest-native provider bridge for GraphReFly boundary metadata (D484). - * - * This focused subpath is allowed to import Nest/RxJS. The dependency-light - * `@graphrefly/ts/adapters/nestjs` structural layer stays Nest-free. - */ - -import type { - ArgumentsHost, - CallHandler, - CanActivate, - ExceptionFilter, - ExecutionContext, - OnModuleDestroy, - OnModuleInit, - Provider, -} from "@nestjs/common"; -import { Catch, HttpException, type NestInterceptor } from "@nestjs/common"; -import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core"; -import { from, type Observable } from "rxjs"; -import { - type CronSchedule, - type FromCronOptions, - matchesCron, - parseCron, -} from "../../graph/sources.js"; -import { - bindingEmitOptions, - bindingRequestId, - createNestGraphBoundaryRunner, - type DecoratorBoundMethod, - type DecoratorHostConstructor, - type GraphGuardDecision, - getNestBoundaryBindings, - isDataIssue, - lowerHttpReplyPayload, - lowerProtocolError, - type NestBoundaryBindingMeta, - type NestDiagnosticIngressBoundary, - type NestGraphRunOptions, - type NestHttpReplyBindingMeta, - type NestHttpResponsePayload, - type NestIngressBindingMeta, - type NestIssueResponse, - type NestProtocolErrorResponse, - protocolError, - resolveNestMethodKey, - toNestHttp, -} from "../nestjs.js"; - -export const GRAPHREFLY_NEST_CRON_SCHEDULER = Symbol.for("graphrefly:nest:cron-scheduler"); -export const GRAPHREFLY_NEST_EXCEPTION_FILTER = Symbol.for("graphrefly:nest:exception-filter"); -export const GRAPHREFLY_NEST_LIFECYCLE_HOOKS = Symbol.for("graphrefly:nest:lifecycle-hooks"); - -export interface GraphNativeHostOptions extends NestGraphRunOptions { - readonly host?: (context: ExecutionContext) => THost; - readonly diagnosticBoundary?: NestDiagnosticIngressBoundary; -} - -export interface GraphNativeHttpOptions extends GraphNativeHostOptions { - readonly issueResponse?: NestIssueResponse; - readonly protocolError?: NestProtocolErrorResponse; -} - -export type GraphGuardDecisionWait = - | { readonly mode?: "same-wave" } - | { - readonly mode: "await"; - readonly timeoutMs: number; - readonly maxPending: number; - readonly scope: NestGraphGuardAwaitScope; - readonly hostAbortSignal?: ( - context: ExecutionContext, - host: THost, - ) => AbortSignal | undefined; - }; - -export interface GraphNativeGuardOptions extends GraphNativeHttpOptions { - readonly decisionWait?: GraphGuardDecisionWait; -} - -/** Host-private cancellation lookup for an explicitly asynchronous guard projector. */ -export interface NestGraphGuardAwaitScope { - lookupAbortSignal(invocationId: string): AbortSignal | undefined; - dispose(): void; -} - -interface NestGraphGuardAwaitScopeState { - active: boolean; - readonly entries: Map; -} - -const graphGuardAwaitScopeStates = new WeakMap< - NestGraphGuardAwaitScope, - NestGraphGuardAwaitScopeState ->(); - -/** Creates the host-private bounded cancellation scope used by await-mode GraphGuard adapters. */ -export function createNestGraphGuardAwaitScope(): NestGraphGuardAwaitScope { - const state: NestGraphGuardAwaitScopeState = { active: true, entries: new Map() }; - const scope: NestGraphGuardAwaitScope = { - lookupAbortSignal(invocationId) { - return state.entries.get(invocationId)?.signal; - }, - dispose() { - if (!state.active) return; - state.active = false; - for (const controller of state.entries.values()) controller.abort(); - state.entries.clear(); - }, - }; - graphGuardAwaitScopeStates.set(scope, state); - return scope; -} - -export interface GraphExceptionFilterTarget { - readonly target: DecoratorHostConstructor | object; - readonly methodKey: string | symbol; -} - -export interface GraphExceptionFilterProviderOptions { - readonly host?: (host: ArgumentsHost, exception: unknown) => THost; - readonly target: ( - host: ArgumentsHost, - exception: unknown, - ) => GraphExceptionFilterTarget | undefined; - readonly diagnosticBoundary?: NestDiagnosticIngressBoundary; - readonly requestId?: string | ((host: THost) => string | undefined); - readonly issueResponse?: NestIssueResponse; - readonly protocolError?: NestProtocolErrorResponse; -} - -export interface GraphCronProviderTarget { - readonly target: DecoratorHostConstructor | object; - readonly methodKey?: string | symbol; - readonly expr: string; - readonly tickMs?: number; - readonly timezone?: string; - readonly dst?: FromCronOptions["dst"]; - readonly host?: (date: Date) => THost; -} - -export interface GraphCronSchedulerProviderOptions { - readonly targets: readonly GraphCronProviderTarget[]; -} - -export interface GraphCronController { - check(now: Date): void; -} - -export interface GraphCronControllerOptions { - readonly targets: readonly GraphCronProviderTarget[]; -} - -export interface GraphLifecycleProviderTarget { - readonly target: DecoratorHostConstructor | object; - readonly methodKey?: string | symbol; - readonly event?: "module-init" | "module-destroy"; - readonly host?: (event: "module-init" | "module-destroy") => THost; -} - -export interface GraphLifecycleHooksProviderOptions { - readonly targets: readonly GraphLifecycleProviderTarget[]; -} - -export interface GraphNativeHttpProviderBundleOptions { - readonly boundaryInterceptor?: GraphNativeHttpOptions | false; - readonly guard?: GraphNativeGuardOptions | false; - readonly guardDeniedFilter?: boolean; - readonly exceptionFilter?: GraphExceptionFilterProviderOptions; -} - -export interface GraphNativeProviderBundleOptions { - readonly http?: GraphNativeHttpProviderBundleOptions | false; - readonly cronScheduler?: GraphCronSchedulerProviderOptions; - readonly lifecycleHooks?: GraphLifecycleHooksProviderOptions; -} - -/** - * Creates Nest provider bindings for graph boundary interceptor. - * - * @param opts - Options that configure the helper. - * @returns Nest provider definitions for the requested boundary. - * @category adapters - * @example - * ```ts - * import { provideGraphBoundaryInterceptor } from "@graphrefly/ts/adapters/nestjs/native"; - * ``` - */ -export function provideGraphBoundaryInterceptor( - opts: GraphNativeHttpOptions = {}, -): Provider { - return { provide: APP_INTERCEPTOR, useValue: new GraphBoundaryInterceptorBridge(opts) }; -} - -/** - * Creates Nest provider bindings for graph guard. - * - * @param opts - Options that configure the helper. - * @returns Nest provider definitions for the requested boundary. - * @category adapters - * @example - * ```ts - * import { provideGraphGuard } from "@graphrefly/ts/adapters/nestjs/native"; - * ``` - */ -export function provideGraphGuard( - opts: GraphNativeGuardOptions = {}, -): Provider { - return { provide: APP_GUARD, useValue: new GraphGuardBridge(opts) }; -} - -/** - * Creates Nest provider bindings for graph exception filter. - * - * @param opts - Options that configure the helper. - * @returns Nest provider definitions for the requested boundary. - * @category adapters - * @example - * ```ts - * import { provideGraphExceptionFilter } from "@graphrefly/ts/adapters/nestjs/native"; - * ``` - */ -export function provideGraphExceptionFilter( - opts: GraphExceptionFilterProviderOptions, -): Provider { - return { provide: GRAPHREFLY_NEST_EXCEPTION_FILTER, useValue: createGraphExceptionFilter(opts) }; -} - -/** - * Creates a graph exception filter. - * - * @param opts - Options that configure the helper. - * @returns The create graph exception filter result. - * @category adapters - * @example - * ```ts - * import { createGraphExceptionFilter } from "@graphrefly/ts/adapters/nestjs/native"; - * ``` - */ -export function createGraphExceptionFilter( - opts: GraphExceptionFilterProviderOptions, -): ExceptionFilter & OnModuleDestroy { - return new GraphExceptionFilterBridge(opts); -} - -/** - * Creates Nest provider bindings for graph guard denied filter. - * - * @returns Nest provider definitions for the requested boundary. - * @category adapters - * @example - * ```ts - * import { provideGraphGuardDeniedFilter } from "@graphrefly/ts/adapters/nestjs/native"; - * ``` - */ -export function provideGraphGuardDeniedFilter(): Provider { - return GraphGuardDeniedFilter; -} - -/** - * Creates a graph guard denied filter. - * - * @returns The create graph guard denied filter result. - * @category adapters - * @example - * ```ts - * import { createGraphGuardDeniedFilter } from "@graphrefly/ts/adapters/nestjs/native"; - * ``` - */ -export function createGraphGuardDeniedFilter(): ExceptionFilter { - return new GraphGuardDeniedFilter(); -} - -/** - * Creates Nest provider bindings for graph cron scheduler. - * - * @param opts - Options that configure the helper. - * @returns Nest provider definitions for the requested boundary. - * @category adapters - * @example - * ```ts - * import { provideGraphCronScheduler } from "@graphrefly/ts/adapters/nestjs/native"; - * ``` - */ -export function provideGraphCronScheduler(opts: GraphCronSchedulerProviderOptions): Provider { - return { provide: GRAPHREFLY_NEST_CRON_SCHEDULER, useValue: new GraphCronSchedulerBridge(opts) }; -} - -/** - * Creates Nest provider bindings for graph lifecycle hooks. - * - * @param opts - Options that configure the helper. - * @returns Nest provider definitions for the requested boundary. - * @category adapters - * @example - * ```ts - * import { provideGraphLifecycleHooks } from "@graphrefly/ts/adapters/nestjs/native"; - * ``` - */ -export function provideGraphLifecycleHooks(opts: GraphLifecycleHooksProviderOptions): Provider { - return { - provide: GRAPHREFLY_NEST_LIFECYCLE_HOOKS, - useValue: new GraphLifecycleHooksBridge(opts), - }; -} - -/** - * Creates Nest provider bindings for graph native HTTP providers. - * - * @param opts - Options that configure the helper. - * @returns Nest provider definitions for the requested boundary. - * @category adapters - * @example - * ```ts - * import { provideGraphNativeHttpProviders } from "@graphrefly/ts/adapters/nestjs/native"; - * ``` - */ -export function provideGraphNativeHttpProviders( - opts: GraphNativeHttpProviderBundleOptions = {}, -): Provider[] { - const providers: Provider[] = []; - if (opts.boundaryInterceptor !== false) - providers.push(provideGraphBoundaryInterceptor(opts.boundaryInterceptor ?? {})); - if (opts.guard !== false) providers.push(provideGraphGuard(opts.guard ?? {})); - if (opts.guardDeniedFilter ?? true) providers.push(provideGraphGuardDeniedFilter()); - if (opts.exceptionFilter !== undefined) - providers.push(provideGraphExceptionFilter(opts.exceptionFilter)); - return providers; -} - -/** - * Creates Nest provider bindings for graph native providers. - * - * @param opts - Options that configure the helper. - * @returns Nest provider definitions for the requested boundary. - * @category adapters - * @example - * ```ts - * import { provideGraphNativeProviders } from "@graphrefly/ts/adapters/nestjs/native"; - * ``` - */ -export function provideGraphNativeProviders( - opts: GraphNativeProviderBundleOptions = {}, -): Provider[] { - const providers: Provider[] = []; - if (opts.http !== false) providers.push(...provideGraphNativeHttpProviders(opts.http ?? {})); - if (opts.cronScheduler !== undefined) - providers.push(provideGraphCronScheduler(opts.cronScheduler)); - if (opts.lifecycleHooks !== undefined) - providers.push(provideGraphLifecycleHooks(opts.lifecycleHooks)); - return providers; -} - -/** - * Creates a graph cron target. - * - * @param target - Class or instance that owns the decorated method. - * @param methodKey - Method key on the decorated target. - * @param opts - Options that configure the helper. - * @returns The graph cron target result. - * @category adapters - * @example - * ```ts - * import { graphCronTarget } from "@graphrefly/ts/adapters/nestjs/native"; - * ``` - */ -export function graphCronTarget( - target: DecoratorHostConstructor | object, - methodKey: string | symbol, - opts: Omit, "target" | "methodKey">, -): GraphCronProviderTarget { - return { ...opts, target, methodKey }; -} - -/** - * Creates a graph lifecycle target. - * - * @param target - Class or instance that owns the decorated method. - * @param methodKey - Method key on the decorated target. - * @param opts - Options that configure the helper. - * @returns The graph lifecycle target result. - * @category adapters - * @example - * ```ts - * import { graphLifecycleTarget } from "@graphrefly/ts/adapters/nestjs/native"; - * ``` - */ -export function graphLifecycleTarget( - target: DecoratorHostConstructor | object, - methodKey: string | symbol, - opts: Omit, "target" | "methodKey"> = {}, -): GraphLifecycleProviderTarget { - return { ...opts, target, methodKey }; -} - -/** - * Creates a graph cron controller. - * - * @param opts - Options that configure the helper. - * @returns The create graph cron controller result. - * @category adapters - * @example - * ```ts - * import { createGraphCronController } from "@graphrefly/ts/adapters/nestjs/native"; - * ``` - */ -export function createGraphCronController(opts: GraphCronControllerOptions): GraphCronController { - return new GraphCronControllerImpl(opts); -} - -class GraphBoundaryInterceptorBridge implements NestInterceptor, OnModuleDestroy { - private readonly runner: ReturnType; - - constructor(private readonly opts: GraphNativeHttpOptions) { - this.runner = createNestGraphBoundaryRunner({ - diagnosticBoundary: opts.diagnosticBoundary, - diagnosticPhase: "http", - }); - } - - intercept(context: ExecutionContext, next: CallHandler): Observable { - const host = this.opts.host?.(context) ?? (defaultHttpHost(context) as THost); - const result = this.runner.run( - context.getClass(), - methodKeyForContext(context), - host, - this.opts, - ); - const reply = firstHttpReply(context); - const value = - result === undefined - ? next.handle() - : Promise.resolve(result).then( - (payload) => - writeHttpResponse( - context, - lowerHttpReplyPayload(payload, host, { - issueResponse: reply?.issueResponse ?? this.opts.issueResponse, - }), - ), - (errorPayload) => - writeHttpResponse( - context, - lowerProtocolError(errorPayload, host, { - protocolError: reply?.protocolError ?? this.opts.protocolError, - }), - ), - ); - return isObservableLike(value) ? value : from(Promise.resolve(value)); - } - - onModuleDestroy(): void { - this.runner.dispose(); - } -} - -let nextGraphGuardBridgeId = 0; - -class GraphGuardBridge implements CanActivate, OnModuleDestroy { - private readonly decisions = new WeakMap< - object, - Map>> - >(); - private readonly disposableDecisions = new Set< - ReturnType> - >(); - private readonly bridgeId = ++nextGraphGuardBridgeId; - private nextInvocationId = 0; - private disposed = false; - private readonly activeAwaitSettlements = new Set<() => void>(); - - constructor(private readonly opts: GraphNativeGuardOptions) { - validateGraphGuardDecisionWait(opts.decisionWait); - } - - async canActivate(context: ExecutionContext): Promise { - if (this.disposed) return false; - const methodKey = methodKeyForContext(context); - const bindings = getNestBoundaryBindings(context.getClass(), methodKey); - const guards = bindings.filter(isGuardIngress); - if (guards.length === 0) return true; - const decisions = bindings.filter(isGuardDecision); - if (decisions.length === 0) return false; - const host = this.opts.host?.(context) ?? (defaultHttpHost(context) as THost); - if (this.opts.decisionWait?.mode === "await") { - return this.canActivateAwait(context, host, guards, decisions); - } - const guardEmits = guards.map((guard) => ({ - guard, - requestId: bindingRequestId(host, guard, this.opts.requestId), - })); - if (guardEmits.some((entry) => entry.requestId === undefined)) return false; - const requestIds = new Set(guardEmits.map((entry) => entry.requestId as string)); - const cleanups: Array<() => boolean> = []; - try { - const pending: GuardDecisionState[] = []; - for (const requestId of requestIds) { - for (const decision of decisions) { - const state: GuardDecisionState = { status: "pending", binding: decision }; - pending.push(state); - cleanups.push( - this.decisionBoundary(decision).attach({ - requestId, - bindingId: decision.bindingId, - handle: { - resolve(payload) { - state.status = "resolved"; - state.payload = payload; - }, - reject(error) { - state.status = "rejected"; - state.error = error; - }, - }, - }), - ); - } - } - for (const entry of guardEmits) - entry.guard.boundary.emit(host, bindingEmitOptions(host, entry.guard, entry.requestId)); - const rejected = pending.find((state) => state.status === "rejected"); - if (rejected !== undefined) { - throw new GraphGuardProtocolErrorException( - lowerProtocolError(rejected.error, host, { - protocolError: rejected.binding.protocolError ?? this.opts.protocolError, - }), - ); - } - if (pending.some((state) => state.status !== "resolved")) return false; - const denied = pending.find( - ( - state, - ): state is GuardDecisionState & { - readonly status: "resolved"; - readonly payload: Extract; - } => state.status === "resolved" && state.payload?.kind === "deny", - ); - if (denied !== undefined) { - throw new GraphGuardDeniedException( - guardDecisionResponse(denied.payload, host, { - issueResponse: denied.binding.issueResponse ?? this.opts.issueResponse, - }), - ); - } - return pending.every((state) => state.payload?.kind === "allow"); - } catch (error) { - if (isGraphGuardDeniedException(error) || error instanceof GraphGuardProtocolErrorException) { - throw error; - } - return false; - } finally { - for (const cleanup of cleanups) cleanup(); - } - } - - onModuleDestroy(): void { - if (this.disposed) return; - this.disposed = true; - for (const settle of [...this.activeAwaitSettlements]) settle(); - this.activeAwaitSettlements.clear(); - for (const boundary of this.disposableDecisions) boundary.dispose(); - this.disposableDecisions.clear(); - } - - private async canActivateAwait( - context: ExecutionContext, - host: THost, - guards: readonly NestIngressBindingMeta[], - decisions: readonly NestGuardDecisionBindingMeta[], - ): Promise { - const wait = this.opts.decisionWait; - if (wait?.mode !== "await") return false; - const scopeState = graphGuardAwaitScopeStates.get(wait.scope); - if (scopeState === undefined || !scopeState.active || this.disposed) return false; - if (scopeState.entries.size >= wait.maxPending) { - throw new GraphGuardDeniedException({ - status: 503, - body: { code: "graphrefly.guard.overloaded" }, - }); - } - - const invocationId = `graphrefly:nest-guard:${this.bridgeId}:${++this.nextInvocationId}`; - const controller = new AbortController(); - scopeState.entries.set(invocationId, controller); - const cleanups: Array<() => boolean> = []; - const pending: GuardDecisionState[] = []; - let emittedGuardCount = 0; - let timer: ReturnType | undefined; - let hostSignal: AbortSignal | undefined; - let settled = false; - let settleWait: (() => void) | undefined; - const waitForSettlement = new Promise((resolve) => { - settleWait = resolve; - }); - const settle = () => { - if (settled) return; - settled = true; - settleWait?.(); - }; - const maybeSettle = () => { - if (pending.length > 0 && pending.every((state) => state.status !== "pending")) settle(); - }; - const abort = () => { - controller.abort(); - settle(); - }; - - this.activeAwaitSettlements.add(abort); - controller.signal.addEventListener("abort", settle, { once: true }); - try { - for (const decision of decisions) { - const state: GuardDecisionState = { status: "pending", binding: decision }; - pending.push(state); - cleanups.push( - this.decisionBoundary(decision).attach({ - requestId: invocationId, - bindingId: decision.bindingId, - handle: { - resolve(payload) { - if (state.status !== "pending") return; - state.premature = emittedGuardCount < guards.length; - state.status = "resolved"; - state.payload = payload; - maybeSettle(); - }, - reject(error) { - if (state.status !== "pending") return; - state.status = "rejected"; - state.error = error; - maybeSettle(); - }, - }, - }), - ); - } - hostSignal = wait.hostAbortSignal?.(context, host); - if (hostSignal?.aborted) abort(); - else hostSignal?.addEventListener("abort", abort, { once: true }); - timer = setTimeout(abort, wait.timeoutMs); - if (!controller.signal.aborted) { - for (const guard of guards) { - emittedGuardCount += 1; - guard.boundary.emit(host, bindingEmitOptions(host, guard, invocationId)); - } - } - maybeSettle(); - await waitForSettlement; - if (controller.signal.aborted || this.disposed) return false; - if (pending.some((state) => state.premature)) return false; - const rejected = pending.find((state) => state.status === "rejected"); - if (rejected !== undefined) { - throw new GraphGuardProtocolErrorException( - lowerProtocolError(rejected.error, host, { - protocolError: rejected.binding.protocolError ?? this.opts.protocolError, - }), - ); - } - const denied = pending.find( - ( - state, - ): state is GuardDecisionState & { - readonly status: "resolved"; - readonly payload: Extract; - } => state.status === "resolved" && state.payload?.kind === "deny", - ); - if (denied !== undefined) { - throw new GraphGuardDeniedException( - guardDecisionResponse(denied.payload, host, { - issueResponse: denied.binding.issueResponse ?? this.opts.issueResponse, - }), - ); - } - return pending.every((state) => state.payload?.kind === "allow"); - } catch (error) { - if (isGraphGuardDeniedException(error) || error instanceof GraphGuardProtocolErrorException) { - throw error; - } - return false; - } finally { - if (timer !== undefined) clearTimeout(timer); - hostSignal?.removeEventListener("abort", abort); - controller.signal.removeEventListener("abort", settle); - for (const cleanup of cleanups) cleanup(); - controller.abort(); - scopeState.entries.delete(invocationId); - this.activeAwaitSettlements.delete(abort); - } - } - - private decisionBoundary( - binding: NestGuardDecisionBindingMeta, - ): ReturnType> { - const node = binding.decisionNode as object; - let byBinding = this.decisions.get(node); - if (byBinding === undefined) { - byBinding = new Map(); - this.decisions.set(node, byBinding); - } - const existing = byBinding.get(binding.bindingId); - if (existing !== undefined) return existing; - const boundary = toNestHttp(binding.decisionNode, { - bindingId: binding.bindingId, - diagnosticBoundary: this.opts.diagnosticBoundary, - diagnosticPhase: "guard", - }); - byBinding.set(binding.bindingId, boundary); - this.disposableDecisions.add(boundary); - return boundary; - } -} - -/** - * Represents a graph exception filter bridge. - * - * @category adapters - * @example - * ```ts - * import { GraphExceptionFilterBridge } from "@graphrefly/ts/adapters/nestjs/native"; - * ``` - */ -export class GraphExceptionFilterBridge implements ExceptionFilter, OnModuleDestroy { - private readonly replies = new WeakMap< - object, - Map>> - >(); - private readonly disposableReplies = new Set< - ReturnType> - >(); - - constructor(private readonly opts: GraphExceptionFilterProviderOptions) {} - - catch(exception: unknown, host: ArgumentsHost): unknown { - const target = this.opts.target(host, exception); - if (target === undefined) throw exception; - const bindings = getNestBoundaryBindings(target.target, target.methodKey); - const filters = bindings.filter(isErrorIngress); - if (filters.length === 0) throw exception; - const nativeHost = - this.opts.host?.(host, exception) ?? (defaultArgumentsHost(host, exception) as THost); - const filterEmits = filters.map((filter) => ({ - filter, - requestId: bindingRequestId(nativeHost, filter, this.opts.requestId), - })); - const handleFilters = filterEmits.filter((entry) => entry.filter.mode !== "observe"); - const observeOnly = handleFilters.length === 0; - const replies = bindings.filter(isHttpReply); - const cleanups: Array<() => boolean> = []; - const emitFilters = () => { - for (const entry of filterEmits) { - entry.filter.boundary.emit( - nativeHost, - bindingEmitOptions(nativeHost, entry.filter, entry.requestId), - ); - } - }; - if (observeOnly) { - emitFilters(); - throw exception; - } - const requestIds = new Set( - handleFilters - .map((entry) => entry.requestId) - .filter((requestId): requestId is string => requestId !== undefined), - ); - if (replies.length === 0 || requestIds.size === 0) { - for (const entry of filterEmits) { - if (entry.requestId === undefined) continue; - entry.filter.boundary.emit( - nativeHost, - bindingEmitOptions(nativeHost, entry.filter, entry.requestId), - ); - } - const filter = handleFilters[0]?.filter; - const lowered = lowerCaughtException(exception, nativeHost, { - issueResponse: filter?.issueResponse ?? this.opts.issueResponse, - protocolError: filter?.protocolError ?? this.opts.protocolError, - }); - return writeHttpResponse(host, lowered); - } - const pending: FilterReplyState[] = []; - try { - for (const requestId of requestIds) { - for (const reply of replies) { - const state: FilterReplyState = { status: "pending", reply }; - pending.push(state); - cleanups.push( - this.replyBoundary(reply).attach({ - requestId, - bindingId: reply.bindingId, - handle: { - resolve(payload) { - state.status = "resolved"; - state.payload = payload; - }, - reject(error) { - state.status = "rejected"; - state.error = error; - }, - }, - }), - ); - } - } - emitFilters(); - const resolved = pending.find((state) => state.status === "resolved"); - if (resolved !== undefined) { - return writeHttpResponse( - host, - lowerHttpReplyPayload(resolved.payload, nativeHost, { - issueResponse: - resolved.reply.issueResponse ?? - handleFilters[0]?.filter.issueResponse ?? - this.opts.issueResponse, - }), - ); - } - const rejected = pending.find((state) => state.status === "rejected"); - if (rejected !== undefined) { - return writeHttpResponse( - host, - lowerProtocolError(rejected.error, nativeHost, { - protocolError: - rejected.reply.protocolError ?? - handleFilters[0]?.filter.protocolError ?? - this.opts.protocolError, - }), - ); - } - const filter = handleFilters[0]?.filter; - return writeHttpResponse( - host, - lowerCaughtException(exception, nativeHost, { - issueResponse: filter?.issueResponse ?? this.opts.issueResponse, - protocolError: filter?.protocolError ?? this.opts.protocolError, - }), - ); - } finally { - for (const cleanup of cleanups) cleanup(); - } - } - - onModuleDestroy(): void { - for (const boundary of this.disposableReplies) boundary.dispose(); - this.disposableReplies.clear(); - } - - private replyBoundary( - binding: NestHttpReplyBindingMeta, - ): ReturnType> { - const node = binding.replyNode as object; - let byBinding = this.replies.get(node); - if (byBinding === undefined) { - byBinding = new Map(); - this.replies.set(node, byBinding); - } - const existing = byBinding.get(binding.bindingId); - if (existing !== undefined) return existing; - const boundary = toNestHttp(binding.replyNode as NodeReplyPayload, { - bindingId: binding.bindingId, - diagnosticBoundary: this.opts.diagnosticBoundary, - diagnosticPhase: "filter", - }); - byBinding.set(binding.bindingId, boundary); - this.disposableReplies.add(boundary); - return boundary; - } -} - -class GraphCronControllerImpl implements GraphCronController { - private readonly targets: Array<{ - readonly target: GraphCronProviderTarget; - readonly schedule: CronSchedule; - readonly fired: Set; - }>; - - constructor(opts: GraphCronControllerOptions) { - this.targets = opts.targets.map((target) => ({ - target, - schedule: parseCron(target.expr), - fired: new Set(), - })); - } - - check(now: Date): void { - for (const entry of this.targets) { - emitCronTarget(entry.target, entry.schedule, entry.fired, now); - } - } -} - -class GraphCronSchedulerBridge implements OnModuleInit, OnModuleDestroy { - private readonly timers: Array> = []; - - constructor(private readonly opts: GraphCronSchedulerProviderOptions) {} - - onModuleInit(): void { - try { - for (const target of this.opts.targets) this.startTarget(target); - } catch (error) { - this.onModuleDestroy(); - throw error; - } - } - - onModuleDestroy(): void { - for (const timer of this.timers.splice(0)) clearInterval(timer); - } - - private startTarget(target: GraphCronProviderTarget): void { - const tickMs = target.tickMs ?? 60_000; - if (!Number.isFinite(tickMs) || tickMs <= 0) { - throw new RangeError("provideGraphCronScheduler: tickMs must be a positive finite number"); - } - const controller = createGraphCronController({ targets: [target] }); - const check = () => controller.check(new Date()); - check(); - this.timers.push(setInterval(check, tickMs)); - } -} - -class GraphLifecycleHooksBridge implements OnModuleInit, OnModuleDestroy { - constructor(private readonly opts: GraphLifecycleHooksProviderOptions) {} - - onModuleInit(): void { - this.emit("module-init"); - } - - onModuleDestroy(): void { - this.emit("module-destroy"); - } - - private emit(event: "module-init" | "module-destroy"): void { - for (const target of this.opts.targets) { - if (target.event !== undefined && target.event !== event) continue; - const host = target.host?.(event) ?? { event }; - for (const binding of lifecycleBindings(target)) { - binding.boundary.emit(host, bindingEmitOptions(host, binding, undefined)); - } - } - } -} - -type NestHttpReplyPayloadUnknown = unknown; -type NodeReplyPayload = Parameters>[0]; -type NestGuardDecisionBindingMeta = Extract; -interface GuardDecisionState { - status: "pending" | "resolved" | "rejected"; - binding: NestGuardDecisionBindingMeta; - premature?: boolean; - payload?: GraphGuardDecision; - error?: unknown; -} - -interface FilterReplyState { - status: "pending" | "resolved" | "rejected"; - reply: NestHttpReplyBindingMeta; - payload?: NestHttpReplyPayloadUnknown; - error?: unknown; -} - -function validateGraphGuardDecisionWait( - wait: GraphGuardDecisionWait | undefined, -): void { - if (wait?.mode !== "await") return; - if (!Number.isFinite(wait.timeoutMs) || wait.timeoutMs <= 0) { - throw new RangeError("provideGraphGuard: await timeoutMs must be a positive finite number"); - } - if (!Number.isFinite(wait.maxPending) || wait.maxPending < 1) { - throw new RangeError("provideGraphGuard: await maxPending must be a positive finite number"); - } - if (Math.floor(wait.maxPending) !== wait.maxPending) { - throw new RangeError("provideGraphGuard: await maxPending must be an integer"); - } - if (!graphGuardAwaitScopeStates.has(wait.scope)) { - throw new TypeError( - "provideGraphGuard: await scope must be created by createNestGraphGuardAwaitScope", - ); - } -} - -function methodKeyForContext(context: ExecutionContext): string | symbol { - const resolved = resolveNestMethodKey( - context.getClass(), - context.getHandler() as DecoratorBoundMethod, - ); - return resolved ?? context.getHandler().name; -} - -function defaultHttpHost(context: ExecutionContext): unknown { - const request = context.switchToHttp?.().getRequest?.(); - return request ?? {}; -} - -function defaultArgumentsHost(host: ArgumentsHost, exception: unknown): unknown { - const request = host.switchToHttp?.().getRequest?.(); - if (request !== undefined && request !== null) return { ...request, exception }; - return { exception }; -} - -function isObservableLike(value: unknown): value is Observable { - return ( - value !== null && - typeof value === "object" && - typeof (value as { subscribe?: unknown }).subscribe === "function" - ); -} - -function isGuardIngress(binding: NestBoundaryBindingMeta): binding is NestIngressBindingMeta { - return binding.direction === "ingress" && binding.kind === "guard"; -} - -function isErrorIngress(binding: NestBoundaryBindingMeta): binding is NestIngressBindingMeta { - return binding.direction === "ingress" && binding.kind === "error"; -} - -function isHttpReply(binding: NestBoundaryBindingMeta): binding is NestHttpReplyBindingMeta { - return binding.direction === "egress" && binding.kind === "http"; -} - -function isGuardDecision( - binding: NestBoundaryBindingMeta, -): binding is NestGuardDecisionBindingMeta { - return binding.direction === "egress" && binding.kind === "guard-decision"; -} - -function firstHttpReply(context: ExecutionContext): NestHttpReplyBindingMeta | undefined { - return getNestBoundaryBindings(context.getClass(), methodKeyForContext(context)).find( - isHttpReply, - ); -} - -function lifecycleBindings( - target: GraphLifecycleProviderTarget, -): readonly NestIngressBindingMeta[] { - return getNestBoundaryBindings(target.target, target.methodKey).filter( - (binding): binding is NestIngressBindingMeta => - binding.direction === "ingress" && binding.kind === "lifecycle", - ); -} - -function cronBindings(target: GraphCronProviderTarget): readonly NestIngressBindingMeta[] { - return getNestBoundaryBindings(target.target, target.methodKey).filter( - (binding): binding is NestIngressBindingMeta => - binding.direction === "ingress" && binding.kind === "cron", - ); -} - -function emitCronTarget( - target: GraphCronProviderTarget, - schedule: CronSchedule, - fired: Set, - now: Date, -): void { - if (!matchesCron(schedule, now, { timezone: target.timezone })) return; - const key = cronProviderMinuteKey(now, target.timezone); - for (const existing of fired) { - if (!existing.startsWith(`${key.dayKey}:`)) fired.delete(existing); - } - if (fired.has(key.minuteKey)) return; - fired.add(key.minuteKey); - const host = - target.host?.(now) ?? - ({ - iso: now.toISOString(), - timestamp_ms: now.getTime(), - timestamp_ns: (BigInt(now.getTime()) * 1_000_000n).toString(), - timezone: target.timezone, - } satisfies Record); - for (const binding of cronBindings(target)) { - binding.boundary.emit(host, bindingEmitOptions(host, binding, undefined)); - } -} - -function cronProviderMinuteKey( - date: Date, - timezone?: string, -): { readonly dayKey: string; readonly minuteKey: string } { - if (timezone === undefined) { - const dayKey = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; - return { dayKey, minuteKey: `${dayKey}:${date.getHours()}:${date.getMinutes()}` }; - } - const parts = new Intl.DateTimeFormat("en-US-u-ca-gregory-nu-latn", { - timeZone: timezone, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hourCycle: "h23", - }).formatToParts(date); - const byType = new Map(parts.map((part) => [part.type, part.value])); - const dayKey = `${byType.get("year")}-${byType.get("month")}-${byType.get("day")}`; - return { dayKey, minuteKey: `${dayKey}:${byType.get("hour")}:${byType.get("minute")}` }; -} - -function lowerCaughtException( - exception: unknown, - host: THost, - opts: { - readonly issueResponse?: NestIssueResponse; - readonly protocolError?: NestProtocolErrorResponse; - }, -): ReturnType { - if (isDataIssue(exception)) return lowerHttpReplyPayload(exception, host, opts); - return (opts.protocolError ?? protocolError)(exception, host); -} - -function guardDecisionResponse( - decision: Extract, - host: THost, - opts: { readonly issueResponse?: NestIssueResponse }, -): NestHttpResponsePayload { - if (decision.issue !== undefined) return lowerHttpReplyPayload(decision.issue, host, opts); - if (Number.isInteger(decision.status)) { - return { - status: decision.status as number, - body: decision.body ?? { - code: "graphrefly.guard_denied", - message: decision.reason ?? "GraphReFly guard denied request", - }, - headers: decision.headers, - }; - } - return { - status: 403, - body: { - code: "graphrefly.guard_denied", - message: decision.reason ?? "GraphReFly guard denied request", - }, - headers: decision.headers, - }; -} - -/** - * Represents a graph guard denied exception. - * - * @category adapters - * @example - * ```ts - * import { GraphGuardDeniedException } from "@graphrefly/ts/adapters/nestjs/native"; - * ``` - */ -export class GraphGuardDeniedException extends HttpException { - readonly payload: NestHttpResponsePayload; - - constructor(payload: NestHttpResponsePayload) { - super(payload.body ?? {}, payload.status); - this.payload = payload; - } -} - -class GraphGuardProtocolErrorException extends HttpException { - constructor(payload: NestHttpResponsePayload) { - super(payload.body ?? {}, payload.status); - } -} - -/** - * Checks whether a value is a graph guard denied exception. - * - * @param value - Unknown value to check or decode. - * @returns `true` when the value matches the expected shape. - * @category adapters - * @example - * ```ts - * import { isGraphGuardDeniedException } from "@graphrefly/ts/adapters/nestjs/native"; - * ``` - */ -export function isGraphGuardDeniedException(value: unknown): value is GraphGuardDeniedException { - return value instanceof GraphGuardDeniedException; -} - -/** - * Represents a graph guard denied filter. - * - * @category adapters - * @example - * ```ts - * import { GraphGuardDeniedFilter } from "@graphrefly/ts/adapters/nestjs/native"; - * ``` - */ -export class GraphGuardDeniedFilter implements ExceptionFilter { - catch(exception: unknown, host: ArgumentsHost): unknown { - if (!isGraphGuardDeniedException(exception)) throw exception; - return writeHttpResponse(host, exception.payload); - } -} - -Catch(GraphGuardDeniedException)(GraphGuardDeniedFilter); - -function writeHttpResponse( - host: ArgumentsHost, - payload: ReturnType, -): unknown { - const response = host.switchToHttp?.().getResponse?.() as - | { - status?: (status: number) => unknown; - json?: (body: unknown) => unknown; - send?: (body?: unknown) => unknown; - setHeader?: (name: string, value: string) => void; - header?: (name: string, value: string) => void; - } - | undefined; - if (response === undefined) return payload; - for (const [name, value] of Object.entries(payload.headers ?? {})) { - if (typeof response.setHeader === "function") response.setHeader(name, value); - else response.header?.(name, value); - } - response.status?.(payload.status); - if (typeof response.json === "function") return response.json(payload.body ?? {}); - if (typeof response.send === "function") return response.send(payload.body); - return payload; -} diff --git a/packages/ts/src/adapters/nestjs/websockets.ts b/packages/ts/src/adapters/nestjs/websockets.ts deleted file mode 100644 index 83139c24..00000000 --- a/packages/ts/src/adapters/nestjs/websockets.ts +++ /dev/null @@ -1,427 +0,0 @@ -/** - * NestJS WebSocket native phase bridge (D488). - * - * This focused subpath may import `@nestjs/websockets`; the dependency-light - * structural layer and HTTP native bridge stay free of that optional peer. - */ - -import type { OnGatewayDisconnect } from "@nestjs/websockets"; -import { - bindingEmitOptions, - bindingRequestId, - type DecoratorHostConstructor, - fromNestWs, - GraphWs, - GraphWsAck, - GraphWsReply, - getNestBoundaryBindings, - type NestBoundaryBindingMeta, - type NestBoundaryDiagnostic, - type NestBoundaryEnvelope, - type NestDiagnosticIngressBoundary, - type NestGraphRunOptions, - type NestIngressBindingMeta, - type NestIngressBoundary, - type NestIngressEmitOptions, - type NestIngressOptions, - type NestProviderBinding, - type NestReplyEnvelope, - type NestReplyResponseHandle, - type NestWsAckBindingMeta, - type NestWsReplyBindingMeta, - sanitizeNestDiagnostic, - toNestHttp, -} from "../nestjs.js"; - -/** D488 provider token for the focused Nest WebSocket native bridge. */ -export const GRAPHREFLY_NEST_WS_BRIDGE = Symbol.for("graphrefly:nest:ws-bridge"); - -/** Options for the D488 Nest WebSocket bridge; socket and ack handles stay host-private. */ -export interface GraphWsBridgeOptions extends NestGraphRunOptions { - readonly ack?: (host: THost) => (payload: unknown, envelope: NestReplyEnvelope) => void; - readonly client?: (host: THost) => object | undefined; - readonly diagnosticBoundary?: NestDiagnosticIngressBoundary; - readonly timeoutMs?: number; - readonly maxDiagnostics?: number; -} - -/** D495 focused WebSocket provider bundle options. */ -export interface GraphWsProviderBundleOptions { - readonly bridge?: GraphWsBridgeOptions | false; -} - -/** Explicit Nest WebSocket phase bridge over `GraphWs`, `GraphWsAck`, and `GraphWsReply` metadata. */ -export interface GraphWsBridge extends OnGatewayDisconnect { - handleMessage( - target: DecoratorHostConstructor | object, - methodKey: string | symbol, - host: THost, - opts?: NestGraphRunOptions, - ): Promise | undefined; - onModuleDestroy(): void; - diagnostics(): readonly NestBoundaryDiagnostic[]; - dispose(): void; -} - -type WsEgressBinding = NestWsAckBindingMeta | NestWsReplyBindingMeta; -type WsBoundary = ReturnType>; -interface WsPending { - readonly requestId: string; - readonly cleanups: Array<() => boolean>; - readonly reject: (error: unknown) => void; - timeout?: ReturnType; - settled: boolean; -} - -/** Build a dependency-light Nest provider for the focused WebSocket bridge token. - * @param opts - Options that configure the helper. - * @returns A `NestProviderBinding>` value. - * @category adapters - * @example - * ```ts - * import { provideGraphWsBridge } from "@graphrefly/ts/adapters/nestjs/websockets"; - * ``` - */ -export function provideGraphWsBridge( - opts: GraphWsBridgeOptions = {}, -): NestProviderBinding> { - return { provide: GRAPHREFLY_NEST_WS_BRIDGE, useValue: createGraphWsBridge(opts) }; -} - -/** Build the D495 focused WebSocket provider bundle without scanning or creating graphs. - * @param opts - Options that configure the helper. - * @returns A `NestProviderBinding>[]` value. - * @category adapters - * @example - * ```ts - * import { provideGraphWsProviders } from "@graphrefly/ts/adapters/nestjs/websockets"; - * ``` - */ -export function provideGraphWsProviders( - opts: GraphWsProviderBundleOptions = {}, -): NestProviderBinding>[] { - return opts.bridge === false ? [] : [provideGraphWsBridge(opts.bridge ?? {})]; -} - -/** Create a host-private WebSocket bridge instance without scanning the Nest container. - * @param opts - Options that configure the helper. - * @returns A `GraphWsBridge` value. - * @category adapters - * @example - * ```ts - * import { createGraphWsBridge } from "@graphrefly/ts/adapters/nestjs/websockets"; - * ``` - */ -export function createGraphWsBridge( - opts: GraphWsBridgeOptions = {}, -): GraphWsBridge { - return new GraphWsBridgeImpl(opts); -} - -class GraphWsBridgeImpl implements GraphWsBridge { - private readonly boundaries = new WeakMap>(); - private readonly disposable = new Set(); - private readonly pendingByClient = new WeakMap>(); - private readonly localDiagnostics: NestBoundaryDiagnostic[] = []; - private active = true; - - constructor(private readonly opts: GraphWsBridgeOptions) {} - - handleDisconnect(client?: unknown): void { - if (client === null || typeof client !== "object") return; - const pending = this.pendingByClient.get(client); - if (pending === undefined) return; - for (const entry of [...pending]) { - this.rejectPending( - entry, - new Error(`GraphWs native bridge disconnected before ${entry.requestId} resolved`), - "dispose-pending", - ); - } - this.pendingByClient.delete(client); - } - - onModuleDestroy(): void { - this.dispose(); - } - - handleMessage( - target: DecoratorHostConstructor | object, - methodKey: string | symbol, - host: THost, - runOpts: NestGraphRunOptions = {}, - ): Promise | undefined { - if (!this.active) throw new Error("GraphWs native bridge is disposed"); - const bindings = wsBindings(target, methodKey); - const ingress = bindings.filter(isWsIngress); - if (ingress.length === 0) return undefined; - const egress = bindings.filter(isWsEgress); - const needsRequestId = egress.length > 0; - for (const binding of ingress) assertExplicitNativePayload(binding, "GraphWs"); - const ingressEmits = ingress.map((binding) => ({ - binding, - requestId: bindingRequestId(host, binding, runOpts.requestId ?? this.opts.requestId), - })); - if (needsRequestId && ingressEmits.some((entry) => entry.requestId === undefined)) { - throw new Error("GraphWs native bridge requires a stable requestId for ack/reply egress"); - } - const requestIds = uniqueDefinedStrings(ingressEmits.map((entry) => entry.requestId)); - const cleanups: Array<() => boolean> = []; - let settled = false; - const replyBindings = egress.filter(isWsReply); - const settleOnAck = replyBindings.length === 0; - let activePending: WsPending | undefined; - let resolvePromise: ((payload: unknown) => void) | undefined; - let rejectPromise: ((error: unknown) => void) | undefined; - let cleaned = false; - const cleanupAll = () => { - if (cleaned) return; - cleaned = true; - for (const cleanup of cleanups) cleanup(); - if (activePending?.timeout !== undefined) clearTimeout(activePending.timeout); - this.unregisterClientPending(host, activePending); - }; - const settleResolve = (payload: unknown) => { - if (settled) return; - settled = true; - if (activePending !== undefined) activePending.settled = true; - resolvePromise?.(payload); - }; - const settleReject = (error: unknown) => { - if (settled) return; - settled = true; - if (activePending !== undefined) activePending.settled = true; - rejectPromise?.(error); - }; - const promise = - egress.length === 0 - ? undefined - : new Promise((resolve, reject) => { - resolvePromise = resolve; - rejectPromise = reject; - }); - if (promise !== undefined) { - activePending = { - requestId: requestIds[0], - cleanups, - reject: settleReject, - settled: false, - }; - try { - for (const requestId of requestIds) { - for (const binding of egress) { - const boundary = this.boundaryFor(binding); - cleanups.push( - boundary.attach({ - requestId, - bindingId: binding.bindingId, - handle: this.handleFor(binding, host, settleResolve, settleReject, settleOnAck), - }), - ); - } - } - } catch (error) { - cleanupAll(); - settleReject(error); - return promise; - } - if (settled) { - cleanupAll(); - return promise; - } - try { - this.registerClientPending(host, activePending); - } catch (error) { - cleanupAll(); - settleReject(error); - return promise; - } - if (this.opts.timeoutMs !== undefined) { - activePending.timeout = setTimeout(() => { - const error = new Error(`GraphWs native bridge timed out waiting for ${requestIds[0]}`); - this.rejectPending(activePending as WsPending, error, "timeout"); - }, this.opts.timeoutMs); - } - } - try { - for (const { binding, requestId } of ingressEmits) { - binding.boundary.emit(host, { - ...bindingEmitOptions(host, binding, requestId), - requireRequestId: needsRequestId, - }); - } - } catch (error) { - cleanupAll(); - throw error; - } - return promise?.finally(() => { - cleanupAll(); - }); - } - - diagnostics(): readonly NestBoundaryDiagnostic[] { - return [ - ...this.localDiagnostics, - ...[...this.disposable].flatMap((boundary) => boundary.diagnostics()), - ]; - } - - dispose(): void { - if (!this.active) return; - this.active = false; - for (const boundary of this.disposable) boundary.dispose(); - } - - private boundaryFor(binding: WsEgressBinding): WsBoundary { - const node = binding.kind === "ws-ack" ? binding.ackNode : binding.replyNode; - let byBinding = this.boundaries.get(node); - if (byBinding === undefined) { - byBinding = new Map(); - this.boundaries.set(node, byBinding); - } - const existing = byBinding.get(binding.bindingId); - if (existing !== undefined) return existing; - const boundary = toNestHttp(node, { - bindingId: binding.bindingId, - diagnosticBoundary: this.opts.diagnosticBoundary, - diagnosticPhase: "ws", - name: `nestjs.${binding.kind}`, - maxDiagnostics: this.opts.maxDiagnostics, - }); - byBinding.set(binding.bindingId, boundary); - this.disposable.add(boundary); - return boundary; - } - - private handleFor( - binding: WsEgressBinding, - host: THost, - resolve: (payload: unknown) => void, - reject: (error: unknown) => void, - settleOnAck: boolean, - ): NestReplyResponseHandle { - return { - resolve: (payload, envelope) => { - if (binding.kind === "ws-ack") { - this.opts.ack?.(host)?.(payload, envelope); - if (settleOnAck) resolve(payload); - return; - } - resolve(payload); - }, - reject, - }; - } - - private diagnose(diagnostic: NestBoundaryDiagnostic): void { - this.localDiagnostics.push(diagnostic); - try { - const phase = diagnostic.phase ?? "ws"; - const payload = sanitizeNestDiagnostic({ ...diagnostic, phase }, phase); - this.opts.diagnosticBoundary?.emit(payload, { payload }); - } catch { - // Graph-visible diagnostics are optional and must not interrupt host cleanup. - } - if ( - this.opts.maxDiagnostics !== undefined && - this.localDiagnostics.length > this.opts.maxDiagnostics - ) { - this.localDiagnostics.splice(0, this.localDiagnostics.length - this.opts.maxDiagnostics); - } - } - - private registerClientPending(host: THost, pending: WsPending | undefined): void { - if (pending === undefined) return; - const client = this.clientFor(host); - if (client === undefined) return; - let set = this.pendingByClient.get(client); - if (set === undefined) { - set = new Set(); - this.pendingByClient.set(client, set); - } - set.add(pending); - } - - private unregisterClientPending(host: THost, pending: WsPending | undefined): void { - if (pending === undefined) return; - const client = this.clientFor(host); - if (client === undefined) return; - const set = this.pendingByClient.get(client); - if (set === undefined) return; - set.delete(pending); - if (set.size === 0) this.pendingByClient.delete(client); - } - - private rejectPending( - pending: WsPending, - error: unknown, - kind: "dispose-pending" | "timeout", - ): void { - if (pending.settled) return; - pending.settled = true; - for (const cleanup of pending.cleanups) cleanup(); - if (pending.timeout !== undefined) clearTimeout(pending.timeout); - this.diagnose({ - kind, - requestId: pending.requestId, - message: error instanceof Error ? error.message : String(error), - error, - }); - pending.reject(error); - } - - private clientFor(host: THost): object | undefined { - const fromOption = this.opts.client?.(host); - if (fromOption !== null && typeof fromOption === "object") return fromOption; - if (host === null || typeof host !== "object") return undefined; - const record = host as { readonly client?: unknown; readonly socket?: unknown }; - if (record.client !== null && typeof record.client === "object") return record.client; - if (record.socket !== null && typeof record.socket === "object") return record.socket; - return undefined; - } -} - -function wsBindings( - target: DecoratorHostConstructor | object, - methodKey: string | symbol, -): readonly NestBoundaryBindingMeta[] { - return getNestBoundaryBindings(target, methodKey); -} - -function isWsIngress(binding: NestBoundaryBindingMeta): binding is NestIngressBindingMeta { - return binding.direction === "ingress" && binding.kind === "ws"; -} - -function isWsEgress(binding: NestBoundaryBindingMeta): binding is WsEgressBinding { - return ( - binding.direction === "egress" && (binding.kind === "ws-ack" || binding.kind === "ws-reply") - ); -} - -function isWsReply(binding: WsEgressBinding): binding is NestWsReplyBindingMeta { - return binding.kind === "ws-reply"; -} - -function assertExplicitNativePayload(binding: NestIngressBindingMeta, label: string): void { - if (binding.payload === undefined) { - throw new Error(`${label} native bridge requires an explicit payload selector`); - } -} - -function uniqueDefinedStrings(values: readonly (string | undefined)[]): string[] { - const seen = new Set(); - for (const value of values) if (value !== undefined) seen.add(value); - return [...seen]; -} - -export { - fromNestWs, - GraphWs, - GraphWsAck, - GraphWsReply, - type NestBoundaryEnvelope, - type NestIngressBoundary, - type NestIngressEmitOptions, - type NestIngressOptions, - type NestReplyEnvelope, -}; diff --git a/packages/ts/src/adapters/react.ts b/packages/ts/src/adapters/react.ts deleted file mode 100644 index 60dda645..00000000 --- a/packages/ts/src/adapters/react.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * React node bindings for GraphReFly (D238). - * - * React is imported only from this focused subpath. The dependency-free - * `@graphrefly/ts/adapters` barrel keeps the framework-neutral store contract. - */ - -import { useCallback, useMemo, useSyncExternalStore } from "react"; -import type { Node } from "../node/node.js"; -import { externalStore, recordReadableStore, type WritableNode } from "./store.js"; - -function assertDataValue(value: unknown): void { - if (value === undefined) { - throw new TypeError("useNodeInput: undefined is SENTINEL/no DATA, not a writable DATA value"); - } -} - -/** Read a GraphReFly node through React's useSyncExternalStore contract. - * @param node - Node to observe, adapt, or connect. - * @returns A `T | undefined` value. - * @category adapters - * @example - * ```ts - * import { useNodeValue } from "@graphrefly/ts/adapters/react"; - * ``` - */ -export function useNodeValue(node: Node): T | undefined { - const store = useMemo(() => externalStore(node), [node]); - return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot); -} - -/** - * Bind a writable GraphReFly node as `[value, setValue]`. - * - * The setter identity is stable for a stable node identity, and writes through - * the node's reactive DATA boundary rather than a presentation-owned trigger. - * @param node - Node to observe, adapt, or connect. - * @returns A `readonly [T | undefined, (value: T) => void]` value. - * @category adapters - * @example - * ```ts - * import { useNodeInput } from "@graphrefly/ts/adapters/react"; - * ``` - */ -export function useNodeInput( - node: WritableNode, -): readonly [T | undefined, (value: T) => void] { - const value = useNodeValue(node); - const setValue = useCallback( - (next: T) => { - assertDataValue(next); - node.set(next); - }, - [node], - ); - return [value, setValue] as const; -} - -/** - * Read a keyed record of nodes. - * - * `factory` must have stable identity. Recreating it on every render forces the - * record subscription graph to rebuild on every render. - * @param keysNode - keys node value used by the helper. - * @param factory - factory value used by the helper. - * @returns A `Record` value. - * @category adapters - * @example - * ```ts - * import { useNodeRecord } from "@graphrefly/ts/adapters/react"; - * ``` - */ -export function useNodeRecord>( - keysNode: Node, - factory: (key: K) => { [P in keyof R]: Node }, -): Record { - const store = useMemo(() => { - const recordStore = recordReadableStore(keysNode, factory); - let current = recordStore.get() ?? ({} as Record); - return { - getSnapshot: () => current, - subscribe(onStoreChange: () => void) { - return recordStore.subscribe((next) => { - current = next ?? ({} as Record); - onStoreChange(); - }); - }, - }; - }, [keysNode, factory]); - const getSnapshot = useCallback(() => store.getSnapshot(), [store]); - return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); -} diff --git a/packages/ts/src/adapters/solid.ts b/packages/ts/src/adapters/solid.ts deleted file mode 100644 index a219e94a..00000000 --- a/packages/ts/src/adapters/solid.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Solid node bindings for GraphReFly (D238). - * - * Solid is imported only from this focused subpath. - */ - -import { type Accessor, createSignal, onCleanup } from "solid-js"; -import type { Node } from "../node/node.js"; -import { readableStore, recordReadableStore, type WritableNode } from "./store.js"; - -function assertDataValue(value: unknown): void { - if (value === undefined) { - throw new TypeError( - "createNodeInput: undefined is SENTINEL/no DATA, not a writable DATA value", - ); - } -} - -function bindReadable(store: { get(): T; subscribe(run: (value: T) => void): () => void }) { - const [value, setValue] = createSignal(store.get()); - const unsubscribe = store.subscribe((next) => { - setValue(() => next); - }); - onCleanup(unsubscribe); - return value; -} - -/** Read a GraphReFly node as a Solid accessor. - * @param node - Node to observe, adapt, or connect. - * @returns A `Accessor` value. - * @category adapters - * @example - * ```ts - * import { createNodeValue } from "@graphrefly/ts/adapters/solid"; - * ``` - */ -export function createNodeValue(node: Node): Accessor { - return bindReadable(readableStore(node)); -} - -/** Bind a writable GraphReFly node as `[valueAccessor, setValue]`. - * @param node - Node to observe, adapt, or connect. - * @returns A `readonly [Accessor, (value: T) => void]` value. - * @category adapters - * @example - * ```ts - * import { createNodeInput } from "@graphrefly/ts/adapters/solid"; - * ``` - */ -export function createNodeInput( - node: WritableNode, -): readonly [Accessor, (value: T) => void] { - return [ - createNodeValue(node), - (value: T) => { - assertDataValue(value); - node.set(value); - }, - ] as const; -} - -/** - * Read a keyed record of nodes as a Solid accessor. - * - * `factory` must have stable identity; callers should define it outside render - * churn or memoize it in their component setup. - * @param keysNode - keys node value used by the helper. - * @param factory - factory value used by the helper. - * @returns A `Accessor>` value. - * @category adapters - * @example - * ```ts - * import { createNodeRecord } from "@graphrefly/ts/adapters/solid"; - * ``` - */ -export function createNodeRecord>( - keysNode: Node, - factory: (key: K) => { [P in keyof R]: Node }, -): Accessor> { - const store = recordReadableStore(keysNode, factory); - return bindReadable({ - get: () => store.get() ?? ({} as Record), - subscribe: (run) => store.subscribe((value) => run(value ?? ({} as Record))), - }); -} diff --git a/packages/ts/src/adapters/svelte.ts b/packages/ts/src/adapters/svelte.ts deleted file mode 100644 index 4885a99e..00000000 --- a/packages/ts/src/adapters/svelte.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Svelte node bindings for GraphReFly (D238). - * - * Svelte is imported only from this focused subpath. - */ - -import { type Readable, readable } from "svelte/store"; -import type { Node } from "../node/node.js"; -import { - nodeSnapshot, - recordReadableStore, - subscribeNodeValues, - type WritableNode, -} from "./store.js"; - -/** A Svelte-readable store plus DATA-only write helpers. */ -export interface NodeWritable extends Readable { - set(value: T): void; - update(fn: (value: T | undefined) => T): void; -} - -/** Read a GraphReFly node as a Svelte readable store. - * @param node - Node to observe, adapt, or connect. - * @returns A `Readable` value. - * @category adapters - * @example - * ```ts - * import { nodeReadable } from "@graphrefly/ts/adapters/svelte"; - * ``` - */ -export function nodeReadable(node: Node): Readable { - return readable(nodeSnapshot(node), (set) => - subscribeNodeValues(node, set, { immediate: true }), - ); -} - -function assertDataValue(value: unknown): void { - if (value === undefined) { - throw new TypeError("nodeWritable: undefined is SENTINEL/no DATA, not a writable DATA value"); - } -} - -/** Bind a writable GraphReFly node as a Svelte store. - * @param node - Node to observe, adapt, or connect. - * @returns A `NodeWritable` value. - * @category adapters - * @example - * ```ts - * import { nodeWritable } from "@graphrefly/ts/adapters/svelte"; - * ``` - */ -export function nodeWritable(node: WritableNode): NodeWritable { - const store = nodeReadable(node); - return { - subscribe: store.subscribe, - set(value) { - assertDataValue(value); - node.set(value); - }, - update(fn) { - const next = fn(nodeSnapshot(node)); - assertDataValue(next); - node.set(next); - }, - }; -} - -/** - * Read a keyed record of nodes as a Svelte readable store. - * - * `factory` must have stable identity. Recreating it during component churn - * rebuilds the record subscriptions. - * @param keysNode - keys node value used by the helper. - * @param factory - factory value used by the helper. - * @returns A `Readable>` value. - * @category adapters - * @example - * ```ts - * import { nodeRecord } from "@graphrefly/ts/adapters/svelte"; - * ``` - */ -export function nodeRecord>( - keysNode: Node, - factory: (key: K) => { [P in keyof R]: Node }, -): Readable> { - const store = recordReadableStore(keysNode, factory); - const read = () => store.get() ?? ({} as Record); - return readable(read(), (set) => store.subscribe((value) => set(value ?? ({} as Record)))); -} diff --git a/packages/ts/src/adapters/vue.ts b/packages/ts/src/adapters/vue.ts deleted file mode 100644 index 1c946da8..00000000 --- a/packages/ts/src/adapters/vue.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Vue node bindings for GraphReFly (D238). - * - * Vue is imported only from this focused subpath. - */ - -import { onScopeDispose, readonly, type ShallowRef, shallowRef } from "vue"; -import type { Node } from "../node/node.js"; -import { readableStore, recordReadableStore, type WritableNode } from "./store.js"; - -function assertDataValue(value: unknown): void { - if (value === undefined) { - throw new TypeError("useNodeInput: undefined is SENTINEL/no DATA, not a writable DATA value"); - } -} - -function bindReadable(store: { get(): T; subscribe(run: (value: T) => void): () => void }) { - const value = shallowRef(store.get()) as ShallowRef; - const unsubscribe = store.subscribe((next) => { - value.value = next; - }); - onScopeDispose(unsubscribe); - return readonly(value) as Readonly>; -} - -/** Read a GraphReFly node as a Vue shallow ref. */ -export function useNodeValue(node: Node): Readonly> { - return bindReadable(readableStore(node)); -} - -/** Bind a writable GraphReFly node as `[valueRef, setValue]`. */ -export function useNodeInput( - node: WritableNode, -): readonly [Readonly>, (value: T) => void] { - return [ - useNodeValue(node), - (value: T) => { - assertDataValue(value); - node.set(value); - }, - ] as const; -} - -/** - * Read a keyed record of nodes as a Vue shallow ref. - * - * `factory` must have stable identity; callers should define it outside render - * churn or memoize it in their composition function. - */ -export function useNodeRecord>( - keysNode: Node, - factory: (key: K) => { [P in keyof R]: Node }, -): Readonly>> { - const store = recordReadableStore(keysNode, factory); - return bindReadable({ - get: () => store.get() ?? ({} as Record), - subscribe: (run) => store.subscribe((value) => run(value ?? ({} as Record))), - }); -} diff --git a/packages/ts/src/solutions/reactive-layout/node-canvas/index.ts b/packages/ts/src/solutions/reactive-layout/node-canvas/index.ts index 953b9c3b..43e7f044 100644 --- a/packages/ts/src/solutions/reactive-layout/node-canvas/index.ts +++ b/packages/ts/src/solutions/reactive-layout/node-canvas/index.ts @@ -1,4 +1,3 @@ -import { createRequire } from "node:module"; import { type Ctx, depLatest } from "../../../ctx/types.js"; import type { Graph } from "../../../graph/graph.js"; import type { Node } from "../../../node/node.js"; @@ -15,14 +14,6 @@ export interface NodeCanvasTextContextLike { font: string; } -export interface NodeCanvasLike { - getContext(type: "2d"): NodeCanvasTextContextLike | null; -} - -export interface NodeCanvasPackageLike { - createCanvas(width: number, height: number): NodeCanvasLike; -} - /** Dependency-free NodeCanvas focused subpath options. */ export interface NodeCanvasTextMeasurementsOptions { readonly graph: Graph; @@ -35,78 +26,6 @@ export interface NodeCanvasTextMeasurementsOptions { readonly name?: string; } -/** Optional-peer NodeCanvas focused subpath options. */ -export interface NodeCanvasPackageTextMeasurementsOptions - extends Omit { - readonly canvas?: NodeCanvasPackageLike; - readonly width?: number; - readonly height?: number; -} - -const requireOptionalPeer = createRequire(`${process.cwd()}/package.json`); - -function loadCanvasPackage(): NodeCanvasPackageLike { - const errors: unknown[] = []; - if (typeof require === "function") { - try { - return require("canvas") as NodeCanvasPackageLike; - } catch (error) { - errors.push(error); - } - } - try { - return requireOptionalPeer("canvas") as NodeCanvasPackageLike; - } catch (error) { - errors.push(error); - throw new TypeError( - "nodeCanvasPackageTextMeasurements requires optional peer dependency 'canvas'. Install canvas, pass { canvas }, or use nodeCanvasTextMeasurements({ context }) with a caller-owned 2D context.", - { cause: errors.at(-1) }, - ); - } -} - -class NodeCanvasPackageTextCapability implements TextMeasureCapability { - private readonly canvasPackage?: NodeCanvasPackageLike; - private readonly width: number; - private readonly height: number; - private context: NodeCanvasTextContextLike | null = null; - - constructor(opts: { - readonly canvas?: NodeCanvasPackageLike; - readonly width: number; - readonly height: number; - }) { - this.canvasPackage = opts.canvas; - this.width = opts.width; - this.height = opts.height; - } - - private getContext(): NodeCanvasTextContextLike { - if (this.context !== null) return this.context; - const canvas = (this.canvasPackage ?? loadCanvasPackage()).createCanvas( - this.width, - this.height, - ); - const context = canvas.getContext("2d"); - if (context === null) { - throw new TypeError("nodeCanvasPackageTextMeasurements: failed to create a 2D context"); - } - this.context = context; - return context; - } - - measureText(text: string, font: string): { readonly width: number } { - const context = this.getContext(); - const previousFont = context.font; - context.font = font; - try { - return context.measureText(text); - } finally { - context.font = previousFont; - } - } -} - /** * NodeCanvas provider helper for caller-injected 2D contexts. * @@ -157,44 +76,3 @@ export function nodeCanvasTextMeasurements( name: opts.name, }); } - -/** - * NodeCanvas provider helper backed by the optional `canvas` peer package. - * - * The native package is loaded only when the graph measures; the universal layout core and - * caller-injected `nodeCanvasTextMeasurements` helper remain dependency-free. - * @param opts - Options that configure the helper. - * @returns A `Node` value. - * @category solutions - * @example - * ```ts - * import { nodeCanvasPackageTextMeasurements } from "@graphrefly/ts/solutions/reactive-layout/node-canvas"; - * ``` - */ -export function nodeCanvasPackageTextMeasurements( - opts: NodeCanvasPackageTextMeasurementsOptions, -): Node { - const targetId = opts.targetId ?? "text"; - const capability = opts.graph.state( - new NodeCanvasPackageTextCapability({ - canvas: opts.canvas, - width: opts.width ?? 0, - height: opts.height ?? 0, - }), - { - name: opts.name - ? `${opts.name}:node-canvas-measure-capability` - : `${targetId}-node-canvas-measure-capability`, - }, - ); - return capabilityTextMeasurements({ - graph: opts.graph, - text: opts.text, - font: opts.font, - capability, - segmentAdapter: opts.segmentAdapter, - targetId: opts.targetId, - source: opts.source ?? "nodeCanvasPackageTextMeasurements", - name: opts.name, - }); -} diff --git a/packages/ts/tsup.config.ts b/packages/ts/tsup.config.ts index cb01fc32..3b1f6ed4 100644 --- a/packages/ts/tsup.config.ts +++ b/packages/ts/tsup.config.ts @@ -4,15 +4,7 @@ export default defineConfig({ entry: [ "src/index.ts", "src/adapters/index.ts", - "src/adapters/nestjs.ts", - "src/adapters/nestjs/microservices.ts", - "src/adapters/nestjs/native.ts", - "src/adapters/nestjs/websockets.ts", "src/adapters/observe-storage.ts", - "src/adapters/react.ts", - "src/adapters/solid.ts", - "src/adapters/svelte.ts", - "src/adapters/vue.ts", "src/committed-facts/index.ts", "src/composition/index.ts", "src/cqrs/index.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc28e606..f3894624 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -422,37 +422,6 @@ importers: version: 5.9.3 packages/ts: - dependencies: - '@nestjs/common': - specifier: ^11.0.0 - version: 11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': - specifier: ^11.0.0 - version: 11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.27)(@nestjs/platform-express@11.1.18)(@nestjs/websockets@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/microservices': - specifier: ^11.0.0 - version: 11.1.27(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@nestjs/websockets@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/websockets': - specifier: ^11.0.0 - version: 11.1.18(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) - canvas: - specifier: ^3.2.3 - version: 3.2.3 - react: - specifier: ^18.0.0 || ^19.0.0 - version: 19.2.4 - rxjs: - specifier: ^7.8.0 - version: 7.8.2 - solid-js: - specifier: ^1.9.0 - version: 1.9.12 - svelte: - specifier: ^5.0.0 - version: 5.56.3(@typescript-eslint/types@8.58.0) - vue: - specifier: ^3.5.0 - version: 3.5.31(typescript@5.9.3) devDependencies: tsup: specifier: ^8.5.1 @@ -10879,6 +10848,7 @@ snapshots: buffer: 5.7.1 inherits: 2.0.4 readable-stream: 3.6.2 + optional: true body-parser@2.2.2: dependencies: @@ -10954,6 +10924,7 @@ snapshots: dependencies: base64-js: 1.5.1 ieee754: 1.2.1 + optional: true bundle-name@4.1.0: dependencies: @@ -11001,6 +10972,7 @@ snapshots: dependencies: node-addon-api: 7.1.1 prebuild-install: 7.1.3 + optional: true ccount@2.0.1: {} @@ -11058,7 +11030,8 @@ snapshots: dependencies: readdirp: 5.0.0 - chownr@1.1.4: {} + chownr@1.1.4: + optional: true chrome-launcher@0.15.2: dependencies: @@ -11470,6 +11443,7 @@ snapshots: decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 + optional: true dedent-js@1.0.1: {} @@ -11496,7 +11470,8 @@ snapshots: which-collection: 1.0.2 which-typed-array: 1.1.20 - deep-extend@0.6.0: {} + deep-extend@0.6.0: + optional: true deepmerge@4.3.1: {} @@ -11627,6 +11602,7 @@ snapshots: end-of-stream@1.4.5: dependencies: once: 1.4.0 + optional: true enquirer@2.4.1: dependencies: @@ -11877,7 +11853,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 - expand-template@2.0.3: {} + expand-template@2.0.3: + optional: true expect-type@1.3.0: {} @@ -12150,7 +12127,8 @@ snapshots: fresh@2.0.0: {} - fs-constants@1.0.0: {} + fs-constants@1.0.0: + optional: true fs-extra@11.3.5: dependencies: @@ -12216,7 +12194,8 @@ snapshots: getenv@2.0.0: {} - github-from-package@0.0.0: {} + github-from-package@0.0.0: + optional: true github-slugger@2.0.0: {} @@ -13771,7 +13750,8 @@ snapshots: mimic-fn@1.2.0: {} - mimic-response@3.1.0: {} + mimic-response@3.1.0: + optional: true minimatch@10.2.5: dependencies: @@ -13785,13 +13765,15 @@ snapshots: dependencies: brace-expansion: 2.0.3 - minimist@1.2.8: {} + minimist@1.2.8: + optional: true minipass@7.1.3: {} mitt@3.0.1: {} - mkdirp-classic@0.5.3: {} + mkdirp-classic@0.5.3: + optional: true mkdirp@1.0.4: {} @@ -13831,7 +13813,8 @@ snapshots: nanoid@5.1.15: {} - napi-build-utils@2.0.0: {} + napi-build-utils@2.0.0: + optional: true negotiator@0.6.3: {} @@ -13848,8 +13831,10 @@ snapshots: node-abi@3.92.0: dependencies: semver: 7.7.4 + optional: true - node-addon-api@7.1.1: {} + node-addon-api@7.1.1: + optional: true node-fetch-native@1.6.7: {} @@ -14170,6 +14155,7 @@ snapshots: simple-get: 4.0.1 tar-fs: 2.1.4 tunnel-agent: 0.6.0 + optional: true prettier@2.8.8: {} @@ -14217,6 +14203,7 @@ snapshots: dependencies: end-of-stream: 1.4.5 once: 1.4.0 + optional: true punycode@2.3.1: {} @@ -14251,6 +14238,7 @@ snapshots: ini: 1.3.8 minimist: 1.2.8 strip-json-comments: 2.0.1 + optional: true react-devtools-core@6.1.5: dependencies: @@ -14830,13 +14818,15 @@ snapshots: signal-exit@4.1.0: {} - simple-concat@1.0.1: {} + simple-concat@1.0.1: + optional: true simple-get@4.0.1: dependencies: decompress-response: 6.0.0 once: 1.4.0 simple-concat: 1.0.1 + optional: true simple-plist@1.3.1: dependencies: @@ -14999,7 +14989,8 @@ snapshots: strip-final-newline@4.0.0: {} - strip-json-comments@2.0.1: {} + strip-json-comments@2.0.1: + optional: true strip-literal@3.1.0: dependencies: @@ -15121,6 +15112,7 @@ snapshots: mkdirp-classic: 0.5.3 pump: 3.0.4 tar-stream: 2.2.0 + optional: true tar-stream@2.2.0: dependencies: @@ -15129,6 +15121,7 @@ snapshots: fs-constants: 1.0.0 inherits: 2.0.4 readable-stream: 3.6.2 + optional: true term-size@2.2.1: {} @@ -15265,6 +15258,7 @@ snapshots: tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 + optional: true type-detect@4.0.8: {} diff --git a/scripts/check-no-raw-async.ts b/scripts/check-no-raw-async.ts index 454a9f16..f4afda19 100644 --- a/scripts/check-no-raw-async.ts +++ b/scripts/check-no-raw-async.ts @@ -32,7 +32,6 @@ const ALLOW_ALL = new Set([ "packages/ts/src/adapters/environment.ts", "packages/ts/src/adapters/environment-outbound.ts", "packages/ts/src/adapters/environment-websocket-session.ts", - "packages/ts/src/adapters/nestjs/native.ts", "packages/ts/src/graph/environment.ts", "packages/ts/src/graph/sources.ts", "packages/ts/src/graph/worker.ts", @@ -93,15 +92,6 @@ const ALLOW_LABELS = new Map>([ "packages/ts/src/executors/tool-provider-adapters.ts", new Set(["Promise.resolve()", "setTimeout("]), ], - ["packages/ts/src/adapters/nestjs.ts", new Set(["new Promise"])], - [ - "packages/ts/src/adapters/nestjs/microservices.ts", - new Set(["new Promise", "setTimeout("]), - ], - [ - "packages/ts/src/adapters/nestjs/websockets.ts", - new Set(["new Promise", "setTimeout("]), - ], [ "packages/ts/src/storage/append-log.ts", new Set(["Promise.resolve()", "Promise.all()"]), diff --git a/scripts/check-ts-package-exports.mjs b/scripts/check-ts-package-exports.mjs index 186de8ff..7b05360a 100644 --- a/scripts/check-ts-package-exports.mjs +++ b/scripts/check-ts-package-exports.mjs @@ -4,11 +4,12 @@ import { existsSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, rmSync, - symlinkSync, writeFileSync, } from "node:fs"; +import { builtinModules } from "node:module"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -17,19 +18,17 @@ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const PKG = join(ROOT, "packages", "ts"); const TSC = join(ROOT, "node_modules", ".bin", "tsc"); const packageJson = JSON.parse(readFileSync(join(PKG, "package.json"), "utf8")); -const optionalPeers = [ - "@nestjs/common", - "@nestjs/core", - "@nestjs/microservices", - "@nestjs/websockets", - "canvas", - "react", - "rxjs", - "solid-js", - "svelte", - "vue", +const nodeBuiltins = new Set(builtinModules.flatMap((name) => [name, `node:${name}`])); +const removedEcosystemSubpaths = [ + "./adapters/nestjs", + "./adapters/nestjs/microservices", + "./adapters/nestjs/native", + "./adapters/nestjs/websockets", + "./adapters/react", + "./adapters/solid", + "./adapters/svelte", + "./adapters/vue", ]; - const expectedSubpaths = { "./adapters": { present: [ @@ -63,67 +62,6 @@ const expectedSubpaths = { "toNestHttp", ], }, - "./adapters/nestjs": { - present: [ - "fromNestReq", - "fromNestGuard", - "fromNestIntercept", - "fromNestError", - "fromNestLifecycle", - "fromNestCron", - "toNestHttp", - "GraphReq", - "GraphGuard", - "GraphIntercept", - "GraphError", - "GraphLifecycle", - "GraphCron", - "GraphHttpReply", - "createNestGraphBoundaryRunner", - "createNestGraphBoundaryInterceptor", - "getNestBoundaryToken", - ], - absent: [], - }, - "./adapters/nestjs/native": { - present: [ - "provideGraphBoundaryInterceptor", - "provideGraphGuard", - "provideGraphExceptionFilter", - "provideGraphCronScheduler", - "provideGraphLifecycleHooks", - "provideGraphGuardDeniedFilter", - ], - absent: [], - }, - "./adapters/nestjs/websockets": { - present: [ - "fromNestWs", - "GraphWs", - "GraphWsAck", - "GraphWsReply", - "createGraphWsBridge", - "provideGraphWsBridge", - ], - absent: ["fromNestMessage", "GraphMessage", "GraphMessageReply"], - }, - "./adapters/nestjs/microservices": { - present: [ - "fromNestMessage", - "GraphMessage", - "GraphMessageReply", - "createGraphMessageBridge", - "provideGraphMessageBridge", - ], - absent: ["fromNestWs", "GraphWs", "GraphWsAck", "GraphWsReply"], - }, - "./adapters/react": { present: ["useNodeValue", "useNodeInput", "useNodeRecord"], absent: [] }, - "./adapters/vue": { present: ["useNodeValue", "useNodeInput", "useNodeRecord"], absent: [] }, - "./adapters/solid": { - present: ["createNodeValue", "createNodeInput", "createNodeRecord"], - absent: [], - }, - "./adapters/svelte": { present: ["nodeReadable", "nodeWritable", "nodeRecord"], absent: [] }, "./committed-facts": { present: [ "appendLogCommittedFactJournal", @@ -584,6 +522,13 @@ function expectTscFailure(tmp, file, expectedNames) { validateExportTree(packageJson.exports, "exports"); +for (const subpath of removedEcosystemSubpaths) { + assert( + packageJson.exports?.[subpath] === undefined, + `${subpath} must move to an ecosystem package`, + ); +} + for (const subpath of Object.keys(expectedSubpaths)) { assert(packageJson.exports?.[subpath] !== undefined, `${subpath} missing from package exports`); for (const [condition, extension] of [ @@ -604,15 +549,25 @@ for (const subpath of Object.keys(expectedSubpaths)) { } } -for (const peer of optionalPeers) { - assert( - packageJson.peerDependencies?.[peer] !== undefined, - `optional peer ${peer} missing from peerDependencies`, - ); - assert( - packageJson.peerDependenciesMeta?.[peer]?.optional === true, - `optional peer ${peer} missing peerDependenciesMeta optional:true`, - ); +for (const field of ["dependencies", "optionalDependencies", "peerDependencies"]) { + assert(packageJson[field] === undefined, `${field} must be absent from the strict core manifest`); +} + +for (const file of readdirSync(join(PKG, "dist"), { recursive: true, withFileTypes: true })) { + if (!file.isFile() || (!file.name.endsWith(".js") && !file.name.endsWith(".cjs"))) continue; + const path = join(file.parentPath, file.name); + const source = readFileSync(path, "utf8"); + const specifiers = [ + ...source.matchAll(/^\s*(?:import|export)\s+(?:[\s\S]*?\s+from\s+)?["']([^"']+)["'];?\s*$/gm), + ...source.matchAll(/^[^"'`]*\bimport\s*\(\s*["']([^"']+)["']\s*\)/gm), + ...source.matchAll(/^[^"'`]*\brequire\s*\(\s*["']([^"']+)["']\s*\)/gm), + ].map((match) => match[1]); + for (const specifier of specifiers) { + assert( + specifier.startsWith(".") || nodeBuiltins.has(specifier), + `${path} contains non-built-in bare import: ${specifier}`, + ); + } } for (const rel of [ @@ -640,14 +595,6 @@ try { mkdirSync(tmpPkg, { recursive: true }); cpSync(join(PKG, "package.json"), join(tmpPkg, "package.json")); cpSync(join(PKG, "dist"), join(tmpPkg, "dist"), { recursive: true }); - for (const peer of optionalPeers) { - const realPeer = join(ROOT, "node_modules", peer); - if (existsSync(realPeer)) { - const link = join(tmp, "node_modules", peer); - mkdirSync(dirname(link), { recursive: true }); - symlinkSync(realPeer, link, "dir"); - } - } writeFileSync( join(tmp, "package.json"), JSON.stringify({ type: "module", private: true }, null, "\t"), @@ -743,10 +690,6 @@ ${runtimeAssertions.replaceAll("await load", "load")} writeFileSync( join(tmp, "types-smoke.mts"), `import { externalStore, readableStore, recordReadableStore, subscribeNodeValues, wireBridgeProtobuf, writableStore, type AgenticMemoryPassiveStoreFrameAdapter, type AgenticMemoryPassiveStoreFrameCursor, type AgenticMemoryPassiveStoreFrameReadResult, type AgenticMemoryPassiveStoreFrameStatus, type AgenticMemoryPassiveStoreFrameWriteResult, type WireBridgeProtobufBundle, type WireBridgeProtobufData, type WireBridgeProtobufIssue, type WireBridgeProtobufOptions, type WireBridgeProtobufStatus } from "@graphrefly/ts/adapters"; -import { useNodeInput, useNodeRecord, useNodeValue } from "@graphrefly/ts/adapters/react"; -import { createNodeInput, createNodeRecord, createNodeValue } from "@graphrefly/ts/adapters/solid"; -import { nodeReadable, nodeRecord, nodeWritable } from "@graphrefly/ts/adapters/svelte"; -import { useNodeInput as useVueNodeInput, useNodeRecord as useVueNodeRecord, useNodeValue as useVueNodeValue } from "@graphrefly/ts/adapters/vue"; import { appendLogCommittedFactJournal, committedFactJournalCursor, @@ -891,18 +834,6 @@ void recordReadableStore; void subscribeNodeValues; void wireBridgeProtobuf; void writableStore; -void useNodeInput; -void useNodeRecord; -void useNodeValue; -void createNodeInput; -void createNodeRecord; -void createNodeValue; -void nodeReadable; -void nodeRecord; -void nodeWritable; -void useVueNodeInput; -void useVueNodeRecord; -void useVueNodeValue; void appendLogCommittedFactJournal; void committedFactJournalCursor; void committedFactJournalCursorCodec; diff --git a/website/src/content/docs/integrations/compat.md b/website/src/content/docs/integrations/compat.md index 176120e5..3903b544 100644 --- a/website/src/content/docs/integrations/compat.md +++ b/website/src/content/docs/integrations/compat.md @@ -9,14 +9,14 @@ description: "Historical note for the retired compatibility layers." > API generator still lives in `website/`. -The old `@graphrefly/graphrefly/compat/*` runtime model is retired. Framework and host bindings now live under focused `@graphrefly/ts/adapters/*` subpaths. +The old `@graphrefly/graphrefly/compat/*` runtime model is retired. Framework and host bindings now live in one-way ecosystem packages over `@graphrefly/ts`. ## Current replacements -- **NestJS structural metadata**: `@graphrefly/ts/adapters/nestjs` keyed ingress/egress boundary nodes plus decorators over existing graph nodes. -- **NestJS HTTP/native providers**: `@graphrefly/ts/adapters/nestjs/native` explicit D494 provider bundles for interceptor, guard, filter, cron, and lifecycle phases. -- **NestJS WebSocket/message providers**: `@graphrefly/ts/adapters/nestjs/websockets` and `@graphrefly/ts/adapters/nestjs/microservices` focused D495 provider bundles over the existing D488 bridges. -- **React/Vue/Solid/Svelte**: focused framework adapter subpaths. +- **NestJS structural metadata**: `@graphrefly/nestjs` keyed ingress/egress boundary nodes plus decorators over existing graph nodes. +- **NestJS HTTP/native providers**: `@graphrefly/nestjs/native` explicit D494 provider bundles for interceptor, guard, filter, cron, and lifecycle phases. +- **NestJS WebSocket/message providers**: `@graphrefly/nestjs/websockets` and `@graphrefly/nestjs/microservices` focused D495 provider bundles over the existing D488 bridges. +- **React/Vue/Solid/Svelte**: `@graphrefly/react`, `@graphrefly/vue`, `@graphrefly/solid`, and `@graphrefly/svelte`. - **Jotai/Nanostores/Zustand-style facades**: small store facades from `@graphrefly/ts/adapters`. See the full walkthrough in [NestJS Integration](/recipes/nestjs-integration/). diff --git a/website/src/content/docs/integrations/matrix.md b/website/src/content/docs/integrations/matrix.md index 92372ada..0e31a9a2 100644 --- a/website/src/content/docs/integrations/matrix.md +++ b/website/src/content/docs/integrations/matrix.md @@ -82,7 +82,7 @@ See [Adapters](/integrations/adapters/) for usage guidance and naming convention | Agentic WorkItem memory application | Cross-family application composition recipe | `@graphrefly/ts/solutions/agentic-work-item-memory-application` | | Reactive layout | DOM-free reactive layout solution core | `@graphrefly/ts/solutions/reactive-layout` | | Reactive layout browser | Browser measurement helpers | `@graphrefly/ts/solutions/reactive-layout/browser` | -| Reactive layout node-canvas | Node canvas measurement helpers | `@graphrefly/ts/solutions/reactive-layout/node-canvas` | +| Reactive layout caller-owned canvas context | Dependency-free Node-style canvas measurement helper | `@graphrefly/ts/solutions/reactive-layout/node-canvas` | | Reactive layout React Native | React Native measurement helpers | `@graphrefly/ts/solutions/reactive-layout/react-native` | | Reactive layout Skia | Skia measurement helpers | `@graphrefly/ts/solutions/reactive-layout/skia` | | Work item | Focused WorkItem solution barrel | `@graphrefly/ts/solutions/work-item` | @@ -94,14 +94,15 @@ See [Adapters](/integrations/adapters/) for usage guidance and naming convention | Integration | Type | Entry | |---|---|---| -| React | Framework adapter | `@graphrefly/ts/adapters/react` | -| Vue | Framework adapter | `@graphrefly/ts/adapters/vue` | -| Solid | Framework adapter | `@graphrefly/ts/adapters/solid` | -| Svelte | Framework adapter | `@graphrefly/ts/adapters/svelte` | -| NestJS structural metadata | D484 dependency-light boundary factories/decorators | `@graphrefly/ts/adapters/nestjs` | -| NestJS native providers | D494 HTTP/guard/filter/cron/lifecycle provider bundles and explicit targets | `@graphrefly/ts/adapters/nestjs/native` | -| NestJS WebSocket boundary | D488 focused optional-peer gateway bridge plus D495 provider bundle | `@graphrefly/ts/adapters/nestjs/websockets` | -| NestJS microservice boundary | D488 focused optional-peer message-pattern bridge plus D495 provider bundle | `@graphrefly/ts/adapters/nestjs/microservices` | +| React | Framework adapter | `@graphrefly/react` | +| Vue | Framework adapter | `@graphrefly/vue` | +| Solid | Framework adapter | `@graphrefly/solid` | +| Svelte | Framework adapter | `@graphrefly/svelte` | +| NestJS structural metadata | D484 dependency-light boundary factories/decorators | `@graphrefly/nestjs` | +| NestJS native providers | D494 HTTP/guard/filter/cron/lifecycle provider bundles and explicit targets | `@graphrefly/nestjs/native` | +| NestJS WebSocket boundary | D488 focused optional-peer gateway bridge plus D495 provider bundle | `@graphrefly/nestjs/websockets` | +| NestJS microservice boundary | D488 focused optional-peer message-pattern bridge plus D495 provider bundle | `@graphrefly/nestjs/microservices` | +| node-canvas package loader | Concrete third-party canvas integration | `@graphrefly/reactive-layout-node-canvas` | | Jotai-style facade | Framework-neutral store facade | `jotaiAtom` from `@graphrefly/ts/adapters` | | Nanostores-style facade | Framework-neutral store facade | `nanoAtom` from `@graphrefly/ts/adapters` | | Zustand-style facade | Framework-neutral store facade | `zustandStore` from `@graphrefly/ts/adapters` | diff --git a/website/src/content/docs/recipes/from-callbag-recharge.md b/website/src/content/docs/recipes/from-callbag-recharge.md index c8f24a8a..e6609627 100644 --- a/website/src/content/docs/recipes/from-callbag-recharge.md +++ b/website/src/content/docs/recipes/from-callbag-recharge.md @@ -93,11 +93,11 @@ All 70+ operators carry forward with the same names and semantics. A few notes: |---|---| | `callbag-recharge/compat/zustand` | `zustandStore(...)` from `@graphrefly/ts/adapters` | | `callbag-recharge/compat/jotai` | `jotaiAtom(...)` from `@graphrefly/ts/adapters` | -| `callbag-recharge/compat/react` | `@graphrefly/ts/adapters/react` | -| `callbag-recharge/compat/vue` | `@graphrefly/ts/adapters/vue` | -| `callbag-recharge/compat/svelte` | `@graphrefly/ts/adapters/svelte` | -| `callbag-recharge/compat/solid` | `@graphrefly/ts/adapters/solid` | -| — | `@graphrefly/ts/adapters/nestjs` keyed boundary nodes | +| `callbag-recharge/compat/react` | `@graphrefly/react` | +| `callbag-recharge/compat/vue` | `@graphrefly/vue` | +| `callbag-recharge/compat/svelte` | `@graphrefly/svelte` | +| `callbag-recharge/compat/solid` | `@graphrefly/solid` | +| — | `@graphrefly/nestjs` keyed boundary nodes | ### Messages diff --git a/website/src/content/docs/recipes/nestjs-integration.md b/website/src/content/docs/recipes/nestjs-integration.md index 321dde62..3caef12d 100644 --- a/website/src/content/docs/recipes/nestjs-integration.md +++ b/website/src/content/docs/recipes/nestjs-integration.md @@ -23,10 +23,10 @@ The NestJS adapter has focused subpaths: | Import path | Use | |---|---| -| `@graphrefly/ts/adapters/nestjs` | Dependency-light structural metadata: boundary factories, binding decorators, envelopes, and lowering types. | -| `@graphrefly/ts/adapters/nestjs/native` | Nest/RxJS native phase bridges for HTTP interceptor, guard, filter, cron, and lifecycle phases. | -| `@graphrefly/ts/adapters/nestjs/websockets` | Focused optional-peer native bridge for `@nestjs/websockets` gateway ingress plus ack/reply egress. | -| `@graphrefly/ts/adapters/nestjs/microservices` | Focused optional-peer native bridge for `@nestjs/microservices` message-pattern ingress plus reply egress. | +| `@graphrefly/nestjs` | Dependency-light structural metadata: boundary factories, binding decorators, envelopes, and lowering types. | +| `@graphrefly/nestjs/native` | Nest/RxJS native phase bridges for HTTP interceptor, guard, filter, cron, and lifecycle phases. | +| `@graphrefly/nestjs/websockets` | Focused optional-peer native bridge for `@nestjs/websockets` gateway ingress plus ack/reply egress. | +| `@graphrefly/nestjs/microservices` | Focused optional-peer native bridge for `@nestjs/microservices` message-pattern ingress plus reply egress. | HTTP/native imports do not pull `@nestjs/websockets` or `@nestjs/microservices`. The WebSocket and message subpaths import only their matching optional peer. @@ -60,7 +60,7 @@ import { GraphHttpReply, type NestBoundaryEnvelope, type NestReplyEnvelope, -} from "@graphrefly/ts/adapters/nestjs"; +} from "@graphrefly/nestjs"; const g = graph({ name: "orders" }); @@ -98,7 +98,7 @@ class OrdersController { Register the native phase bridge with Nest providers: ```ts -import { provideGraphBoundaryInterceptor } from "@graphrefly/ts/adapters/nestjs/native"; +import { provideGraphBoundaryInterceptor } from "@graphrefly/nestjs/native"; @Module({ providers: [ @@ -119,7 +119,7 @@ import { graphCronTarget, graphLifecycleTarget, provideGraphNativeProviders, -} from "@graphrefly/ts/adapters/nestjs/native"; +} from "@graphrefly/nestjs/native"; const cronTargets = [ graphCronTarget(OrdersController, "cronTick", { @@ -155,13 +155,13 @@ The bundle returns ordinary `Provider[]`. It is not a module, does not create a ## WebSocket and Message Bridges -D495 extends the NestJS ergonomics slice to focused transport subpaths. WebSocket modules may use `provideGraphWsProviders(...)` from `@graphrefly/ts/adapters/nestjs/websockets`, and message-pattern modules may use `provideGraphMessageProviders(...)` from `@graphrefly/ts/adapters/nestjs/microservices`. Each helper returns an ordinary explicit Nest provider array over existing bridge options. These helpers are not modules, do not create graphs, do not scan the container, do not discover routes or handlers, and do not own retry, session, or transport lifecycle policy. +D495 extends the NestJS ergonomics slice to focused transport subpaths. WebSocket modules may use `provideGraphWsProviders(...)` from `@graphrefly/nestjs/websockets`, and message-pattern modules may use `provideGraphMessageProviders(...)` from `@graphrefly/nestjs/microservices`. Each helper returns an ordinary explicit Nest provider array over existing bridge options. These helpers are not modules, do not create graphs, do not scan the container, do not discover routes or handlers, and do not own retry, session, or transport lifecycle policy. -Use `GraphWs(...)` with `GraphWsAck(...)` or `GraphWsReply(...)` through `provideGraphWsProviders(...)` or the primitive `provideGraphWsBridge(...)` from `@graphrefly/ts/adapters/nestjs/websockets`. Use `GraphMessage(...)` with `GraphMessageReply(...)` through `provideGraphMessageProviders(...)` or the primitive `provideGraphMessageBridge(...)` from `@graphrefly/ts/adapters/nestjs/microservices`. +Use `GraphWs(...)` with `GraphWsAck(...)` or `GraphWsReply(...)` through `provideGraphWsProviders(...)` or the primitive `provideGraphWsBridge(...)` from `@graphrefly/nestjs/websockets`. Use `GraphMessage(...)` with `GraphMessageReply(...)` through `provideGraphMessageProviders(...)` or the primitive `provideGraphMessageBridge(...)` from `@graphrefly/nestjs/microservices`. ```ts -import { provideGraphMessageProviders } from "@graphrefly/ts/adapters/nestjs/microservices"; -import { provideGraphWsProviders } from "@graphrefly/ts/adapters/nestjs/websockets"; +import { provideGraphMessageProviders } from "@graphrefly/nestjs/microservices"; +import { provideGraphWsProviders } from "@graphrefly/nestjs/websockets"; @Module({ providers: [ @@ -182,7 +182,7 @@ class AppModule {} These native phase bridges read only the metadata for the current gateway/controller method, require explicit payload selectors, and correlate reply-capable egress by both `requestId` and `bindingId`. Sockets, clients, ack callbacks, message contexts, transport clients, Observables, Promises, and reply handles stay host-private pending handles; graph-visible DATA carries only the selected payload envelope. Wrong-binding, stale, malformed, terminal, timeout, disconnect, and dispose cases are adapter diagnostics and cleanup paths, not protocol `ERROR`. -The optional-peer boundary remains strict: `@nestjs/websockets` is imported only by the WebSocket subpath, and `@nestjs/microservices` is imported only by the microservice/message subpath. The structural `@graphrefly/ts/adapters/nestjs` surface and HTTP/native `@graphrefly/ts/adapters/nestjs/native` surface do not pull those transport peers. +The optional-peer boundary remains strict: `@nestjs/websockets` is imported only by the WebSocket subpath, and `@nestjs/microservices` is imported only by the microservice/message subpath. The structural `@graphrefly/nestjs` surface and HTTP/native `@graphrefly/nestjs/native` surface do not pull those transport peers. ## Guards, Filters, and Issues @@ -190,7 +190,7 @@ Use `GraphGuard(...)` with `GraphGuardDecision(...)` for guard phase decisions. ```ts import { UseFilters } from "@nestjs/common"; -import { GraphGuardDeniedFilter } from "@graphrefly/ts/adapters/nestjs/native"; +import { GraphGuardDeniedFilter } from "@graphrefly/nestjs/native"; class OrdersController { @UseFilters(GraphGuardDeniedFilter) @@ -218,7 +218,7 @@ For exception handling, prefer the targeted helper: ```ts import { UseFilters } from "@nestjs/common"; -import { createGraphExceptionFilter } from "@graphrefly/ts/adapters/nestjs/native"; +import { createGraphExceptionFilter } from "@graphrefly/nestjs/native"; const graphErrorFilter = createGraphExceptionFilter({ target: () => ({ target: OrdersController, methodKey: "handledError" }), @@ -257,7 +257,7 @@ Plain `DataIssue` values lower through `issueResponse(issue, host)`. Protocol `E The deterministic controller is for manual checks and tests: ```ts -import { createGraphCronController, graphCronTarget } from "@graphrefly/ts/adapters/nestjs/native"; +import { createGraphCronController, graphCronTarget } from "@graphrefly/nestjs/native"; const controller = createGraphCronController({ targets: [ @@ -290,10 +290,10 @@ const stop = g.observe("orders.audit").subscribe((event) => { Graph-visible adapter diagnostics require an explicitly wired diagnostic ingress boundary: ```ts -import { fromNestDiagnostics } from "@graphrefly/ts/adapters/nestjs"; -import { provideGraphMessageProviders } from "@graphrefly/ts/adapters/nestjs/microservices"; -import { provideGraphNativeProviders } from "@graphrefly/ts/adapters/nestjs/native"; -import { provideGraphWsProviders } from "@graphrefly/ts/adapters/nestjs/websockets"; +import { fromNestDiagnostics } from "@graphrefly/nestjs"; +import { provideGraphMessageProviders } from "@graphrefly/nestjs/microservices"; +import { provideGraphNativeProviders } from "@graphrefly/nestjs/native"; +import { provideGraphWsProviders } from "@graphrefly/nestjs/websockets"; const nestDiagnostics = fromNestDiagnostics(g, { bindingId: "node.nest.diagnostics", diff --git a/website/src/content/docs/solutions/reactive-layout.md b/website/src/content/docs/solutions/reactive-layout.md index 1871501b..b68249d4 100644 --- a/website/src/content/docs/solutions/reactive-layout.md +++ b/website/src/content/docs/solutions/reactive-layout.md @@ -128,6 +128,8 @@ Focused platform subpaths expose dependency-free API shapes for native text meas These subpaths do not import `canvas`, Skia, or React Native packages. They keep platform packages caller-owned while making the measurement capability a graph-visible dependency. Async font or native layout readiness should still be modeled with explicit readiness or measurement facts. +Use `@graphrefly/reactive-layout-node-canvas` when the adapter should load the concrete `canvas` +package instead of receiving a caller-owned context. ## Recipes From 302bc6916a90679379a130aee6f372db442171b6 Mon Sep 17 00:00:00 2001 From: David Chen Date: Fri, 24 Jul 2026 16:51:15 -0700 Subject: [PATCH 5/7] fix(ts): harden Podman certification proofs --- ...n-libpod-api-v0-rootless-node.live.test.ts | 71 ++- .../node.ts | 539 ++++++++++++++---- 2 files changed, 480 insertions(+), 130 deletions(-) diff --git a/packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless-node.live.test.ts b/packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless-node.live.test.ts index c74433d5..fd9866fa 100644 --- a/packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless-node.live.test.ts +++ b/packages/ts/src/__tests__/local-container-postgresql-podman-libpod-api-v0-rootless-node.live.test.ts @@ -11,30 +11,60 @@ const live = process.env.GRAPHREFLY_D645_LIVE_PODMAN === "1"; const digest = "sha256:d13105efe29040feb046f1c5fc9f0a98e58d8980c85300306a325c80df9a45c4"; const imageRef = `docker.io/library/postgres@${digest}`; +function manifest(backendCertificationRevision = "podman-certification:d645-v0") { + return localContainerPostgresqlManifest({ + kind: "local-container-postgresql-manifest", + manifestId: "manifest:pg-d645-live", + revision: "revision:d645-live", + fingerprint: "fingerprint:pg-d645-live", + imageDigest: digest, + engineCompatibilityRevision: LOCAL_CONTAINER_POSTGRESQL_COMPATIBILITY, + backendFamily: LOCAL_CONTAINER_POSTGRESQL_PODMAN_LIBPOD_API_V0_ROOTLESS_BACKEND_FAMILY, + backendCertificationRevision, + recipeRevision: "postgresql-read-only-query-v1", + sandboxRevision: "sandbox:d645-live", + mountPolicyRevision: "mount:d645-live", + networkPolicyRevision: "network:deny:d645-live", + resourcePolicyRevision: "resources:d645-live", + stopGraceMs: 5, + attestationRefs: [{ kind: "attestation", id: "manifest:d645-live" }], + }); +} + +describe("Node-local Podman Libpod API v0 rootless certifier package policy", () => { + it("rejects caller-selected certification revisions before host effects", async () => { + await expect( + certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWithNode({ + manifest: manifest("podman-certification:caller-selected"), + imageRef, + }), + ).rejects.toThrow("package-owned certification revision"); + }); + + it("does not expose caller-selected observation time or freshness", () => { + const callerObservedAt = () => + certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWithNode({ + manifest: manifest(), + imageRef, + // @ts-expect-error D645 observation time is package-owned. + observedAtMs: 1, + }); + const callerTtl = () => + certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWithNode({ + manifest: manifest(), + imageRef, + // @ts-expect-error D645 freshness is package-owned. + ttlMs: 1, + }); + expect([callerObservedAt, callerTtl]).toHaveLength(2); + }); +}); + describe.runIf(live)("Node-local Podman Libpod API v0 rootless certifier (D645 live)", () => { it("certifies the exact host only after every containment, network, secret, cancellation, and cleanup proof", async () => { - const manifest = localContainerPostgresqlManifest({ - kind: "local-container-postgresql-manifest", - manifestId: "manifest:pg-d645-live", - revision: "revision:d645-live", - fingerprint: "fingerprint:pg-d645-live", - imageDigest: digest, - engineCompatibilityRevision: LOCAL_CONTAINER_POSTGRESQL_COMPATIBILITY, - backendFamily: LOCAL_CONTAINER_POSTGRESQL_PODMAN_LIBPOD_API_V0_ROOTLESS_BACKEND_FAMILY, - backendCertificationRevision: "podman-certification:d645-v0", - recipeRevision: "postgresql-read-only-query-v1", - sandboxRevision: "sandbox:d645-live", - mountPolicyRevision: "mount:d645-live", - networkPolicyRevision: "network:deny:d645-live", - resourcePolicyRevision: "resources:d645-live", - stopGraceMs: 5, - attestationRefs: [{ kind: "attestation", id: "manifest:d645-live" }], - }); const preflight = await certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWithNode({ - manifest, + manifest: manifest(), imageRef, - observedAtMs: 100, - ttlMs: 1_000, }); const readiness = localContainerPostgresqlPodmanLibpodApiV0RootlessPreflightReadiness(preflight); @@ -67,5 +97,6 @@ describe.runIf(live)("Node-local Podman Libpod API v0 rootless certifier (D645 l expect(JSON.stringify(preflight)).not.toContain("podman-machine-default-api.sock"); expect(JSON.stringify(preflight)).not.toContain("d645-canary-value"); expect(JSON.stringify(preflight)).not.toContain("/var/folders/"); + expect(JSON.stringify(preflight)).not.toContain("podman-machine-default"); }, 30_000); }); diff --git a/packages/ts/src/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.ts b/packages/ts/src/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.ts index a313dfb2..88da0ea1 100644 --- a/packages/ts/src/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.ts +++ b/packages/ts/src/executors/local-container-postgresql-podman-libpod-api-v0-rootless/node.ts @@ -3,6 +3,8 @@ import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; import { lstat } from "node:fs/promises"; import { request as httpRequest } from "node:http"; +import { createServer, type Server } from "node:net"; +import { setTimeout as delay } from "node:timers/promises"; import { promisify } from "node:util"; import type { LocalContainerPostgresqlManifest, @@ -16,6 +18,7 @@ import { const execFileAsync = promisify(execFile); const API_REVISION = "5.0.3"; +const CERTIFICATION_REVISION = "podman-certification:d645-v0"; const BOUNDARY_LABEL = "d645-podman-libpod-api-v0-rootless-certifier"; const DEFAULT_TTL_MS = 5 * 60 * 1000; const DEFAULT_TIMEOUT_MS = 10_000; @@ -26,8 +29,22 @@ const DIGEST = /^sha256:[a-f0-9]{64}$/; const SAFE_IMAGE = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,254}$/; const IPV4 = /^(?:\d{1,3}\.){3}\d{1,3}$/; const PROBE_ENTRYPOINT = ["/bin/bash", "-ec"] as const; +const EXPECTED_CAP_DROP = [ + "CAP_CHOWN", + "CAP_DAC_OVERRIDE", + "CAP_FOWNER", + "CAP_FSETID", + "CAP_KILL", + "CAP_NET_BIND_SERVICE", + "CAP_SETFCAP", + "CAP_SETGID", + "CAP_SETPCAP", + "CAP_SETUID", + "CAP_SYS_CHROOT", +] as const; const PEER_PORT = 15432; const PEER_COMMAND = `exec nc -l -p ${PEER_PORT} >/dev/null`; +const CANCELLATION_READY_MARKER = "graphrefly-d645-cancel-ready"; const LIMITATION_REFS = Object.freeze([ { kind: "limitation", id: "podman-libpod-api-v0-rootless-only" }, @@ -61,8 +78,6 @@ const ATTESTATION_REFS = Object.freeze([ export interface NodeLocalPodmanLibpodApiV0RootlessCertificationOptions { readonly manifest: LocalContainerPostgresqlManifest; readonly imageRef: string; - readonly observedAtMs?: number; - readonly ttlMs?: number; readonly signal?: AbortSignal; } @@ -74,24 +89,22 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi opts: NodeLocalPodmanLibpodApiV0RootlessCertificationOptions, ): Promise { const manifest = localContainerPostgresqlManifest(opts.manifest); - const observedAtMs = opts.observedAtMs ?? Date.now(); - const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS; - if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) - throw new TypeError("Invalid Podman observation time."); - if (!Number.isSafeInteger(ttlMs) || ttlMs < 1 || ttlMs > 60 * 60 * 1000) - throw new TypeError("Invalid Podman readiness TTL."); + const observedAtMs = Date.now(); + const ttlMs = DEFAULT_TTL_MS; if ( manifest.backendFamily !== LOCAL_CONTAINER_POSTGRESQL_PODMAN_LIBPOD_API_V0_ROOTLESS_BACKEND_FAMILY ) throw new TypeError("Podman certifier requires the Podman rootless backend family."); + if (manifest.backendCertificationRevision !== CERTIFICATION_REVISION) + throw new TypeError("Podman certifier requires the package-owned certification revision."); if (!SAFE_IMAGE.test(opts.imageRef) || !imageRefPinsDigest(opts.imageRef, manifest.imageDigest)) throw new TypeError("Podman certifier requires the manifest digest-pinned image."); const base = (): LocalContainerPostgresqlPodmanLibpodApiV0RootlessPreflight => ({ kind: "local-container-postgresql-podman-libpod-api-v0-rootless-preflight", manifestFingerprint: manifest.fingerprint, - backendCertificationRevision: manifest.backendCertificationRevision, + backendCertificationRevision: CERTIFICATION_REVISION, observedAtMs, expiresAtMs: observedAtMs + ttlMs, hostPlatform: `${process.platform}/${process.arch}`, @@ -142,8 +155,10 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi let socketPath: string | undefined; let networkName: string | undefined; let secretName: string | undefined; + let secretId: string | undefined; let containerId: string | undefined; let peerContainerId: string | undefined; + let hostControl: HostControl | undefined; let patch: Partial = {}; try { const discovered = await discoverRootlessPodmanSocket(opts.signal); @@ -187,6 +202,8 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi secretName = `graphrefly-d645-${suffix}-secret`; const containerName = `graphrefly-d645-${suffix}-container`; const peerContainerName = `graphrefly-d645-${suffix}-peer`; + containerId = containerName; + peerContainerId = peerContainerName; const network = await jsonRequest( socketPath, `/v${API_REVISION}/libpod/networks/create`, @@ -195,6 +212,7 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi { name: networkName, internal: true, + dns_enabled: false, labels: { "dev.graphrefly.boundary": BOUNDARY_LABEL }, }, ); @@ -202,13 +220,29 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi network.status !== 200 || !isRecord(network.body) || network.body.name !== networkName || - network.body.internal !== true + network.body.internal !== true || + network.body.dns_enabled !== false ) + return finish({ + ...patch, + cleanupVerified: await cleanup( + socketPath, + containerId, + secretName, + networkName, + peerContainerId, + ), + }); + const networkInspected = await jsonRequest( + socketPath, + `/v${API_REVISION}/libpod/networks/${encodeURIComponent(networkName)}/json`, + opts.signal, + ); + if (!networkInspectMatches(networkInspected, networkName)) return finish({ ...patch, cleanupVerified: await cleanup(socketPath, containerId, secretName, networkName), }); - const secret = await rawRequest( socketPath, `/v${API_REVISION}/libpod/secrets/create?name=${encodeURIComponent(secretName)}`, @@ -226,8 +260,15 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi ) return finish({ ...patch, - cleanupVerified: await cleanup(socketPath, containerId, secretName, networkName), + cleanupVerified: await cleanup( + socketPath, + containerId, + secretName, + networkName, + peerContainerId, + ), }); + secretId = secretBody.ID; const peerCreated = await jsonRequest( socketPath, @@ -246,7 +287,13 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi ) return finish({ ...patch, - cleanupVerified: await cleanup(socketPath, containerId, secretName, networkName), + cleanupVerified: await cleanup( + socketPath, + containerId, + secretName, + networkName, + peerContainerId, + ), }); peerContainerId = peerCreated.body.Id; const peerStarted = await rawRequest( @@ -289,7 +336,28 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi peerContainerId, ), }); - const probeCommand = probeCommandForPeer(peerIp); + hostControl = await startHostControl(); + if ( + hostControl === undefined || + !(await verifyHostGatewayPositiveControl( + socketPath, + opts.imageRef, + `graphrefly-d645-${suffix}-host-gateway-control`, + hostControl.port, + opts.signal, + )) + ) + return finish({ + ...patch, + cleanupVerified: await cleanup( + socketPath, + containerId, + secretName, + networkName, + peerContainerId, + ), + }); + const probeCommand = probeCommandForPeer(peerIp, hostControl.port); const created = await jsonRequest( socketPath, `/v${API_REVISION}/libpod/containers/create`, @@ -406,6 +474,19 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi peerContainerId, ), }); + const hostControlClosed = await closeHostControl(hostControl); + hostControl = undefined; + if (!hostControlClosed) + return finish({ + ...patch, + cleanupVerified: await cleanup( + socketPath, + containerId, + secretName, + networkName, + peerContainerId, + ), + }); patch = { ...patch, destinationPinnedEgressDenyVerified: true, @@ -423,6 +504,14 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi opts.signal, ); patch = { ...patch, cancellationVerified }; + const secretDestructionVerified = await verifySecretDestruction( + socketPath, + secretName, + secretId, + networkName, + opts.imageRef, + suffix, + ); const cleanupVerified = await cleanup( socketPath, @@ -441,7 +530,7 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi recipeVerified: manifest.recipeRevision === "postgresql-read-only-query-v1", artifactResolverReady: true, credentialResolverReady: true, - secretDestructionVerified: cleanupVerified, + secretDestructionVerified, cleanupVerified, }; return finish(patch); @@ -452,10 +541,169 @@ export async function certifyPodmanLibpodApiV0RootlessLocalContainerPostgresqlWi : await cleanup(socketPath, containerId, secretName, networkName, peerContainerId); return finish({ ...patch, - secretDestructionVerified: cleanupVerified && patch.isolationVerified === true, + secretDestructionVerified: false, cleanupVerified, }); + } finally { + if (hostControl !== undefined) await closeHostControl(hostControl); + } +} + +interface HostControl { + readonly server: Server; + readonly port: number; +} + +async function startHostControl(): Promise { + const server = createServer((socket) => socket.end()); + server.unref(); + const listening = new Promise((resolve) => { + server.once("listening", () => resolve(true)); + server.once("error", () => resolve(false)); + }); + server.listen({ host: "127.0.0.1", port: 0, exclusive: true }); + if (!(await listening)) return undefined; + const address = server.address(); + if (address === null || typeof address === "string" || address.address !== "127.0.0.1") { + await closeHostControl({ server, port: 0 }); + return undefined; } + return { server, port: address.port }; +} + +async function closeHostControl(control: HostControl): Promise { + const closed = new Promise((resolve) => { + control.server.close((error) => resolve(error === undefined)); + }); + return Promise.race([closed, delay(1_000).then(() => false)]); +} + +async function verifyHostGatewayPositiveControl( + socketPath: string, + imageRef: string, + containerName: string, + port: number, + signal?: AbortSignal, +): Promise { + let containerRef = containerName; + const command = `timeout 2 nc -z -w 2 host.containers.internal ${port}`; + const request = cancellationContainerRequest(containerName, "unused", imageRef, command); + delete request.networks; + const verified = await (async (): Promise => { + try { + const created = await jsonRequest( + socketPath, + `/v${API_REVISION}/libpod/containers/create`, + signal, + "POST", + request, + ); + if ( + created.status !== 201 || + !isRecord(created.body) || + typeof created.body.Id !== "string" || + !ID.test(created.body.Id) || + !Array.isArray(created.body.Warnings) || + created.body.Warnings.length !== 0 + ) + return false; + containerRef = created.body.Id; + const started = await rawRequest( + socketPath, + `/v${API_REVISION}/libpod/containers/${containerRef}/start`, + signal, + "POST", + ); + if (started.status !== 204) return false; + const waited = await rawRequest( + socketPath, + `/v${API_REVISION}/libpod/containers/${containerRef}/wait?condition=exited`, + signal, + "POST", + ); + return waited.status === 200 && waited.body.trim() === "0"; + } catch { + return false; + } + })(); + return verified && (await removeContainerAndVerify(socketPath, containerRef)); +} + +async function verifySecretDestruction( + socketPath: string, + secretName: string, + secretId: string, + networkName: string, + imageRef: string, + suffix: string, +): Promise { + const removed = await rawRequest( + socketPath, + `/v${API_REVISION}/libpod/secrets/${encodeURIComponent(secretName)}`, + undefined, + "DELETE", + ).catch(() => undefined); + if (removed?.status !== 204) return false; + for (const secretRef of [secretName, secretId]) { + const absent = await rawRequest( + socketPath, + `/v${API_REVISION}/libpod/secrets/${encodeURIComponent(secretRef)}/json`, + ).catch(() => undefined); + if (absent?.status !== 404) return false; + } + const nameRejected = await secretCannotBeRemounted( + socketPath, + secretName, + networkName, + imageRef, + `graphrefly-d645-${suffix}-deleted-secret-name`, + ); + const idRejected = await secretCannotBeRemounted( + socketPath, + secretId, + networkName, + imageRef, + `graphrefly-d645-${suffix}-deleted-secret-id`, + ); + return nameRejected && idRejected; +} + +async function secretCannotBeRemounted( + socketPath: string, + secretRef: string, + networkName: string, + imageRef: string, + containerName: string, +): Promise { + const created = await jsonRequest( + socketPath, + `/v${API_REVISION}/libpod/containers/create`, + undefined, + "POST", + probeContainerRequest(containerName, networkName, secretRef, imageRef, "true"), + ).catch(() => undefined); + const cleanupRef = + created && + created.status === 201 && + isRecord(created.body) && + typeof created.body.Id === "string" && + ID.test(created.body.Id) + ? created.body.Id + : containerName; + const absent = await rawRequest( + socketPath, + `/v${API_REVISION}/libpod/containers/${encodeURIComponent(containerName)}/json`, + ).catch(() => undefined); + const rejected = + created !== undefined && + created.status === 500 && + isRecord(created.body) && + created.body.cause === "no such secret" && + created.body.response === 500 && + created.body.message === `no secret with name or id "${secretRef}": no such secret` && + absent?.status === 404; + const cleanupVerified = await removeContainerAndVerify(socketPath, cleanupRef); + return rejected && cleanupVerified; } async function verifyCancellationCanaries( @@ -470,7 +718,7 @@ async function verifyCancellationCanaries( networkName, imageRef, name: `graphrefly-d645-${suffix}-cooperative-cancel`, - command: "trap 'exit 0' TERM; while :; do :; done", + command: `trap 'exit 0' TERM; echo ${CANCELLATION_READY_MARKER}; while :; do :; done`, expectedExitCode: "0", signal, }); @@ -480,7 +728,7 @@ async function verifyCancellationCanaries( networkName, imageRef, name: `graphrefly-d645-${suffix}-forced-cancel`, - command: "trap '' TERM; while :; do sleep 1; done", + command: `trap '' TERM; echo ${CANCELLATION_READY_MARKER}; while :; do sleep 1; done`, expectedExitCode: "137", signal, }); @@ -495,86 +743,101 @@ async function runCancellationCanary(opts: { readonly expectedExitCode: string; readonly signal?: AbortSignal; }): Promise { - let containerId: string | undefined; - try { - const created = await jsonRequest( - opts.socketPath, - `/v${API_REVISION}/libpod/containers/create`, - opts.signal, - "POST", - cancellationContainerRequest(opts.name, opts.networkName, opts.imageRef, opts.command), - ); - if ( - created.status !== 201 || - !isRecord(created.body) || - typeof created.body.Id !== "string" || - !ID.test(created.body.Id) || - !Array.isArray(created.body.Warnings) || - created.body.Warnings.length !== 0 - ) - return false; - containerId = created.body.Id; - const inspected = await jsonRequest( - opts.socketPath, - `/v${API_REVISION}/libpod/containers/${containerId}/json`, - opts.signal, - ); - if ( - !cancellationInspectMatches( - inspected, - containerId, - opts.name, - opts.networkName, - opts.imageRef, - opts.command, + let containerRef = opts.name; + const canaryVerified = await (async (): Promise => { + try { + const created = await jsonRequest( + opts.socketPath, + `/v${API_REVISION}/libpod/containers/create`, + opts.signal, + "POST", + cancellationContainerRequest(opts.name, opts.networkName, opts.imageRef, opts.command), + ); + if ( + created.status !== 201 || + !isRecord(created.body) || + typeof created.body.Id !== "string" || + !ID.test(created.body.Id) || + !Array.isArray(created.body.Warnings) || + created.body.Warnings.length !== 0 ) - ) - return false; - const started = await rawRequest( - opts.socketPath, - `/v${API_REVISION}/libpod/containers/${containerId}/start`, - opts.signal, - "POST", - ); - if (started.status !== 204) return false; - const stopped = await rawRequest( - opts.socketPath, - `/v${API_REVISION}/libpod/containers/${containerId}/stop?timeout=2`, - opts.signal, - "POST", - ); - if (stopped.status !== 200 && stopped.status !== 204) return false; - const waited = await rawRequest( - opts.socketPath, - `/v${API_REVISION}/libpod/containers/${containerId}/wait?condition=exited`, - opts.signal, - "POST", - ); - if (waited.status !== 200) return false; - const settled = await jsonRequest( - opts.socketPath, - `/v${API_REVISION}/libpod/containers/${containerId}/json`, - opts.signal, - ); - if (settled.status !== 200 || !isRecord(settled.body)) return false; - const state = isRecord(settled.body.State) ? settled.body.State : undefined; - return ( - !!state && - state.Running === false && - state.ExitCode === Number.parseInt(opts.expectedExitCode, 10) - ); - } catch { - return false; - } finally { - if (containerId !== undefined) { - await rawRequest( + return false; + containerRef = created.body.Id; + const inspected = await jsonRequest( + opts.socketPath, + `/v${API_REVISION}/libpod/containers/${containerRef}/json`, + opts.signal, + ); + if ( + !cancellationInspectMatches( + inspected, + containerRef, + opts.name, + opts.networkName, + opts.imageRef, + opts.command, + ) + ) + return false; + const started = await rawRequest( + opts.socketPath, + `/v${API_REVISION}/libpod/containers/${containerRef}/start`, + opts.signal, + "POST", + ); + if (started.status !== 204) return false; + if (!(await waitForCancellationReady(opts.socketPath, containerRef, opts.signal))) + return false; + const stopped = await rawRequest( + opts.socketPath, + `/v${API_REVISION}/libpod/containers/${containerRef}/stop?timeout=2`, + opts.signal, + "POST", + ); + if (stopped.status !== 200 && stopped.status !== 204) return false; + const waited = await rawRequest( + opts.socketPath, + `/v${API_REVISION}/libpod/containers/${containerRef}/wait?condition=exited`, + opts.signal, + "POST", + ); + if (waited.status !== 200) return false; + const settled = await jsonRequest( opts.socketPath, - `/v${API_REVISION}/libpod/containers/${containerId}?force=true&v=true`, - undefined, - "DELETE", - ).catch(() => undefined); + `/v${API_REVISION}/libpod/containers/${containerRef}/json`, + opts.signal, + ); + if (settled.status !== 200 || !isRecord(settled.body)) return false; + const state = isRecord(settled.body.State) ? settled.body.State : undefined; + return ( + !!state && + state.Running === false && + state.ExitCode === Number.parseInt(opts.expectedExitCode, 10) + ); + } catch { + return false; } + })(); + const cleanupVerified = await removeContainerAndVerify(opts.socketPath, containerRef); + return canaryVerified && cleanupVerified; +} + +async function waitForCancellationReady( + socketPath: string, + containerRef: string, + signal?: AbortSignal, +): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (signal?.aborted) return false; + const logs = await rawRequest( + socketPath, + `/v${API_REVISION}/libpod/containers/${containerRef}/logs?stdout=true&stderr=true&tail=10`, + signal, + ).catch(() => undefined); + if (logs?.status === 200 && logs.body.includes(CANCELLATION_READY_MARKER)) return true; + await delay(25, undefined, { signal }).catch(() => undefined); } + return false; } interface DiscoveredPodman { @@ -707,7 +970,7 @@ function exactCandidateFacts( engineRevision: API_REVISION, runtimeRevision: "1.14.4", guestPlatform: "linux/arm64", - vmRuntimeRevision: `${discovered.machineName}:applehv-v1`, + vmRuntimeRevision: "podman-machine:applehv-v1", }; } @@ -784,8 +1047,10 @@ function peerContainerRequest( }; } -function probeCommandForPeer(peerIp: string): string { +function probeCommandForPeer(peerIp: string, hostControlPort: number): string { if (!validIpv4(peerIp)) throw new TypeError("Invalid private Podman probe peer address."); + if (!Number.isSafeInteger(hostControlPort) || hostControlPort < 1_024 || hostControlPort > 65_535) + throw new TypeError("Invalid host control port."); return [ 'test "$(id -u)" != "0"', 'test "$(cat /run/secrets/d645-canary)" = "d645-canary-value"', @@ -793,12 +1058,45 @@ function probeCommandForPeer(peerIp: string): string { "! grep -Eq '^[^[:space:]]+[[:space:]]+00000000[[:space:]]' /proc/net/route", "! timeout 1 nc -z -w 1 1.1.1.1 53", "! timeout 1 nc -z -w 1 169.254.169.254 80", - "! timeout 1 nc -z -w 1 127.0.0.1 15432", - "! timeout 1 nc -z -w 1 host.containers.internal 15432", + "! timeout 1 nc -z -w 1 169.254.1.1 9", + `! timeout 1 nc -z -w 1 127.0.0.1 ${hostControlPort}`, + `! timeout 1 nc -z -w 1 host.containers.internal ${hostControlPort}`, 'test -z "$(timeout 2 getent hosts example.com || true)"', ].join(" && "); } +function networkInspectMatches(response: JsonResponse, networkName: string): boolean { + if (response.status !== 200 || !isRecord(response.body)) return false; + const labels = isRecord(response.body.labels) ? response.body.labels : undefined; + const subnets = Array.isArray(response.body.subnets) ? response.body.subnets : undefined; + if ( + response.body.name !== networkName || + response.body.driver !== "bridge" || + response.body.internal !== true || + response.body.dns_enabled !== false || + response.body.ipv6_enabled !== false || + !labels || + labels["dev.graphrefly.boundary"] !== BOUNDARY_LABEL || + !subnets || + subnets.length !== 1 || + !isRecord(subnets[0]) || + typeof subnets[0].subnet !== "string" + ) + return false; + return privateIpv4Cidr(subnets[0].subnet); +} + +function privateIpv4Cidr(value: string): boolean { + const match = /^(10|172|192)\.(\d{1,3})\.(\d{1,3})\.0\/24$/.exec(value); + if (!match) return false; + const second = Number(match[2]); + const third = Number(match[3]); + if (second > 255 || third > 255) return false; + if (match[1] === "172") return second >= 16 && second <= 31; + if (match[1] === "192") return second === 168; + return true; +} + function cancellationContainerRequest( name: string, network: string, @@ -865,6 +1163,7 @@ function runningPeerIp( !labels || labels["dev.graphrefly.boundary"] !== BOUNDARY_LABEL || !host || + !containmentHostMatches(host) || host.ReadonlyRootfs !== true || host.Privileged !== false || !Array.isArray(host.SecurityOpt) || @@ -916,6 +1215,7 @@ function inspectMatches( !!labels && labels["dev.graphrefly.boundary"] === BOUNDARY_LABEL && !!host && + containmentHostMatches(host) && host.ReadonlyRootfs === true && host.Privileged === false && Array.isArray(host.SecurityOpt) && @@ -960,6 +1260,7 @@ function cancellationInspectMatches( !!labels && labels["dev.graphrefly.boundary"] === BOUNDARY_LABEL && !!host && + containmentHostMatches(host) && host.ReadonlyRootfs === true && host.Privileged === false && Array.isArray(host.SecurityOpt) && @@ -976,6 +1277,17 @@ function cancellationInspectMatches( ); } +function containmentHostMatches(host: Record): boolean { + const portBindings = isRecord(host.PortBindings) ? host.PortBindings : undefined; + return ( + exactStrings(host.CapDrop, EXPECTED_CAP_DROP) && + host.PublishAllPorts === false && + portBindings !== undefined && + Object.keys(portBindings).length === 0 && + host.NetworkMode === "bridge" + ); +} + async function cleanup( socketPath: string, containerId?: string, @@ -986,18 +1298,7 @@ async function cleanup( let verified = true; for (const privateContainerId of [containerId, peerContainerId]) { if (privateContainerId === undefined) continue; - const response = await rawRequest( - socketPath, - `/v${API_REVISION}/libpod/containers/${privateContainerId}?force=true&v=true`, - undefined, - "DELETE", - ).catch(() => undefined); - const absent = await rawRequest( - socketPath, - `/v${API_REVISION}/libpod/containers/${privateContainerId}/json`, - ).catch(() => undefined); - verified = - (response?.status === 200 || response?.status === 404) && absent?.status === 404 && verified; + verified = (await removeContainerAndVerify(socketPath, privateContainerId)) && verified; } if (secretName !== undefined) { const removed = await rawRequest( @@ -1030,6 +1331,24 @@ async function cleanup( return verified; } +async function removeContainerAndVerify( + socketPath: string, + containerRef: string, +): Promise { + const encodedRef = encodeURIComponent(containerRef); + const response = await rawRequest( + socketPath, + `/v${API_REVISION}/libpod/containers/${encodedRef}?force=true&v=true`, + undefined, + "DELETE", + ).catch(() => undefined); + const absent = await rawRequest( + socketPath, + `/v${API_REVISION}/libpod/containers/${encodedRef}/json`, + ).catch(() => undefined); + return (response?.status === 200 || response?.status === 404) && absent?.status === 404; +} + interface RawResponse { readonly status: number; readonly body: string; From 0d37fedc24a390a9d067c75fd8693a10b0a4a893 Mon Sep 17 00:00:00 2001 From: David Chen Date: Tue, 28 Jul 2026 20:11:28 -0700 Subject: [PATCH 6/7] test(ts): keep D647 release scope isolated --- packages/ts/src/__tests__/subpaths.test.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/ts/src/__tests__/subpaths.test.ts b/packages/ts/src/__tests__/subpaths.test.ts index d3b09fad..be68b799 100644 --- a/packages/ts/src/__tests__/subpaths.test.ts +++ b/packages/ts/src/__tests__/subpaths.test.ts @@ -418,7 +418,6 @@ describe("package subpath barrels (D40/D41 intent parity)", () => { expect(typeof adapters.toHttp).toBe("function"); expect(typeof adapters.toProcess).toBe("function"); expect(typeof adapters.toWebSocket).toBe("function"); - expect(typeof adapters.attachKeyedRateLimitAuthority).toBe("function"); expect(typeof adapters.webSocketSession).toBe("function"); expect(typeof adapters.remoteCall).toBe("function"); expect(typeof adapters.remoteResponder).toBe("function"); @@ -991,12 +990,7 @@ describe("package subpath barrels (D40/D41 intent parity)", () => { expect(typeof orchestration.breakerBundle).toBe("function"); expect(typeof orchestration.processBundle).toBe("function"); expect(typeof orchestration.processEffectRunner).toBe("function"); - expect(typeof orchestration.localFixedWindowRateLimitBundle).toBe("function"); - expect(typeof orchestration.keyedRateLimitAdmissionBundle).toBe("function"); - expect(Object.hasOwn(orchestration, "rateLimitBundle")).toBe(false); - expect(Object.hasOwn(rootPackage, "localFixedWindowRateLimitBundle")).toBe(false); - expect(Object.hasOwn(rootPackage, "keyedRateLimitAdmissionBundle")).toBe(false); - expect(Object.hasOwn(rootPackage, "attachKeyedRateLimitAuthority")).toBe(false); + expect(typeof orchestration.rateLimitBundle).toBe("function"); expect(typeof orchestration.timeoutBundle).toBe("function"); expect(typeof orchestration.requestSatisfactionProjector).toBe("function"); expect(typeof orchestration.effectRunCompletionProjector).toBe("function"); From 01b4bc1581d4c9f25fb14de120e0cae113723100 Mon Sep 17 00:00:00 2001 From: David Chen Date: Tue, 28 Jul 2026 20:19:12 -0700 Subject: [PATCH 7/7] docs(ts): migrate consumers to ecosystem packages --- demos/compat-matrix/package.json | 4 + .../src/components/ReactDemo.tsx | 10 +- .../src/components/SolidDemo.tsx | 10 +- .../src/components/SvelteDemo.svelte | 2 +- .../compat-matrix/src/components/VueDemo.vue | 6 +- demos/compat-matrix/src/lib/counter.ts | 16 +- examples/README.md | 8 +- examples/framework/react/README.md | 2 +- examples/framework/react/package.json | 1 + examples/framework/react/src/Counter.tsx | 2 +- examples/framework/solid/README.md | 2 +- examples/framework/solid/package.json | 1 + examples/framework/solid/src/Counter.tsx | 2 +- examples/framework/svelte/README.md | 2 +- examples/framework/svelte/package.json | 1 + examples/framework/svelte/src/App.svelte | 2 +- examples/framework/vue/README.md | 2 +- examples/framework/vue/package.json | 1 + examples/framework/vue/src/App.vue | 2 +- examples/nestjs-graph-boundary/README.md | 2 +- examples/nestjs-graph-boundary/package.json | 1 + examples/nestjs-graph-boundary/src/main.ts | 10 +- packages/ts/README.md | 10 +- pnpm-lock.yaml | 112 +++++ pnpm-workspace.yaml | 6 + .../content/docs/api/agenticmemorybundle.md | 9 +- ...genticmemoryrecordusedecisioncoordinate.md | 46 ++ .../api/agenticmemoryrecordusegatebundle.md | 46 ++ .../agenticmemoryrecorduserecordidentity.md | 40 ++ .../agenticmemoryrecorduserequestidentity.md | 40 ++ .../assertagenticmemoryrecordusedecision.md | 40 ++ .../assertagenticmemoryrecorduserequest.md | 42 ++ ...nticatedoutboundcustomerhostedtransport.md | 51 +++ .../authenticatedwssmanagedcloudtransport.md | 48 ++ .../content/docs/api/bindingemitoptions.md | 46 -- .../src/content/docs/api/bindingrequestid.md | 46 -- .../content/docs/api/blueprinttomermaid.md | 47 ++ ...ootlesslocalcontainerpostgresqlwithnode.md | 44 ++ .../docs/api/clickhousetrustedqueryruntime.md | 48 ++ .../createagenticmemoryrecordusedecision.md | 46 ++ ...reateclickhousetrustedqueryadapterinput.md | 43 ++ ...setrustedqueryscenarioresultfromoutcome.md | 43 ++ .../docs/api/creategraphcroncontroller.md | 40 -- .../docs/api/creategraphexceptionfilter.md | 40 -- .../docs/api/creategraphguarddeniedfilter.md | 34 -- .../docs/api/creategraphmessagebridge.md | 40 -- .../content/docs/api/creategraphwsbridge.md | 40 -- .../api/createnestgraphboundaryinterceptor.md | 40 -- .../docs/api/createnestgraphboundaryrunner.md | 40 -- .../src/content/docs/api/createnodeinput.md | 40 -- .../src/content/docs/api/createnoderecord.md | 47 -- .../src/content/docs/api/createnodevalue.md | 40 -- ...stgresqladmittedenvelopefromapprovedrun.md | 51 +++ .../api/customerhostedpostgresqlruntime.md | 48 ++ .../content/docs/api/diffgraphblueprints.md | 46 ++ ...oudpostgresqlclaimwithattemptcredential.md | 74 ++++ ...sqlclaimwithauthorizedattemptcredential.md | 70 +++ website/src/content/docs/api/fromnestcron.md | 44 -- .../content/docs/api/fromnestdiagnostics.md | 44 -- website/src/content/docs/api/fromnesterror.md | 44 -- website/src/content/docs/api/fromnestguard.md | 44 -- .../src/content/docs/api/fromnestintercept.md | 44 -- .../src/content/docs/api/fromnestlifecycle.md | 44 -- .../src/content/docs/api/fromnestmessage.md | 44 -- website/src/content/docs/api/fromnestreq.md | 44 -- website/src/content/docs/api/fromnestws.md | 44 -- website/src/content/docs/api/getgraphtoken.md | 40 -- .../docs/api/getnestboundarybindings.md | 44 -- .../content/docs/api/getnestboundarytoken.md | 40 -- website/src/content/docs/api/getnodetoken.md | 40 -- website/src/content/docs/api/graphcron.md | 44 -- .../src/content/docs/api/graphcrontarget.md | 46 -- website/src/content/docs/api/grapherror.md | 44 -- .../docs/api/graphexceptionfilterbridge.md | 30 -- website/src/content/docs/api/graphfilter.md | 44 -- website/src/content/docs/api/graphguard.md | 44 -- .../content/docs/api/graphguarddecision.md | 44 -- .../docs/api/graphguarddeniedexception.md | 30 -- .../docs/api/graphguarddeniedfilter.md | 30 -- .../src/content/docs/api/graphhttpreply.md | 44 -- .../src/content/docs/api/graphintercept.md | 44 -- website/src/content/docs/api/graphinterval.md | 40 -- .../src/content/docs/api/graphlifecycle.md | 44 -- .../content/docs/api/graphlifecycletarget.md | 46 -- website/src/content/docs/api/graphmessage.md | 44 -- .../src/content/docs/api/graphmessagereply.md | 44 -- website/src/content/docs/api/graphreq.md | 44 -- website/src/content/docs/api/graphws.md | 44 -- website/src/content/docs/api/graphwsack.md | 44 -- website/src/content/docs/api/graphwsreply.md | 44 -- website/src/content/docs/api/isdataissue.md | 40 -- .../docs/api/isgraphguarddeniedexception.md | 40 -- .../src/content/docs/api/ishttpdataissue.md | 40 -- website/src/content/docs/api/issueresponse.md | 44 -- .../content/docs/api/lowerhttpreplypayload.md | 46 -- .../content/docs/api/lowerprotocolerror.md | 46 -- website/src/content/docs/api/nestprovider.md | 44 -- .../api/nodecanvaspackagetextmeasurements.md | 43 -- website/src/content/docs/api/nodereadable.md | 40 -- website/src/content/docs/api/noderecord.md | 47 -- website/src/content/docs/api/nodewritable.md | 40 -- website/src/content/docs/api/ongraphevent.md | 40 -- .../content/docs/api/parsegraphblueprint.md | 43 ++ .../postgresql16customerhostedcontrolstore.md | 43 ++ .../postgresql16managedcloudcontrolstore.md | 43 ++ .../api/postgresql16runoperationsstore.md | 43 ++ .../postgresqlquerytoolargumentsfromintent.md | 43 ++ .../postgresqltoolproviderinputfromintent.md | 48 ++ .../api/projectagenticmemoryrecordusegate.md | 51 +++ website/src/content/docs/api/protocolerror.md | 44 -- .../api/providegraphboundaryinterceptor.md | 40 -- .../docs/api/providegraphcronscheduler.md | 40 -- .../docs/api/providegraphexceptionfilter.md | 40 -- .../src/content/docs/api/providegraphguard.md | 40 -- .../docs/api/providegraphguarddeniedfilter.md | 34 -- .../docs/api/providegraphlifecyclehooks.md | 40 -- .../docs/api/providegraphmessagebridge.md | 40 -- .../docs/api/providegraphmessageproviders.md | 40 -- .../api/providegraphnativehttpproviders.md | 40 -- .../docs/api/providegraphnativeproviders.md | 40 -- .../content/docs/api/providegraphwsbridge.md | 40 -- .../docs/api/providegraphwsproviders.md | 40 -- .../content/docs/api/resolvenestmethodkey.md | 44 -- .../docs/api/sanitizenestdiagnostic.md | 44 -- website/src/content/docs/api/tonesthttp.md | 48 -- website/src/content/docs/api/usenodeinput.md | 43 -- website/src/content/docs/api/usenoderecord.md | 47 -- website/src/content/docs/api/usenodevalue.md | 40 -- .../content/docs/api/verifyblueprinthash.md | 44 ++ website/src/generated/api-sidebar.mjs | 412 +++++------------- 130 files changed, 1556 insertions(+), 3538 deletions(-) create mode 100644 website/src/content/docs/api/agenticmemoryrecordusedecisioncoordinate.md create mode 100644 website/src/content/docs/api/agenticmemoryrecordusegatebundle.md create mode 100644 website/src/content/docs/api/agenticmemoryrecorduserecordidentity.md create mode 100644 website/src/content/docs/api/agenticmemoryrecorduserequestidentity.md create mode 100644 website/src/content/docs/api/assertagenticmemoryrecordusedecision.md create mode 100644 website/src/content/docs/api/assertagenticmemoryrecorduserequest.md create mode 100644 website/src/content/docs/api/authenticatedoutboundcustomerhostedtransport.md create mode 100644 website/src/content/docs/api/authenticatedwssmanagedcloudtransport.md delete mode 100644 website/src/content/docs/api/bindingemitoptions.md delete mode 100644 website/src/content/docs/api/bindingrequestid.md create mode 100644 website/src/content/docs/api/blueprinttomermaid.md create mode 100644 website/src/content/docs/api/certifypodmanlibpodapiv0rootlesslocalcontainerpostgresqlwithnode.md create mode 100644 website/src/content/docs/api/clickhousetrustedqueryruntime.md create mode 100644 website/src/content/docs/api/createagenticmemoryrecordusedecision.md create mode 100644 website/src/content/docs/api/createclickhousetrustedqueryadapterinput.md create mode 100644 website/src/content/docs/api/createclickhousetrustedqueryscenarioresultfromoutcome.md delete mode 100644 website/src/content/docs/api/creategraphcroncontroller.md delete mode 100644 website/src/content/docs/api/creategraphexceptionfilter.md delete mode 100644 website/src/content/docs/api/creategraphguarddeniedfilter.md delete mode 100644 website/src/content/docs/api/creategraphmessagebridge.md delete mode 100644 website/src/content/docs/api/creategraphwsbridge.md delete mode 100644 website/src/content/docs/api/createnestgraphboundaryinterceptor.md delete mode 100644 website/src/content/docs/api/createnestgraphboundaryrunner.md delete mode 100644 website/src/content/docs/api/createnodeinput.md delete mode 100644 website/src/content/docs/api/createnoderecord.md delete mode 100644 website/src/content/docs/api/createnodevalue.md create mode 100644 website/src/content/docs/api/customerhostedpostgresqladmittedenvelopefromapprovedrun.md create mode 100644 website/src/content/docs/api/customerhostedpostgresqlruntime.md create mode 100644 website/src/content/docs/api/diffgraphblueprints.md create mode 100644 website/src/content/docs/api/executemanagedcloudpostgresqlclaimwithattemptcredential.md create mode 100644 website/src/content/docs/api/executemanagedcloudpostgresqlclaimwithauthorizedattemptcredential.md delete mode 100644 website/src/content/docs/api/fromnestcron.md delete mode 100644 website/src/content/docs/api/fromnestdiagnostics.md delete mode 100644 website/src/content/docs/api/fromnesterror.md delete mode 100644 website/src/content/docs/api/fromnestguard.md delete mode 100644 website/src/content/docs/api/fromnestintercept.md delete mode 100644 website/src/content/docs/api/fromnestlifecycle.md delete mode 100644 website/src/content/docs/api/fromnestmessage.md delete mode 100644 website/src/content/docs/api/fromnestreq.md delete mode 100644 website/src/content/docs/api/fromnestws.md delete mode 100644 website/src/content/docs/api/getgraphtoken.md delete mode 100644 website/src/content/docs/api/getnestboundarybindings.md delete mode 100644 website/src/content/docs/api/getnestboundarytoken.md delete mode 100644 website/src/content/docs/api/getnodetoken.md delete mode 100644 website/src/content/docs/api/graphcron.md delete mode 100644 website/src/content/docs/api/graphcrontarget.md delete mode 100644 website/src/content/docs/api/grapherror.md delete mode 100644 website/src/content/docs/api/graphexceptionfilterbridge.md delete mode 100644 website/src/content/docs/api/graphfilter.md delete mode 100644 website/src/content/docs/api/graphguard.md delete mode 100644 website/src/content/docs/api/graphguarddecision.md delete mode 100644 website/src/content/docs/api/graphguarddeniedexception.md delete mode 100644 website/src/content/docs/api/graphguarddeniedfilter.md delete mode 100644 website/src/content/docs/api/graphhttpreply.md delete mode 100644 website/src/content/docs/api/graphintercept.md delete mode 100644 website/src/content/docs/api/graphinterval.md delete mode 100644 website/src/content/docs/api/graphlifecycle.md delete mode 100644 website/src/content/docs/api/graphlifecycletarget.md delete mode 100644 website/src/content/docs/api/graphmessage.md delete mode 100644 website/src/content/docs/api/graphmessagereply.md delete mode 100644 website/src/content/docs/api/graphreq.md delete mode 100644 website/src/content/docs/api/graphws.md delete mode 100644 website/src/content/docs/api/graphwsack.md delete mode 100644 website/src/content/docs/api/graphwsreply.md delete mode 100644 website/src/content/docs/api/isdataissue.md delete mode 100644 website/src/content/docs/api/isgraphguarddeniedexception.md delete mode 100644 website/src/content/docs/api/ishttpdataissue.md delete mode 100644 website/src/content/docs/api/issueresponse.md delete mode 100644 website/src/content/docs/api/lowerhttpreplypayload.md delete mode 100644 website/src/content/docs/api/lowerprotocolerror.md delete mode 100644 website/src/content/docs/api/nestprovider.md delete mode 100644 website/src/content/docs/api/nodecanvaspackagetextmeasurements.md delete mode 100644 website/src/content/docs/api/nodereadable.md delete mode 100644 website/src/content/docs/api/noderecord.md delete mode 100644 website/src/content/docs/api/nodewritable.md delete mode 100644 website/src/content/docs/api/ongraphevent.md create mode 100644 website/src/content/docs/api/parsegraphblueprint.md create mode 100644 website/src/content/docs/api/postgresql16customerhostedcontrolstore.md create mode 100644 website/src/content/docs/api/postgresql16managedcloudcontrolstore.md create mode 100644 website/src/content/docs/api/postgresql16runoperationsstore.md create mode 100644 website/src/content/docs/api/postgresqlquerytoolargumentsfromintent.md create mode 100644 website/src/content/docs/api/postgresqltoolproviderinputfromintent.md create mode 100644 website/src/content/docs/api/projectagenticmemoryrecordusegate.md delete mode 100644 website/src/content/docs/api/protocolerror.md delete mode 100644 website/src/content/docs/api/providegraphboundaryinterceptor.md delete mode 100644 website/src/content/docs/api/providegraphcronscheduler.md delete mode 100644 website/src/content/docs/api/providegraphexceptionfilter.md delete mode 100644 website/src/content/docs/api/providegraphguard.md delete mode 100644 website/src/content/docs/api/providegraphguarddeniedfilter.md delete mode 100644 website/src/content/docs/api/providegraphlifecyclehooks.md delete mode 100644 website/src/content/docs/api/providegraphmessagebridge.md delete mode 100644 website/src/content/docs/api/providegraphmessageproviders.md delete mode 100644 website/src/content/docs/api/providegraphnativehttpproviders.md delete mode 100644 website/src/content/docs/api/providegraphnativeproviders.md delete mode 100644 website/src/content/docs/api/providegraphwsbridge.md delete mode 100644 website/src/content/docs/api/providegraphwsproviders.md delete mode 100644 website/src/content/docs/api/resolvenestmethodkey.md delete mode 100644 website/src/content/docs/api/sanitizenestdiagnostic.md delete mode 100644 website/src/content/docs/api/tonesthttp.md delete mode 100644 website/src/content/docs/api/usenodeinput.md delete mode 100644 website/src/content/docs/api/usenoderecord.md delete mode 100644 website/src/content/docs/api/usenodevalue.md create mode 100644 website/src/content/docs/api/verifyblueprinthash.md diff --git a/demos/compat-matrix/package.json b/demos/compat-matrix/package.json index 7b046188..280e2bd4 100644 --- a/demos/compat-matrix/package.json +++ b/demos/compat-matrix/package.json @@ -8,7 +8,11 @@ "preview": "astro preview" }, "dependencies": { + "@graphrefly/react": "^0.1.0", + "@graphrefly/solid": "^0.1.0", + "@graphrefly/svelte": "^0.1.0", "@graphrefly/ts": "workspace:*", + "@graphrefly/vue": "^0.1.0", "astro": "^5.16.0", "@astrojs/react": "^4.0.0", "@astrojs/vue": "^5.1.4", diff --git a/demos/compat-matrix/src/components/ReactDemo.tsx b/demos/compat-matrix/src/components/ReactDemo.tsx index 5e00d82c..50fd57c1 100644 --- a/demos/compat-matrix/src/components/ReactDemo.tsx +++ b/demos/compat-matrix/src/components/ReactDemo.tsx @@ -1,3 +1,8 @@ +import { + useNodeRecord, + useNodeInput as useStore, + useNodeValue as useSubscribe, +} from "@graphrefly/react"; import type { JotaiAtom, NanoAtom, @@ -5,11 +10,6 @@ import type { WritableNanoAtom, ZustandStoreApi, } from "@graphrefly/ts/adapters"; -import { - useNodeRecord, - useNodeInput as useStore, - useNodeValue as useSubscribe, -} from "@graphrefly/ts/adapters/react"; import type { Node } from "@graphrefly/ts/graph"; import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { diff --git a/demos/compat-matrix/src/components/SolidDemo.tsx b/demos/compat-matrix/src/components/SolidDemo.tsx index 6eaf9d82..715be509 100644 --- a/demos/compat-matrix/src/components/SolidDemo.tsx +++ b/demos/compat-matrix/src/components/SolidDemo.tsx @@ -1,5 +1,10 @@ /** @jsxImportSource solid-js */ +import { + createNodeRecord, + createNodeInput as useStore, + createNodeValue as useSubscribe, +} from "@graphrefly/solid"; import type { JotaiAtom, NanoAtom, @@ -7,11 +12,6 @@ import type { WritableNanoAtom, ZustandStoreApi, } from "@graphrefly/ts/adapters"; -import { - createNodeRecord, - createNodeInput as useStore, - createNodeValue as useSubscribe, -} from "@graphrefly/ts/adapters/solid"; import type { Node } from "@graphrefly/ts/graph"; import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"; import { diff --git a/demos/compat-matrix/src/components/SvelteDemo.svelte b/demos/compat-matrix/src/components/SvelteDemo.svelte index 40b51ec5..968979dc 100644 --- a/demos/compat-matrix/src/components/SvelteDemo.svelte +++ b/demos/compat-matrix/src/components/SvelteDemo.svelte @@ -3,7 +3,7 @@ import { nodeRecord, nodeWritable as useStore, nodeReadable as useSubscribe, -} from "@graphrefly/ts/adapters/svelte"; +} from "@graphrefly/svelte"; import type { Node } from "@graphrefly/ts/graph"; import { onMount } from "svelte"; import { readable, writable } from "svelte/store"; diff --git a/demos/compat-matrix/src/components/VueDemo.vue b/demos/compat-matrix/src/components/VueDemo.vue index 4feeed26..7928c8f7 100644 --- a/demos/compat-matrix/src/components/VueDemo.vue +++ b/demos/compat-matrix/src/components/VueDemo.vue @@ -6,12 +6,8 @@ import type { WritableNode, ZustandStoreApi, } from "@graphrefly/ts/adapters"; -import { - useNodeInput, - useNodeRecord, - useNodeValue as useSubscribe, -} from "@graphrefly/ts/adapters/vue"; import type { Node } from "@graphrefly/ts/graph"; +import { useNodeInput, useNodeRecord, useNodeValue as useSubscribe } from "@graphrefly/vue"; import { computed, onMounted, onUnmounted, ref, watch } from "vue"; import { counterGraph, diff --git a/demos/compat-matrix/src/lib/counter.ts b/demos/compat-matrix/src/lib/counter.ts index f14a0876..566dd2e8 100644 --- a/demos/compat-matrix/src/lib/counter.ts +++ b/demos/compat-matrix/src/lib/counter.ts @@ -128,7 +128,7 @@ export type FrameworkName = "react" | "vue" | "solid" | "svelte"; const GRAPHREFLY_BY_FRAMEWORK: Record = { react: `// GraphReFly direct node binding [React] import { graph } from "@graphrefly/ts/graph"; -import { useNodeInput, useNodeValue } from "@graphrefly/ts/adapters/react"; +import { useNodeInput, useNodeValue } from "@graphrefly/react"; const g = graph({ name: "counter" }); const count = g.state(0, { name: "count" }); @@ -149,7 +149,7 @@ function Counter() { vue: `