diff --git a/README.md b/README.md index 2293038..f63ec7a 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,15 @@ Configuration lives at exactly one of two levels: in the package (`eslint.config ## Packages -| Package | What it is | -| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| [`graph-dag`](packages/core/graph/dag/README.md) | Model-independent dependency-graph primitives over a node set and a `needs` accessor: layers, ancestors, descendants, cycles, transitive reduction, critical path. | -| [`graph-schema`](packages/core/graph/schema/README.md) | The process model: typed identifier spaces, `defineModel` with compile-time reference checking, structural validation. | -| [`graph-projection`](packages/core/graph/projection/README.md) | Projections of the model into a flowchart, swimlane, state machine and sequence diagram, plus derived hand-offs. | -| [`graph-domain`](packages/core/graph/domain/README.md) | Reasoning over the model and a run overlay: control graph, illegal cycles, three-valued status, ready set, blocked-by, impact, what-if and critical path. | -| [`graph-layout`](packages/core/graph/layout/README.md) | Layered layout of a projected flowchart, swimlane or state machine into positions in the model's `views` shape. | -| [`process-graph`](packages/targets/process-graph/README.md) | The primary entry point: re-exports the schema, projection and domain APIs flat and the DAG API as the `dag` namespace. | +| Package | What it is | +| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`graph-dag`](packages/core/graph/dag/README.md) | Model-independent dependency-graph primitives over a node set and a `needs` accessor: layers, ancestors, descendants, cycles, transitive reduction, critical path. | +| [`graph-schema`](packages/core/graph/schema/README.md) | The process model: typed identifier spaces, `defineModel` with compile-time reference checking, structural validation. | +| [`graph-projection`](packages/core/graph/projection/README.md) | Projections of the model into a flowchart, swimlane, state machine and sequence diagram, plus derived hand-offs. | +| [`graph-domain`](packages/core/graph/domain/README.md) | Reasoning over the model and a run overlay: control graph, illegal cycles, three-valued status, ready set, blocked-by, impact, what-if and critical path. | +| [`graph-layout`](packages/core/graph/layout/README.md) | Layered layout of a projected flowchart, swimlane or state machine into positions in the model's `views` shape. | +| [`graph-source-contract`](packages/core/graph/source-contract/README.md) | The `GraphSource` port as an oRPC contract: versioned, paged and streamed sync of bound activities, participants, transitions and run state, resumable by cursor, with an in-memory fake and a conformance kit. | +| [`process-graph`](packages/targets/process-graph/README.md) | The primary entry point: re-exports the schema, projection and domain APIs flat and the DAG API as the `dag` namespace. | ## Conventions diff --git a/packages/core/graph/source-contract/AGENTS.md b/packages/core/graph/source-contract/AGENTS.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/core/graph/source-contract/AGENTS.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/core/graph/source-contract/CLAUDE.md b/packages/core/graph/source-contract/CLAUDE.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/core/graph/source-contract/CLAUDE.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/core/graph/source-contract/README.md b/packages/core/graph/source-contract/README.md new file mode 100644 index 0000000..65cbeca --- /dev/null +++ b/packages/core/graph/source-contract/README.md @@ -0,0 +1,118 @@ +# @exadev/process-graph-source-contract + +[![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/process-graph) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/@exadev/process-graph-source-contract) [![Release](https://img.shields.io/github/v/release/ExaDev/process-graph)](https://github.com/ExaDev/process-graph/releases/latest) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/process-graph/ci.yml?branch=main)](https://github.com/ExaDev/process-graph/actions) + +The `GraphSource` port: how the process model from `@exadev/process-graph-schema` reads work from an external tracker. It is an [oRPC](https://orpc.dev) router contract (`@orpc/contract`), so one definition is called in process, across a `MessagePort` to a worker, or over HTTP, and a consumer never changes with the transport. The package also ships the router builder adapters use, an in-memory fake and the conformance kit every adapter must pass. `src/` imports nothing Node-only. + +```ts +import { createRouterClient } from "@orpc/server"; +import { + assembleSync, + createFakeGraphSource, + createGraphSourceRouter, +} from "@exadev/process-graph-source-contract"; + +const router = createGraphSourceRouter( + createFakeGraphSource({ source: "fake", scopes }), +); +const client = createRouterClient(router); + +const pages = []; +for await (const page of await client.v1.sync.stream({ + scope: ["fake:acme/alpha"], +})) { + pages.push(page); +} +const { model, overlay, relations, gaps } = assembleSync(pages); +``` + +## The contract + +`graphSourceContract` is keyed by version. A breaking change adds `v2` beside `v1`, so one server serves both while clients move across. + +| Procedure | Input | Output | +| ---------------- | ------------------- | -------------------------------------------- | +| `v1.describe` | none | `{ source }`: the prefix of every identifier | +| `v1.sync.page` | `{ scope, after? }` | one `SyncPage` | +| `v1.sync.stream` | `{ scope, after? }` | an event iterator of `SyncPage` | + +`scope` lists source-qualified scope identifiers, read in order, each once: for GitHub, `github:org` for every repository an owner has and `github:org/repo` for one. `after` is a cursor from an earlier page's `next`. + +A `SyncPage` is a slice of a model: bound activities, the participants they name, the transitions between them, relations, run state as an `Overlay`, and gaps. Its record schemas are taken from `modelSchema`, so a page can never drift from the model. + +- **Bound activity.** A model `activity` whose `source` is required and equal to its `id`, a source-qualified identifier such as `github:org/repo#12`. The same record reached twice is the same element. +- **Transition.** One per dependency, from the prerequisite to the work that waits on it. +- **Relation.** A link the model has no field for: `child-of` (a sub-task to its parent), `assigned-to` (an activity to a person) and `part-of` (a repository to its organisation). Relations carry no ordering; a consumer decides whether a parent waits on its children. +- **Gap.** Part of the scope the source could not read (`not-found` or `forbidden`), reported as data so the rest of the sync still arrives. +- **`next`.** The cursor to resume after this page; absent on the last page. + +## Resuming + +Cursors are stable: the same scope and cursor give the same page while the source is unchanged, and a cursor holds no in-memory state, so a sync interrupted in one process resumes in another. `sync.stream` reads pages only as the consumer pulls them, so a slow consumer applies backpressure and closing the iterator stops the reads. Each event carries its `next` cursor as the event id, so over an event-stream transport a reconnecting client's `lastEventId` resumes there; an explicit `after` takes precedence. + +## Errors + +Failures that end a call are typed oRPC errors with their detail as data, using oRPC's common codes so an HTTP transport answers with the matching status. + +| Code | Data | When | +| ------------------- | -------------------------------------------------------- | -------------------------------------------------------- | +| `UNAUTHORIZED` | `{}` | The source rejected the credentials. | +| `TOO_MANY_REQUESTS` | `{ resetAt? }` | The source is rate limiting; resume after `resetAt`. | +| `BAD_REQUEST` | `{ reason: "invalid-cursor" \| "invalid-scope", value }` | A cursor the source did not issue, or a malformed scope. | + +What a source can partly read is not an error: it is a page with gaps. Every call takes the caller's abort signal through the oRPC client options and passes it to the source. + +## Writing an adapter + +An adapter implements `GraphSourcePager`, one page at a time, and throws `GraphSourceError` for the failures above. `createGraphSourceRouter(pager)` builds the whole port on it, so paging, streaming, resumption and error mapping are the same for every source. + +```ts +const pager: GraphSourcePager = { + source: "linear", + page: async ({ scope, cursor, signal }) => readOnePage(scope, cursor, signal), +}; +``` + +## Conformance + +`@exadev/process-graph-source-contract/conformance` exports `graphSourceConformance`, a set of Vitest tests (Vitest is an optional peer dependency, needed only for this entry point). Call it from the adapter's own tests, once per transport; the tests talk only to the oRPC client. + +```ts +import { + graphSourceConformance, + inProcessClient, +} from "@exadev/process-graph-source-contract/conformance"; + +graphSourceConformance({ + name: "my adapter, in process", + createClient: () => inProcessClient(createGraphSourceRouter(pager)), + scope: ["linear:team-a"], // must span at least two pages + missingScope: "linear:no-such-team", + malformedScope: "team-a", +}); +``` + +The entry point also exports `inProcessClient(router)` and `messagePortClient(router)`, the two transports this package's own tests run the kit over; a `messagePortClient` closes its channel after the test file. + +It checks that paging and streaming agree; that a stream closed after any page resumes from that page's `next` with exactly the pages that follow; that a cursor never repeats; that a sync is deterministic; that the assembled model and overlay pass the schema's validation and every identifier carries the source prefix; that relations name what the sync returned; that an unknown scope is a gap beside the rest; that malformed scopes and foreign cursors are typed errors; and that an aborted signal ends both procedures. + +## Functions + +| Export | What it is | +| -------------------------------- | ------------------------------------------------------------------------------------------- | +| `graphSourceContract` | The router contract. | +| `GraphSourceClient` | The client type for the contract, over any transport. | +| `createGraphSourceRouter(pager)` | The implemented router over a pager. | +| `assembleSync(pages)` | Merges a sync's pages into one model, overlay, relation list and gap list; later pages win. | +| `paginate(first, read)` | An async iterable over a cursor-linked sequence, reading each page only as it is pulled. | +| `createFakeGraphSource(options)` | The reference in-memory pager. | +| `GraphSourceError` | What a pager throws to end a call with a typed failure. | + +## Commands + +```sh +pnpm build # tsc -b -> dist/ (ESM + .d.ts) +pnpm test # unit tests, including the conformance kit over the fake in process and over a message port +pnpm test:smoke # the built dist/ exercised as a real artefact +pnpm test:mutation # Stryker +``` diff --git a/packages/core/graph/source-contract/eslint.config.ts b/packages/core/graph/source-contract/eslint.config.ts new file mode 100644 index 0000000..4c43201 --- /dev/null +++ b/packages/core/graph/source-contract/eslint.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "eslint/config"; +import { packageLintConfig } from "../../../../eslint.shared.ts"; + +export default defineConfig( + packageLintConfig({ + tsconfigRootDir: import.meta.dirname, + isomorphic: true, + }), +); diff --git a/packages/core/graph/source-contract/package.json b/packages/core/graph/source-contract/package.json new file mode 100644 index 0000000..a9c32fb --- /dev/null +++ b/packages/core/graph/source-contract/package.json @@ -0,0 +1,90 @@ +{ + "name": "@exadev/process-graph-source-contract", + "version": "0.0.0", + "description": "The GraphSource port as an oRPC contract: versioned, paginated and streamed sync of bound activities, participants, transitions and overlay status, with an in-memory fake and a conformance kit every adapter must pass.", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/ExaDev/process-graph.git", + "directory": "packages/core/graph/source-contract" + }, + "homepage": "https://github.com/ExaDev/process-graph/tree/main/packages/core/graph/source-contract#readme", + "bugs": { + "url": "https://github.com/ExaDev/process-graph/issues" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./conformance": { + "types": "./dist/conformance.d.ts", + "import": "./dist/conformance.js" + } + }, + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "publishConfig": { + "access": "public", + "provenance": true, + "registry": "https://registry.npmjs.org/" + }, + "sideEffects": false, + "license": "MIT", + "scripts": { + "build": "turbo run _build", + "_build": "tsc -b tsconfig.build.json", + "lint": "turbo run _lint", + "_lint": "eslint . --cache --max-warnings 0", + "_lint:fix": "eslint . --fix --cache --max-warnings 0", + "typecheck": "turbo run _typecheck _typecheck:attw", + "_typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit", + "_typecheck:attw": "attw --pack --profile esm-only", + "test": "turbo run _test", + "_test": "vitest run --project unit", + "test:coverage": "turbo run _test:coverage", + "_test:coverage": "vitest run --project unit --coverage", + "test:smoke": "turbo run _test:smoke", + "_test:smoke": "vitest run --project smoke", + "test:mutation": "turbo run _test:mutation", + "_test:mutation": "stryker run stryker.config.ts", + "prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && publint && attw --pack --profile esm-only" + }, + "keywords": [ + "process-model", + "orpc", + "contract", + "port", + "conformance" + ], + "devDependencies": { + "@arethetypeswrong/cli": "0.18.5", + "@stryker-mutator/core": "10.0.0", + "@stryker-mutator/typescript-checker": "10.0.0", + "@stryker-mutator/vitest-runner": "10.0.0", + "@types/node": "26.6.4", + "@vitest/coverage-v8": "5.0.3", + "eslint": "10.12.0", + "publint": "0.3.25", + "typescript": "6.0.3", + "vitest": "5.0.3" + }, + "dependencies": { + "@exadev/process-graph-schema": "1.0.2", + "@orpc/client": "2.0.0-beta.41", + "@orpc/contract": "2.0.0-beta.41", + "@orpc/server": "2.0.0-beta.41", + "trilean": "1.7.2", + "zod": "4.6.5" + }, + "peerDependencies": { + "vitest": "5.0.3" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } +} diff --git a/packages/core/graph/source-contract/src/assemble.ts b/packages/core/graph/source-contract/src/assemble.ts new file mode 100644 index 0000000..cbc0c18 --- /dev/null +++ b/packages/core/graph/source-contract/src/assemble.ts @@ -0,0 +1,70 @@ +import type { Model, OpenIds, Overlay } from "@exadev/process-graph-schema"; +import type { Gap, Relation, SyncPage } from "./schema.ts"; + +/** A whole sync merged into one model, with what the model has no place for kept beside it. */ +export interface AssembledSync { + /** The participants, bound activities and transitions of every page, each identifier once. */ + readonly model: Model; + /** The run state of every activity, keyed by its identifier. */ + readonly overlay: Overlay; + /** Every relation, each distinct one once. */ + readonly relations: readonly Relation[]; + /** Every gap, in page order. */ + readonly gaps: readonly Gap[]; +} + +const relationKey = (relation: Readonly): string => + JSON.stringify([relation.kind, relation.from, relation.to]); + +/** Keeps one entry per key, in first-seen order, holding the latest value seen for it. */ +const latestById = ( + items: readonly T[], + keyOf: (item: T) => string, +): readonly T[] => { + const byKey = new Map(); + for (const item of items) { + byKey.set(keyOf(item), item); + } + + return [...byKey.values()]; +}; + +/** + * Merges the pages of a sync into one model. A record can appear on several pages, for example an issue in another repository reached through a dependency before its own repository is read; identity is the identifier, so it becomes one element, and where pages disagree the later page wins, being the more recent read. + * @param pages - The pages of one sync, in the order they arrived. + * @returns The model, overlay, relations and gaps of the whole sync. + */ +export const assembleSync = (pages: readonly SyncPage[]): AssembledSync => { + // Entries later in the list overwrite earlier ones, so the later page wins. + const overlay: Overlay = Object.fromEntries( + pages.flatMap((page) => Object.entries(page.overlay)), + ); + + return { + model: { + participants: latestById( + pages.flatMap((page) => page.participants), + (participant) => participant.id, + ), + events: [], + actions: [], + artefacts: [], + elements: latestById( + pages.flatMap((page) => page.activities), + (activity) => activity.id, + ), + transitions: latestById( + pages.flatMap((page) => page.transitions), + (transition) => transition.id, + ), + fragments: [], + projections: [], + }, + overlay, + relations: latestById( + pages.flatMap((page) => page.relations), + relationKey, + ), + gaps: pages.flatMap((page) => page.gaps), + }; +}; diff --git a/packages/core/graph/source-contract/src/assemble.unit.test.ts b/packages/core/graph/source-contract/src/assemble.unit.test.ts new file mode 100644 index 0000000..b002254 --- /dev/null +++ b/packages/core/graph/source-contract/src/assemble.unit.test.ts @@ -0,0 +1,88 @@ +import { validate } from "@exadev/process-graph-schema"; +import { describe, expect, test } from "vitest"; +import { assembleSync } from "./assemble.ts"; +import type { SyncPage } from "./schema.ts"; +import { fakeOptions } from "./test-support/pages.ts"; + +const pagesOf = (scope: string): readonly SyncPage[] => + fakeOptions.scopes[scope] ?? []; + +describe("assembleSync", () => { + const assembled = assembleSync([ + ...pagesOf("fake:acme/alpha"), + ...pagesOf("fake:acme/beta"), + ]); + + test("keeps each element once, in first-seen order", () => { + expect(assembled.model.elements.map((element) => element.id)).toEqual([ + "fake:acme/alpha#1", + "fake:acme/alpha#2", + "fake:acme/alpha#3", + "fake:acme/beta#1", + ]); + expect( + assembled.model.participants.map((participant) => participant.id), + ).toEqual(["fake:acme", "fake:acme/alpha", "fake:@ada", "fake:acme/beta"]); + }); + + test("keeps every transition, including the one across repositories", () => { + expect( + assembled.model.transitions.map((transition) => transition.id), + ).toEqual([ + "fake:acme/alpha#1->fake:acme/alpha#2", + "fake:acme/alpha#2->fake:acme/beta#1", + ]); + }); + + test("keeps each distinct relation once", () => { + expect(assembled.relations).toEqual([ + { kind: "part-of", from: "fake:acme/alpha", to: "fake:acme" }, + { kind: "assigned-to", from: "fake:acme/alpha#1", to: "fake:@ada" }, + { kind: "child-of", from: "fake:acme/alpha#3", to: "fake:acme/alpha#2" }, + { kind: "part-of", from: "fake:acme/beta", to: "fake:acme" }, + ]); + }); + + test("merges the overlay, the later page winning", () => { + const later = assembleSync([ + ...pagesOf("fake:acme/alpha"), + { + participants: [], + activities: [], + transitions: [], + relations: [], + overlay: { "fake:acme/alpha#2": "done" }, + gaps: [], + }, + ]); + expect(later.overlay).toEqual({ + "fake:acme/alpha#1": "done", + "fake:acme/alpha#2": "done", + "fake:acme/alpha#3": "open", + }); + }); + + test("produces a model the schema's validation accepts", () => { + expect(validate(assembled.model)).toEqual([]); + }); + + test("keeps gaps in page order", () => { + const gap = { + target: "fake:acme/gamma", + reason: "not-found", + message: "missing", + } as const; + const withGap = assembleSync([ + { + participants: [], + activities: [], + transitions: [], + relations: [], + overlay: {}, + gaps: [gap], + }, + ...pagesOf("fake:acme/beta"), + ]); + expect(withGap.gaps).toEqual([gap]); + }); +}); diff --git a/packages/core/graph/source-contract/src/conformance.ts b/packages/core/graph/source-contract/src/conformance.ts new file mode 100644 index 0000000..ae8e42b --- /dev/null +++ b/packages/core/graph/source-contract/src/conformance.ts @@ -0,0 +1,266 @@ +import { validate, validateOverlay } from "@exadev/process-graph-schema"; +import { afterAll, describe, expect, test } from "vitest"; +import { assembleSync } from "./assemble.ts"; +import type { GraphSourceClient } from "./contract.ts"; +import { paginate } from "./paginate.ts"; +import type { SyncPage } from "./schema.ts"; +import { createORPCClient } from "@orpc/client"; +import { RPCLink } from "@orpc/client/message-port"; +import { createRouterClient } from "@orpc/server"; +import { RPCHandler } from "@orpc/server/message-port"; +import type { GraphSourceRouter } from "./router.ts"; + +/** What the conformance kit needs from an adapter's test. */ +export interface GraphSourceConformanceOptions { + /** A name for the `describe` block, such as the adapter and transport under test. */ + readonly name: string; + /** Makes a client for the source under test. Called once per test, so any transport can be used, in process or over a link. */ + readonly createClient: () => GraphSourceClient; + /** A scope that exists and spans at least two pages, so resumption is exercised between pages. */ + readonly scope: readonly string[]; + /** A well-formed scope identifier naming nothing, which must arrive as a `not-found` gap. */ + readonly missingScope: string; + /** A string the source cannot parse as a scope identifier, which must be rejected. */ + readonly malformedScope: string; +} + +/** A string no source issues as a cursor: it is the kit's own, outside any source's identifier syntax. */ +const foreignCursor = "conformance-kit:not-a-cursor"; + +/** Fails when a page's `next` repeats an earlier one: the sync would never end. */ +const recordCursor = (seen: Set, next: string | undefined): void => { + if (next === undefined) { + return; + } + if (seen.has(next)) { + throw new Error(`cursor "${next}" repeats, so the sync never ends`); + } + seen.add(next); +}; + +/** Reads every page by following `next` through `sync.page`. */ +const walkPages = async ( + client: GraphSourceClient, + scope: readonly string[], +): Promise => { + const pages: SyncPage[] = []; + const seen = new Set(); + const paged = paginate(undefined, async (after) => + client.v1.sync.page(after === undefined ? { scope } : { scope, after }), + ); + for await (const page of paged) { + pages.push(page); + recordCursor(seen, page.next); + } + + return pages; +}; + +/** How much of a stream to read, and with which signal. */ +interface StreamOptions { + /** Stop after this many events, closing the stream early. Absent, the stream is read to its end. */ + readonly limit?: number; + readonly signal?: AbortSignal; +} + +/** Reads `sync.stream` to its end, or only its first `limit` events, closing the stream early in that case. */ +const streamPages = async ( + client: GraphSourceClient, + input: { readonly scope: readonly string[]; readonly after?: string }, + { limit, signal }: StreamOptions = {}, +): Promise => { + const pages: SyncPage[] = []; + const iterator = await client.v1.sync.stream( + input, + signal === undefined ? {} : { signal }, + ); + const seen = new Set(); + for await (const page of iterator) { + pages.push(page); + recordCursor(seen, page.next); + if (pages.length === limit) { + break; + } + } + + return pages; +}; + +/** The cursor to resume after `page`, failing the test when the page is the last. */ +const nextOf = (page: SyncPage | undefined): string => { + if (page?.next === undefined) { + throw new Error("expected a page that is not the last"); + } + + return page.next; +}; + +const identifiersOf = (pages: readonly SyncPage[]): readonly string[] => + pages.flatMap((page) => [ + ...page.participants.map((participant) => participant.id), + ...page.activities.map((activity) => activity.id), + ...page.transitions.map((transition) => transition.id), + ]); + +/** + * The behaviour every `GraphSource` must have, as Vitest tests run against a client. An adapter's own test calls this once per transport it supports; because the tests talk only to the oRPC client, the same assertions hold in process, over a message port or over HTTP. + * + * The tests check that paging and streaming return the same pages; that a stream closed after any page resumes from that page's `next` with exactly the pages that follow; that a sync is deterministic; that the assembled model and overlay are valid and every identifier carries the source's prefix; that relations name what the sync returned; that an unknown scope is a gap beside the rest; that malformed scopes and foreign cursors are typed `BAD_REQUEST` errors; and that an aborted signal ends both procedures. + * @param options - The client factory and the scopes to exercise. + */ +export const graphSourceConformance = ( + options: GraphSourceConformanceOptions, +): void => { + const { name, createClient, scope, missingScope, malformedScope } = options; + + describe(`GraphSource conformance: ${name}`, () => { + test("the scope spans at least two pages, so resumption is exercised", async () => { + const pages = await walkPages(createClient(), scope); + expect(pages.length).toBeGreaterThanOrEqual(2); + }); + + test("every page but the last carries next, and the last does not", async () => { + const pages = await walkPages(createClient(), scope); + expect(pages.map((page) => page.next === undefined)).toEqual( + pages.map((_, index) => index === pages.length - 1), + ); + }); + + test("the stream yields exactly the pages paging returns", async () => { + const client = createClient(); + const paged = await walkPages(client, scope); + await expect(streamPages(client, { scope })).resolves.toEqual(paged); + }); + + test("a stream closed after any page resumes from its next with exactly the pages that follow", async () => { + const client = createClient(); + const all = await streamPages(client, { scope }); + // Each interruption point is independent of the others, so they are checked together. + const interruptionPoints = all.slice(1).map((_, index) => index + 1); + await Promise.all( + interruptionPoints.map(async (taken) => { + const interrupted = await streamPages( + client, + { scope }, + { limit: taken }, + ); + expect(interrupted).toEqual(all.slice(0, taken)); + const resumeFrom = nextOf(interrupted.at(-1)); + await expect( + streamPages(client, { scope, after: resumeFrom }), + ).resolves.toEqual(all.slice(taken)); + await expect( + client.v1.sync.page({ scope, after: resumeFrom }), + ).resolves.toEqual(all[taken]); + }), + ); + }); + + test("two syncs of the same scope return the same pages", async () => { + const client = createClient(); + const first = await walkPages(client, scope); + await expect(walkPages(client, scope)).resolves.toEqual(first); + }); + + test("every identifier carries the source prefix, and the assembled model and overlay are valid", async () => { + const client = createClient(); + const { source } = await client.v1.describe(); + const pages = await walkPages(client, scope); + for (const id of identifiersOf(pages)) { + expect(id.startsWith(`${source}:`)).toBe(true); + } + const { model, overlay, relations } = assembleSync(pages); + expect(model.elements.length).toBeGreaterThan(0); + expect(validate(model)).toEqual([]); + expect(validateOverlay(model, overlay)).toEqual([]); + const known = new Set([ + ...model.participants.map((participant) => participant.id), + ...model.elements.map((element) => element.id), + ]); + for (const relation of relations) { + expect(known).toContain(relation.from); + expect(known).toContain(relation.to); + } + }); + + test("an unknown scope is a not-found gap, and the rest of the sync still arrives", async () => { + const client = createClient(); + const expected = assembleSync(await walkPages(client, scope)); + const withMissing = assembleSync( + await walkPages(client, [missingScope, ...scope]), + ); + expect(withMissing.gaps).toContainEqual( + expect.objectContaining({ target: missingScope, reason: "not-found" }), + ); + expect(withMissing.model).toEqual(expected.model); + expect(withMissing.overlay).toEqual(expected.overlay); + }); + + test("a malformed scope identifier is a BAD_REQUEST with reason invalid-scope", async () => { + const client = createClient(); + const rejection = { + code: "BAD_REQUEST", + data: { reason: "invalid-scope", value: malformedScope }, + }; + await expect( + client.v1.sync.page({ scope: [malformedScope] }), + ).rejects.toMatchObject(rejection); + await expect( + streamPages(client, { scope: [malformedScope] }), + ).rejects.toMatchObject(rejection); + }); + + test("a cursor the source did not issue is a BAD_REQUEST with reason invalid-cursor", async () => { + const client = createClient(); + const rejection = { + code: "BAD_REQUEST", + data: { reason: "invalid-cursor", value: foreignCursor }, + }; + await expect( + client.v1.sync.page({ scope, after: foreignCursor }), + ).rejects.toMatchObject(rejection); + await expect( + streamPages(client, { scope, after: foreignCursor }), + ).rejects.toMatchObject(rejection); + }); + + test("an aborted signal ends both procedures", async () => { + const client = createClient(); + const controller = new AbortController(); + controller.abort(); + await expect( + client.v1.sync.page({ scope }, { signal: controller.signal }), + ).rejects.toThrow(); + await expect( + streamPages(client, { scope }, { signal: controller.signal }), + ).rejects.toThrow(); + }); + }); +}; + +/** + * A client that calls the router in the same process, with no serialisation. + * @param router - The router to call. + * @returns The client. + */ +export const inProcessClient = (router: GraphSourceRouter): GraphSourceClient => + createRouterClient(router); + +/** + * A client that reaches the router across a `MessageChannel`, serialising every call and event as a worker transport does. Each client gets its own channel, closed after the calling test file's tests, so call it from within a test file. + * @param router - The router to serve on the channel. + * @returns The client. + */ +export const messagePortClient = ( + router: GraphSourceRouter, +): GraphSourceClient => { + const { port1, port2 } = new MessageChannel(); + new RPCHandler(router).upgrade(port1); + port2.start(); + afterAll(() => { + port1.close(); + port2.close(); + }); + + return createORPCClient(new RPCLink({ port: port2 })); +}; diff --git a/packages/core/graph/source-contract/src/contract.ts b/packages/core/graph/source-contract/src/contract.ts new file mode 100644 index 0000000..027cd04 --- /dev/null +++ b/packages/core/graph/source-contract/src/contract.ts @@ -0,0 +1,99 @@ +import { + type ContractRouterClient, + eventIterator, + type InitialInputSchema, + oc, + type ProcedureContract, + type RouterContract, +} from "@orpc/contract"; +import { z } from "zod"; +import { + badRequestReasons, + sourceDescriptionSchema, + syncInputSchema, + syncPageSchema, + type SyncPage, + type SyncPageInput, +} from "./schema.ts"; + +/** + * The typed errors every procedure can end with, each carrying its detail as data. The codes are oRPC's common codes, so an HTTP transport answers with the matching status (401, 429, 400) without any per-transport mapping. + */ +const graphSourceErrors = { + UNAUTHORIZED: { + message: "The source rejected the credentials.", + data: z.strictObject({}), + }, + TOO_MANY_REQUESTS: { + message: "The source is rate limiting requests.", + data: z.strictObject({ resetAt: z.iso.datetime().optional() }), + }, + BAD_REQUEST: { + message: "The cursor or a scope identifier is not valid for this source.", + data: z.strictObject({ + reason: z.enum(badRequestReasons), + value: z.string(), + }), + }, +}; + +type GraphSourceErrors = typeof graphSourceErrors; + +/** The event iterator of pages `sync.stream` returns, typed by name so declarations stay small. */ +const syncPageEvents: ReturnType< + typeof eventIterator +> = eventIterator(syncPageSchema); + +/** + * The shape of version 1, stated so the published declarations name the page and input schemas rather than expanding them into every procedure. + */ +interface GraphSourceV1Contract { + /** Present so the contract is assignable to oRPC's `RouterContract`, which is an index-signature type. */ + readonly [key: string]: RouterContract; + readonly describe: ProcedureContract< + InitialInputSchema, + typeof sourceDescriptionSchema, + GraphSourceErrors + >; + readonly sync: { + readonly page: ProcedureContract< + typeof syncInputSchema, + typeof syncPageSchema, + GraphSourceErrors + >; + readonly stream: ProcedureContract< + typeof syncInputSchema, + typeof syncPageEvents, + GraphSourceErrors + >; + }; +} + +/** + * Version 1 of the `GraphSource` port. A breaking change adds a sibling `v2` beside it, so one server can serve both while clients move across. + * + * - `describe` names the source, which is the prefix of every identifier it emits. + * - `sync.page` returns one page of the scope, starting after `after` when given. + * - `sync.stream` yields every page from `after` onwards (or from the start) as an event iterator, each carrying the `next` cursor to resume after it. Pages are produced on demand as the consumer pulls, so a slow consumer applies backpressure. Over an event-stream transport each event's id is its `next` cursor, so a reconnecting client's `lastEventId` resumes there too; an explicit `after` takes precedence. + * + * Every call takes the caller's abort signal through the oRPC client options, and every implementation passes it on to its source. + */ +const graphSourceV1Contract: GraphSourceV1Contract = oc + .errors(graphSourceErrors) + .router({ + describe: oc.output(sourceDescriptionSchema), + sync: { + page: oc.input(syncInputSchema).output(syncPageSchema), + stream: oc.input(syncInputSchema).output(syncPageEvents), + }, + }); + +/** The `GraphSource` port: an oRPC router contract, keyed by version. */ +export const graphSourceContract: { + readonly v1: GraphSourceV1Contract; +} = { v1: graphSourceV1Contract }; + +/** A client for the `GraphSource` port over any transport: an in-process router client or one built on an oRPC link. */ +export type GraphSourceClient = ContractRouterClient< + typeof graphSourceContract +>; diff --git a/packages/core/graph/source-contract/src/errors.ts b/packages/core/graph/source-contract/src/errors.ts new file mode 100644 index 0000000..2a98410 --- /dev/null +++ b/packages/core/graph/source-contract/src/errors.ts @@ -0,0 +1,40 @@ +import type { BadRequestReason } from "./schema.ts"; + +/** + * The failures that end a sync call, as a closed union a caller can switch on exhaustively. What a source can still partly read is not a failure: it arrives as a `Gap` in the page. + * + * - `unauthorised`: the source rejected the credentials outright. + * - `rate-limited`: the source refused the request until `resetAt` (an ISO 8601 instant) when it said so. Resume from the last page's `next` after that. + * - `bad-request`: the cursor or a scope identifier is not one this source issued or understands. + */ +export type GraphSourceFailure = + | { readonly kind: "unauthorised"; readonly message: string } + | { + readonly kind: "rate-limited"; + readonly message: string; + readonly resetAt: string | undefined; + } + | { + readonly kind: "bad-request"; + readonly message: string; + readonly reason: BadRequestReason; + readonly value: string; + }; + +/** + * Thrown by a `GraphSourcePager` to end a call with a typed failure. `createGraphSourceRouter` turns it into the contract's matching oRPC error, so it reaches a client over any transport as `UNAUTHORIZED`, `TOO_MANY_REQUESTS` or `BAD_REQUEST` with its data. + */ +export class GraphSourceError extends Error { + /** The failure, with the detail a caller branches on. */ + readonly failure: GraphSourceFailure; + + /** + * @param failure - The failure this error carries; its `message` becomes the error's message. + * @param options - Standard error options: `cause` is the underlying error, when there is one. + */ + constructor(failure: GraphSourceFailure, options?: ErrorOptions) { + super(failure.message, options); + this.name = "GraphSourceError"; + this.failure = failure; + } +} diff --git a/packages/core/graph/source-contract/src/fake.ts b/packages/core/graph/source-contract/src/fake.ts new file mode 100644 index 0000000..a22cf8b --- /dev/null +++ b/packages/core/graph/source-contract/src/fake.ts @@ -0,0 +1,142 @@ +import { GraphSourceError } from "./errors.ts"; +import type { GraphSourcePager, PageRequest } from "./router.ts"; +import type { SyncPage } from "./schema.ts"; + +/** A page's content without its cursor, which the fake derives. */ +export type FakePage = Omit; + +/** What the in-memory fake serves. */ +export interface FakeGraphSourceOptions { + /** The identifier prefix, such as `fake`. Every scope identifier must start with it and a colon. */ + readonly source: string; + /** The pages of each known scope, keyed by scope identifier. A scope with no pages is known and empty. */ + readonly scopes: Readonly>; +} + +/** One position in a sync: a page of a known scope, or the single gap page of an unknown one. */ +interface Position { + readonly scope: string; + readonly index: number; +} + +const gapIndex = -1; +const cursorPattern = /^(?[^@]+)@(?-1|0|[1-9]\d*)$/u; + +const encodeCursor = (position: Position): string => + `${encodeURIComponent(position.scope)}@${String(position.index)}`; + +const samePosition = (a: Position, b: Position): boolean => + a.scope === b.scope && a.index === b.index; + +/** + * The reference in-memory `GraphSourcePager`: a fixed set of pages per scope, served in scope order. A scope that is well formed but unknown yields one page holding a `not-found` gap, so a sync over several scopes still returns the rest. Cursors encode the scope identifier and page index, so they are stable and resume in any instance built from the same pages. + * @param options - The source prefix and the pages of each scope. + * @returns A pager for `createGraphSourceRouter`. + */ +export const createFakeGraphSource = ( + options: FakeGraphSourceOptions, +): GraphSourcePager => { + const { source, scopes } = options; + const prefix = `${source}:`; + + const positionsOf = (scope: readonly string[]): readonly Position[] => + [...new Set(scope)].flatMap((id) => { + if (!id.startsWith(prefix) || id.length === prefix.length) { + throw new GraphSourceError({ + kind: "bad-request", + message: `"${id}" is not a ${source} scope identifier`, + reason: "invalid-scope", + value: id, + }); + } + const pages = scopes[id]; + if (pages === undefined) { + return [{ scope: id, index: gapIndex }]; + } + + // A known scope with no pages still answers with one empty page, so every scope occupies at least one position. + return pages.length === 0 + ? [{ scope: id, index: 0 }] + : pages.map((_, index) => ({ scope: id, index })); + }); + + const startOf = ( + positions: readonly Position[], + cursor: string | undefined, + ): number => { + if (cursor === undefined) { + return 0; + } + const groups = cursorPattern.exec(cursor)?.groups; + const after = + groups?.scope === undefined || groups.index === undefined + ? undefined + : { + scope: decodeURIComponent(groups.scope), + index: Number(groups.index), + }; + const found = + after === undefined + ? -1 + : positions.findIndex((position) => samePosition(position, after)); + // The last position's page has no `next`, so a cursor naming it was not issued by this source. + if (found === -1 || found === positions.length - 1) { + throw new GraphSourceError({ + kind: "bad-request", + message: `"${cursor}" is not a cursor for this scope`, + reason: "invalid-cursor", + value: cursor, + }); + } + + return found + 1; + }; + + const pageAt = (position: Position): FakePage => { + const page = scopes[position.scope]?.[position.index]; + if (page !== undefined) { + return page; + } + + return { + participants: [], + activities: [], + transitions: [], + relations: [], + overlay: {}, + gaps: + position.index === gapIndex + ? [ + { + target: position.scope, + reason: "not-found", + message: `${position.scope} does not exist`, + }, + ] + : [], + }; + }; + + const read = ({ scope, cursor, signal }: PageRequest): SyncPage => { + signal?.throwIfAborted(); + const positions = positionsOf(scope); + const start = startOf(positions, cursor); + const position = positions[start]; + if (position === undefined) { + throw new Error( + "unreachable: startOf returns an index within the positions", + ); + } + const page = pageAt(position); + + return start === positions.length - 1 + ? page + : { ...page, next: encodeCursor(position) }; + }; + + return { + source, + // Async, so a throw from `read` becomes a rejection, as it would from a source that reads over a network. + page: async (request) => Promise.resolve(read(request)), + }; +}; diff --git a/packages/core/graph/source-contract/src/fake.unit.test.ts b/packages/core/graph/source-contract/src/fake.unit.test.ts new file mode 100644 index 0000000..ad44527 --- /dev/null +++ b/packages/core/graph/source-contract/src/fake.unit.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "vitest"; +import { graphSourceConformance } from "./conformance.ts"; +import { createFakeGraphSource } from "./fake.ts"; +import { createGraphSourceRouter } from "./router.ts"; +import { fakeOptions } from "./test-support/pages.ts"; +import { inProcessClient, messagePortClient } from "./conformance.ts"; + +const router = createGraphSourceRouter(createFakeGraphSource(fakeOptions)); +const scope = ["fake:acme/alpha", "fake:acme/beta"]; + +graphSourceConformance({ + name: "in-memory fake, in process", + createClient: () => inProcessClient(router), + scope, + missingScope: "fake:acme/gamma", + malformedScope: "other:acme/alpha", +}); + +graphSourceConformance({ + name: "in-memory fake, over a message port", + createClient: () => messagePortClient(router), + scope, + missingScope: "fake:acme/gamma", + malformedScope: "fake:", +}); + +describe("createFakeGraphSource", () => { + test("serves the scopes in the order requested, one page per call", async () => { + const client = inProcessClient(router); + const first = await client.v1.sync.page({ + scope: ["fake:acme/beta", "fake:acme/alpha"], + }); + expect(first.activities.map((activity) => activity.id)).toEqual([ + "fake:acme/beta#1", + "fake:acme/alpha#2", + ]); + expect(first.next).toBe("fake%3Aacme%2Fbeta@0"); + }); + + test("a repeated scope identifier is read once", async () => { + const client = inProcessClient(router); + const only = await client.v1.sync.page({ + scope: ["fake:acme/beta", "fake:acme/beta"], + }); + expect(only.next).toBeUndefined(); + }); + + test("a known scope with no pages answers with one empty page", async () => { + const client = inProcessClient( + createGraphSourceRouter( + createFakeGraphSource({ source: "fake", scopes: { "fake:empty": [] } }), + ), + ); + await expect( + client.v1.sync.page({ scope: ["fake:empty"] }), + ).resolves.toEqual({ + participants: [], + activities: [], + transitions: [], + relations: [], + overlay: {}, + gaps: [], + }); + }); + + test("the last page's position is not a cursor it accepts", async () => { + const client = inProcessClient(router); + await expect( + client.v1.sync.page({ + scope: ["fake:acme/beta"], + after: "fake%3Aacme%2Fbeta@0", + }), + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + data: { reason: "invalid-cursor", value: "fake%3Aacme%2Fbeta@0" }, + }); + }); + + test("describe names the source", async () => { + await expect(inProcessClient(router).v1.describe()).resolves.toEqual({ + source: "fake", + }); + }); +}); diff --git a/packages/core/graph/source-contract/src/index.ts b/packages/core/graph/source-contract/src/index.ts new file mode 100644 index 0000000..2d26846 --- /dev/null +++ b/packages/core/graph/source-contract/src/index.ts @@ -0,0 +1,44 @@ +/** + * The `GraphSource` port: how the process model reads work from an external tracker. It is an oRPC router contract, so one definition is served in process, across a message port to a worker, or over HTTP with no change to its consumers. Adapters implement a `GraphSourcePager`, one page at a time; `createGraphSourceRouter` builds the port on it. + * + * The conformance kit every adapter must pass is the separate `./conformance` entry point, so the runtime entry carries no test framework. + */ +export { graphSourceContract, type GraphSourceClient } from "./contract.ts"; +export { + badRequestReasons, + boundActivitySchema, + gapReasons, + gapSchema, + participantSchema, + relationKinds, + relationSchema, + sourceDescriptionSchema, + syncInputSchema, + syncPageSchema, + transitionSchema, + type BadRequestReason, + type BoundActivity, + type Gap, + type GapReason, + type Relation, + type RelationKind, + type SourceDescription, + type SourceParticipant, + type SourceTransition, + type SyncInput, + type SyncPage, +} from "./schema.ts"; +export { GraphSourceError, type GraphSourceFailure } from "./errors.ts"; +export { + createGraphSourceRouter, + type GraphSourcePager, + type GraphSourceRouter, + type PageRequest, +} from "./router.ts"; +export { assembleSync, type AssembledSync } from "./assemble.ts"; +export { paginate } from "./paginate.ts"; +export { + createFakeGraphSource, + type FakeGraphSourceOptions, + type FakePage, +} from "./fake.ts"; diff --git a/packages/core/graph/source-contract/src/paginate.ts b/packages/core/graph/source-contract/src/paginate.ts new file mode 100644 index 0000000..dacb239 --- /dev/null +++ b/packages/core/graph/source-contract/src/paginate.ts @@ -0,0 +1,31 @@ +/** + * Pages through a cursor-linked sequence on demand: each `next()` reads one page, starting after `first`, and the sequence ends after the page whose `next` is absent. Nothing is read ahead of the consumer, so a consumer that stops early stops the reads. + * @param first - The cursor to start after, or `undefined` for the first page. + * @param read - Reads the page after a cursor. + * @returns The pages, in order. + */ +export const paginate = ( + first: string | undefined, + read: (after: string | undefined) => Promise, +): AsyncIterable => ({ + [Symbol.asyncIterator]: () => { + let after = first; + let finished = false; + + return { + // Consumers pull one page at a time, as `for await` does, so each read starts from the cursor the previous page left. + next: async (): Promise> => { + if (finished) { + return { done: true, value: undefined }; + } + + return read(after).then((page) => { + after = page.next; + finished = after === undefined; + + return { done: false, value: page }; + }); + }, + }; + }, +}); diff --git a/packages/core/graph/source-contract/src/paginate.unit.test.ts b/packages/core/graph/source-contract/src/paginate.unit.test.ts new file mode 100644 index 0000000..7e3d3c2 --- /dev/null +++ b/packages/core/graph/source-contract/src/paginate.unit.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "vitest"; +import { paginate } from "./paginate.ts"; + +interface Page { + readonly value: string; + readonly next?: string; +} + +const pages: Readonly> = { + start: { value: "a", next: "b" }, + b: { value: "b", next: "c" }, + c: { value: "c" }, +}; + +/** A reader over `pages` that records the key of every page it reads. */ +const recordingReader = () => { + const reads: string[] = []; + const read = async (after: string | undefined): Promise => { + const key = after ?? "start"; + reads.push(key); + const page = pages[key]; + if (page === undefined) { + throw new Error(`no page after ${key}`); + } + + return Promise.resolve(page); + }; + + return { reads, read }; +}; + +const valuesOf = async ( + iterable: Readonly>, +): Promise => { + const values: string[] = []; + for await (const page of iterable) { + values.push(page.value); + } + + return values; +}; + +describe("paginate", () => { + test("reads from the first cursor to the page without next", async () => { + const { reads, read } = recordingReader(); + await expect(valuesOf(paginate(undefined, read))).resolves.toEqual([ + "a", + "b", + "c", + ]); + expect(reads).toEqual(["start", "b", "c"]); + }); + + test("starts after the given cursor", async () => { + const { read } = recordingReader(); + await expect(valuesOf(paginate("b", read))).resolves.toEqual(["b", "c"]); + }); + + test("reads nothing ahead of the consumer", async () => { + const { reads, read } = recordingReader(); + const iterator = paginate(undefined, read)[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: pages.start, + }); + expect(reads).toEqual(["start"]); + }); +}); diff --git a/packages/core/graph/source-contract/src/resume.unit.test.ts b/packages/core/graph/source-contract/src/resume.unit.test.ts new file mode 100644 index 0000000..110e439 --- /dev/null +++ b/packages/core/graph/source-contract/src/resume.unit.test.ts @@ -0,0 +1,109 @@ +import { getEventMeta } from "@orpc/client"; +import { describe, expect, test } from "vitest"; +import type { GraphSourceClient } from "./contract.ts"; +import { createFakeGraphSource } from "./fake.ts"; +import { createGraphSourceRouter } from "./router.ts"; +import type { SyncPage } from "./schema.ts"; +import { fakeOptions } from "./test-support/pages.ts"; +import { inProcessClient, messagePortClient } from "./conformance.ts"; + +const router = createGraphSourceRouter(createFakeGraphSource(fakeOptions)); +const scope = ["fake:acme/alpha", "fake:acme/beta"]; + +const activityIds = ( + pages: readonly SyncPage[], +): readonly (readonly string[])[] => + pages.map((page) => page.activities.map((activity) => activity.id)); + +/** Reads the first page of a stream, then closes it, returning the page and the event id the transport delivered with it. */ +const interruptAfterFirst = async ( + client: GraphSourceClient, +): Promise<{ + readonly page: SyncPage; + readonly eventId: string | undefined; +}> => { + const iterator = await client.v1.sync.stream({ scope }); + const first = await iterator.next(); + await iterator.return(); + if (first.done === true) { + throw new Error("expected the stream to yield a page"); + } + + return { page: first.value, eventId: getEventMeta(first.value)?.id }; +}; + +const drain = async ( + iterator: Readonly>, +): Promise => { + const pages: SyncPage[] = []; + for await (const page of iterator) { + pages.push(page); + } + + return pages; +}; + +describe.each([ + ["in process", () => inProcessClient(router)], + ["over a message port", () => messagePortClient(router)], +])("a streamed sync interrupted mid-way, %s", (_, createClient) => { + test("carries its next cursor as the event id", async () => { + const { page, eventId } = await interruptAfterFirst(createClient()); + expect(page.next).toBe("fake%3Aacme%2Falpha@0"); + expect(eventId).toBe(page.next); + }); + + test("resumes by the cursor in its input with exactly the remaining pages", async () => { + const client = createClient(); + const { page } = await interruptAfterFirst(client); + expect(activityIds([page])).toEqual([ + ["fake:acme/alpha#1", "fake:acme/alpha#2"], + ]); + const rest = await drain( + await client.v1.sync.stream({ scope, after: page.next ?? "" }), + ); + expect(activityIds(rest)).toEqual([ + ["fake:acme/alpha#3"], + ["fake:acme/beta#1", "fake:acme/alpha#2"], + ]); + expect(rest.map((resumed) => resumed.next)).toEqual([ + "fake%3Aacme%2Falpha@1", + undefined, + ]); + expect(rest[1]?.transitions).toEqual([ + { + id: "fake:acme/alpha#2->fake:acme/beta#1", + source: "fake:acme/alpha#2", + target: "fake:acme/beta#1", + }, + ]); + }); + + test("resumes by the transport's last event id when the input has no cursor", async () => { + const client = createClient(); + const { eventId } = await interruptAfterFirst(client); + const rest = await drain( + await client.v1.sync.stream( + { scope }, + eventId === undefined ? {} : { lastEventId: eventId }, + ), + ); + expect(activityIds(rest)).toEqual([ + ["fake:acme/alpha#3"], + ["fake:acme/beta#1", "fake:acme/alpha#2"], + ]); + }); + + test("an explicit cursor in the input wins over the last event id", async () => { + const client = createClient(); + const rest = await drain( + await client.v1.sync.stream( + { scope, after: "fake%3Aacme%2Falpha@1" }, + { lastEventId: "fake%3Aacme%2Falpha@0" }, + ), + ); + expect(activityIds(rest)).toEqual([ + ["fake:acme/beta#1", "fake:acme/alpha#2"], + ]); + }); +}); diff --git a/packages/core/graph/source-contract/src/router.ts b/packages/core/graph/source-contract/src/router.ts new file mode 100644 index 0000000..88f3d18 --- /dev/null +++ b/packages/core/graph/source-contract/src/router.ts @@ -0,0 +1,133 @@ +import { withEventMeta } from "@orpc/client"; +import type { ORPCErrorConstructorMap } from "@orpc/contract"; +import { + type ContractedRouter, + type DefaultInitialContext, + implement, +} from "@orpc/server"; +import { graphSourceContract } from "./contract.ts"; +import { GraphSourceError } from "./errors.ts"; +import { paginate } from "./paginate.ts"; +import type { SyncInput, SyncPage } from "./schema.ts"; + +/** One request for a page, as a pager receives it. */ +export interface PageRequest { + /** The scope identifiers to read, in order. */ + readonly scope: SyncInput["scope"]; + /** Where to start: the `next` of the page before, or `undefined` for the first page. */ + readonly cursor: string | undefined; + /** The caller's abort signal. A pager passes it to every request it makes and stops when it fires. */ + readonly signal: AbortSignal | undefined; +} + +/** + * What an adapter implements: one page at a time. `createGraphSourceRouter` builds the whole port on it, so paging, streaming, resumption and error mapping behave identically for every source. + * + * A pager's cursors must be stable: the same scope and cursor return the same page while the source is unchanged, and a cursor never names a position relative to state held in memory, so a sync resumes in a different process. A cursor the pager did not issue, or one from a different scope list, throws `GraphSourceError` with `bad-request` and `invalid-cursor`; a scope identifier it cannot parse throws `bad-request` and `invalid-scope`. + */ +export interface GraphSourcePager { + /** The identifier prefix this source emits, such as `github`. */ + readonly source: string; + /** + * Reads one page. + * @param request - The scope, the cursor to start after, and the abort signal. + * @returns The page, with `next` absent when it is the last. + * @throws GraphSourceError for a failure that ends the call. + */ + readonly page: (request: PageRequest) => Promise; +} + +const os = implement(graphSourceContract); + +/** The implemented `GraphSource` router: serve it over any oRPC transport, or call it in process with `createRouterClient`. */ +export type GraphSourceRouter = ContractedRouter< + typeof graphSourceContract, + DefaultInitialContext +>; + +type V1Errors = ORPCErrorConstructorMap< + (typeof graphSourceContract.v1.describe)["~orpc"]["errorMap"] +>; + +/** Rethrows a pager's `GraphSourceError` as the contract's typed oRPC error; anything else propagates unchanged. */ +const toContractError = ( + error: unknown, + errors: Readonly, +): unknown => { + if (!(error instanceof GraphSourceError)) { + return error; + } + const { failure } = error; + if (failure.kind === "unauthorised") { + return errors.UNAUTHORIZED({ + message: failure.message, + data: {}, + cause: error, + }); + } + if (failure.kind === "rate-limited") { + return errors.TOO_MANY_REQUESTS({ + message: failure.message, + data: failure.resetAt === undefined ? {} : { resetAt: failure.resetAt }, + cause: error, + }); + } + + return errors.BAD_REQUEST({ + message: failure.message, + data: { reason: failure.reason, value: failure.value }, + cause: error, + }); +}; + +/** Reads one page from the pager, as the contract's typed error when it fails. */ +const readPage = async ( + pager: GraphSourcePager, + request: Readonly, + errors: Readonly, +): Promise => { + request.signal?.throwIfAborted(); + try { + return await pager.page(request); + } catch (error) { + throw toContractError(error, errors); + } +}; + +/** + * Builds the `GraphSource` router over a pager. Serve it over any oRPC transport, or call it in process with `createRouterClient`. + * @param pager - The adapter's page reader. + * @returns The implemented router for `graphSourceContract`. + */ +export const createGraphSourceRouter = ( + pager: GraphSourcePager, +): GraphSourceRouter => + os.router({ + v1: { + describe: os.v1.describe.handler(() => ({ source: pager.source })), + sync: { + page: os.v1.sync.page.handler(async ({ input, signal, errors }) => + readPage( + pager, + { scope: input.scope, cursor: input.after, signal }, + errors, + ), + ), + stream: os.v1.sync.stream.handler(async function* ({ + input, + signal, + lastEventId, + errors, + }) { + const pages = paginate(input.after ?? lastEventId, async (cursor) => + readPage(pager, { scope: input.scope, cursor, signal }, errors), + ); + for await (const page of pages) { + yield page.next === undefined + ? page + : withEventMeta(page, { id: page.next }); + } + }), + }, + }, + }); diff --git a/packages/core/graph/source-contract/src/router.unit.test.ts b/packages/core/graph/source-contract/src/router.unit.test.ts new file mode 100644 index 0000000..c7daf4f --- /dev/null +++ b/packages/core/graph/source-contract/src/router.unit.test.ts @@ -0,0 +1,133 @@ +import { isDefinedError } from "@orpc/client"; +import { describe, expect, test } from "vitest"; +import { GraphSourceError, type GraphSourceFailure } from "./errors.ts"; +import { createFakeGraphSource } from "./fake.ts"; +import { + createGraphSourceRouter, + type GraphSourcePager, + type PageRequest, +} from "./router.ts"; +import { fakeOptions } from "./test-support/pages.ts"; +import { inProcessClient, messagePortClient } from "./conformance.ts"; + +const scope = ["fake:acme/alpha", "fake:acme/beta"]; +const fake = createFakeGraphSource(fakeOptions); + +/** A pager that serves the fake's first page, then fails every later call with `failure`. */ +const failingAfterFirstPage = ( + failure: GraphSourceFailure, +): GraphSourcePager => ({ + source: "fake", + page: async (request) => { + if (request.cursor !== undefined) { + throw new GraphSourceError(failure); + } + + return fake.page(request); + }, +}); + +const failures: readonly (readonly [GraphSourceFailure, object])[] = [ + [ + { kind: "unauthorised", message: "bad credentials" }, + { code: "UNAUTHORIZED", message: "bad credentials", data: {} }, + ], + [ + { + kind: "rate-limited", + message: "slow down", + resetAt: "2026-10-10T12:00:00.000Z", + }, + { + code: "TOO_MANY_REQUESTS", + message: "slow down", + data: { resetAt: "2026-10-10T12:00:00.000Z" }, + }, + ], + [ + { kind: "rate-limited", message: "slow down", resetAt: undefined }, + { code: "TOO_MANY_REQUESTS", message: "slow down", data: {} }, + ], + [ + { kind: "bad-request", message: "no", reason: "invalid-scope", value: "x" }, + { + code: "BAD_REQUEST", + message: "no", + data: { reason: "invalid-scope", value: "x" }, + }, + ], +]; + +describe.each([ + ["in process", inProcessClient], + ["over a message port", messagePortClient], +])("createGraphSourceRouter, %s", (_, clientFor) => { + test.each(failures)( + "maps %o to its typed error on page", + async (failure, expected) => { + const client = clientFor( + createGraphSourceRouter(failingAfterFirstPage(failure)), + ); + const first = await client.v1.sync.page({ scope }); + await expect( + client.v1.sync.page({ scope, after: first.next ?? "" }), + ).rejects.toMatchObject({ + ...expected, + defined: true, + }); + }, + ); + + test.each(failures)( + "ends a stream with %o after the pages already yielded", + async (failure, expected) => { + const client = clientFor( + createGraphSourceRouter(failingAfterFirstPage(failure)), + ); + const iterator = await client.v1.sync.stream({ scope }); + const first = await iterator.next(); + expect(first.done).toBe(false); + await expect(iterator.next()).rejects.toMatchObject({ + ...expected, + defined: true, + }); + }, + ); + + test("an error that is not a GraphSourceError is not reported as a defined contract error", async () => { + const client = clientFor( + createGraphSourceRouter({ + source: "fake", + page: async () => Promise.reject(new Error("boom")), + }), + ); + await expect(client.v1.sync.page({ scope })).rejects.toSatisfy( + (error: unknown) => error instanceof Error && !isDefinedError(error), + ); + }); +}); + +describe("the abort signal", () => { + test("reaches the pager, and aborting it mid-stream ends the stream", async () => { + const seen: (AbortSignal | undefined)[] = []; + const recording: GraphSourcePager = { + source: "fake", + page: async (request: PageRequest) => { + seen.push(request.signal); + + return await fake.page(request); + }, + }; + const client = inProcessClient(createGraphSourceRouter(recording)); + const controller = new AbortController(); + const iterator = await client.v1.sync.stream( + { scope }, + { signal: controller.signal }, + ); + await iterator.next(); + controller.abort(); + await expect(iterator.next()).rejects.toThrow(); + expect(seen.length).toBeGreaterThan(0); + expect(seen.every((signal) => signal?.aborted === true)).toBe(true); + }); +}); diff --git a/packages/core/graph/source-contract/src/schema.ts b/packages/core/graph/source-contract/src/schema.ts new file mode 100644 index 0000000..c9423a7 --- /dev/null +++ b/packages/core/graph/source-contract/src/schema.ts @@ -0,0 +1,105 @@ +import { modelSchema, overlaySchema } from "@exadev/process-graph-schema"; +import { z } from "zod"; + +/** + * The model's own record schemas, taken from `modelSchema` rather than restated, so a page of a sync is exactly a slice of a model and can never drift from it. + */ +const modelShape = modelSchema.unwrap().shape; + +/** A participant as the model declares it: an organisation, a repository, a person. */ +export const participantSchema = modelShape.participants.unwrap().element; + +/** A transition as the model declares it. A source emits one per dependency, from the prerequisite to the work that waits on it. */ +export const transitionSchema = modelShape.transitions.unwrap().element; + +/** The model's `activity` element: the first variant of its element union, pinned by a type test so a reordering of the union is a compile error. */ +const [activityElementSchema] = modelShape.elements.unwrap().element.options; + +/** + * An activity bound to the external record it stands for. `source` is required here, where the model leaves it optional, and it equals `id`: the source-qualified identifier (`github:org/repo#12`) is the element's identity, so the same record synced twice, or reached from two places, is the same element. + */ +export const boundActivitySchema = activityElementSchema + .unwrap() + .extend({ source: z.string().min(1) }) + .refine((activity) => activity.id === activity.source, { + message: "a bound activity's id must equal its source", + path: ["id"], + }) + .readonly(); + +/** The relation kinds a source reports beside the model's transitions. */ +export const relationKinds = ["child-of", "assigned-to", "part-of"] as const; + +/** + * A structural link between two identifiers that the model has no field for: `child-of` from a sub-task activity to its parent activity, `assigned-to` from an activity to a person participant, `part-of` from one participant to the participant that contains it (a repository to its organisation). Relations carry no ordering, so a consumer decides whether a parent waits on its children. + */ +export const relationSchema = z + .strictObject({ + kind: z.enum(relationKinds), + from: z.string().min(1), + to: z.string().min(1), + }) + .readonly(); + +/** Why part of a requested scope is missing from a sync. */ +export const gapReasons = ["not-found", "forbidden"] as const; + +/** + * A part of the requested scope that the source could not read, reported as data so the rest of the sync still arrives. `target` is the source-qualified identifier of what is missing: a scope, a record, or one of a record's connections. + */ +export const gapSchema = z + .strictObject({ + target: z.string().min(1), + reason: z.enum(gapReasons), + message: z.string(), + }) + .readonly(); + +/** + * One page of a sync: bound activities, the participants they name, the transitions between them, their relations, their run state, and any gaps. A page is a partial view; `assembleSync` merges a whole sync into one model. + * + * `next` is the cursor to resume after this page. It is absent on the last page. + */ +export const syncPageSchema = z + .strictObject({ + participants: z.array(participantSchema).readonly(), + activities: z.array(boundActivitySchema).readonly(), + transitions: z.array(transitionSchema).readonly(), + relations: z.array(relationSchema).readonly(), + overlay: overlaySchema, + gaps: z.array(gapSchema).readonly(), + next: z.string().min(1).exactOptional(), + }) + .readonly(); + +/** + * What to sync and where to resume. `scope` names what to read as source-qualified scope identifiers (for GitHub, `github:org` for every repository an owner has, or `github:org/repo`), read in the order given. `after` is a cursor from an earlier page's `next`: the sync continues with the page that followed it. + */ +export const syncInputSchema = z + .strictObject({ + scope: z.array(z.string().min(1)).min(1).readonly(), + after: z.string().min(1).exactOptional(), + }) + .readonly(); + +/** What a source says about itself. `source` is the prefix of every identifier it emits, before the colon. */ +export const sourceDescriptionSchema = z + .strictObject({ source: z.string().regex(/^[a-z][a-z0-9-]*$/u) }) + .readonly(); + +/** Why a request was rejected as malformed. */ +export const badRequestReasons = ["invalid-cursor", "invalid-scope"] as const; + +export type BoundActivity = z.infer; +export type SourceParticipant = z.infer; +export type SourceTransition = z.infer; +export type Relation = z.infer; +export type RelationKind = (typeof relationKinds)[number]; +export type Gap = z.infer; +export type GapReason = (typeof gapReasons)[number]; +export type SyncPage = z.output; +/** A page as an implementation supplies it, before parsing. */ +export type SyncPageInput = z.input; +export type SyncInput = z.infer; +export type SourceDescription = z.infer; +export type BadRequestReason = (typeof badRequestReasons)[number]; diff --git a/packages/core/graph/source-contract/src/schema.unit.test.ts b/packages/core/graph/source-contract/src/schema.unit.test.ts new file mode 100644 index 0000000..b3fb14f --- /dev/null +++ b/packages/core/graph/source-contract/src/schema.unit.test.ts @@ -0,0 +1,83 @@ +import type { + Element, + OpenIds, + Participant, + Transition, +} from "@exadev/process-graph-schema"; +import { describe, expect, expectTypeOf, test } from "vitest"; +import { + boundActivitySchema, + type BoundActivity, + type SourceParticipant, + type SourceTransition, + syncInputSchema, + syncPageSchema, +} from "./schema.ts"; + +describe("boundActivitySchema", () => { + test("is the model's activity variant", () => { + expectTypeOf().toEqualTypeOf<"activity">(); + expectTypeOf().toExtend>(); + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toExtend>(); + }); + + test("requires the source and its equality with the identifier", () => { + const bound = { + kind: "activity", + id: "github:o/r#1", + source: "github:o/r#1", + label: "x", + }; + expect(boundActivitySchema.parse(bound)).toEqual(bound); + expect( + boundActivitySchema.safeParse({ ...bound, source: "github:o/r#2" }) + .success, + ).toBe(false); + expect( + boundActivitySchema.safeParse({ + kind: "activity", + id: "github:o/r#1", + label: "x", + }).success, + ).toBe(false); + }); + + test("rejects a field the model does not define", () => { + expect( + boundActivitySchema.safeParse({ + kind: "activity", + id: "a:1", + source: "a:1", + label: "x", + url: "u", + }).success, + ).toBe(false); + }); +}); + +describe("syncInputSchema", () => { + test("needs at least one scope identifier", () => { + expect(syncInputSchema.safeParse({ scope: [] }).success).toBe(false); + expect(syncInputSchema.safeParse({ scope: ["a:b"] }).success).toBe(true); + }); +}); + +describe("syncPageSchema", () => { + test("accepts an overlay of model statuses only", () => { + const page = { + participants: [], + activities: [], + transitions: [], + relations: [], + gaps: [], + }; + expect( + syncPageSchema.safeParse({ ...page, overlay: { "a:1": "done" } }).success, + ).toBe(true); + expect( + syncPageSchema.safeParse({ ...page, overlay: { "a:1": "closed" } }) + .success, + ).toBe(false); + }); +}); diff --git a/packages/core/graph/source-contract/src/test-support/pages.ts b/packages/core/graph/source-contract/src/test-support/pages.ts new file mode 100644 index 0000000..3d6ddb1 --- /dev/null +++ b/packages/core/graph/source-contract/src/test-support/pages.ts @@ -0,0 +1,90 @@ +import type { FakeGraphSourceOptions } from "../fake.ts"; + +const org = { id: "fake:acme", label: "acme", kind: "system" } as const; +const alpha = { + id: "fake:acme/alpha", + label: "acme/alpha", + kind: "system", +} as const; +const beta = { + id: "fake:acme/beta", + label: "acme/beta", + kind: "system", +} as const; +const ada = { id: "fake:@ada", label: "ada", kind: "role" } as const; + +const activity = ( + repo: typeof alpha | typeof beta, + number: string, + label: string, +) => { + const id = `${repo.id}#${number}`; + + return { kind: "activity", id, source: id, label, actor: repo.id } as const; +}; + +/** Two repositories of one organisation: alpha spans two pages, beta one, and beta's work waits on alpha's. */ +export const fakeOptions: FakeGraphSourceOptions = { + source: "fake", + scopes: { + "fake:acme/alpha": [ + { + participants: [org, alpha, ada], + activities: [ + activity(alpha, "1", "Design the schema"), + activity(alpha, "2", "Build the store"), + ], + transitions: [ + { + id: "fake:acme/alpha#1->fake:acme/alpha#2", + source: "fake:acme/alpha#1", + target: "fake:acme/alpha#2", + }, + ], + relations: [ + { kind: "part-of", from: alpha.id, to: org.id }, + { kind: "assigned-to", from: "fake:acme/alpha#1", to: ada.id }, + ], + overlay: { "fake:acme/alpha#1": "done", "fake:acme/alpha#2": "open" }, + gaps: [], + }, + { + participants: [org, alpha], + activities: [activity(alpha, "3", "Document the store")], + transitions: [], + relations: [ + { kind: "part-of", from: alpha.id, to: org.id }, + { + kind: "child-of", + from: "fake:acme/alpha#3", + to: "fake:acme/alpha#2", + }, + ], + overlay: { "fake:acme/alpha#3": "open" }, + gaps: [], + }, + ], + "fake:acme/beta": [ + { + participants: [org, alpha, beta], + activities: [ + activity(beta, "1", "Ship the client"), + activity(alpha, "2", "Build the store"), + ], + transitions: [ + { + id: "fake:acme/alpha#2->fake:acme/beta#1", + source: "fake:acme/alpha#2", + target: "fake:acme/beta#1", + }, + ], + relations: [ + { kind: "part-of", from: beta.id, to: org.id }, + { kind: "part-of", from: alpha.id, to: org.id }, + ], + overlay: { "fake:acme/beta#1": "open", "fake:acme/alpha#2": "open" }, + gaps: [], + }, + ], + }, +}; diff --git a/packages/core/graph/source-contract/stryker.config.ts b/packages/core/graph/source-contract/stryker.config.ts new file mode 100644 index 0000000..6429b27 --- /dev/null +++ b/packages/core/graph/source-contract/stryker.config.ts @@ -0,0 +1,3 @@ +import { packageStrykerConfig } from "../../../../stryker.shared.ts"; + +export default packageStrykerConfig(); diff --git a/packages/core/graph/source-contract/test/package.smoke.test.ts b/packages/core/graph/source-contract/test/package.smoke.test.ts new file mode 100644 index 0000000..5a5db06 --- /dev/null +++ b/packages/core/graph/source-contract/test/package.smoke.test.ts @@ -0,0 +1,74 @@ +import { createRouterClient } from "@orpc/server"; +import { expect, test } from "vitest"; +// Deliberately imports the built dist/, not src/: this is the check that catches a wrong exports map or a build misconfiguration that a unit test importing straight from src/ never touches. +import { + assembleSync, + createFakeGraphSource, + createGraphSourceRouter, +} from "../dist/index.js"; +import { graphSourceConformance } from "../dist/conformance.js"; + +const fake = createFakeGraphSource({ + source: "fake", + scopes: { + "fake:o/r": [ + { + participants: [{ id: "fake:o/r", label: "o/r", kind: "system" }], + activities: [ + { + kind: "activity", + id: "fake:o/r#1", + source: "fake:o/r#1", + label: "One", + actor: "fake:o/r", + }, + ], + transitions: [], + relations: [], + overlay: { "fake:o/r#1": "open" }, + gaps: [], + }, + { + participants: [{ id: "fake:o/r", label: "o/r", kind: "system" }], + activities: [ + { + kind: "activity", + id: "fake:o/r#2", + source: "fake:o/r#2", + label: "Two", + actor: "fake:o/r", + }, + ], + transitions: [ + { + id: "fake:o/r#1->fake:o/r#2", + source: "fake:o/r#1", + target: "fake:o/r#2", + }, + ], + relations: [], + overlay: { "fake:o/r#2": "open" }, + gaps: [], + }, + ], + }, +}); +const router = createGraphSourceRouter(fake); + +test("the built package syncs and assembles a model", async () => { + const client = createRouterClient(router); + const first = await client.v1.sync.page({ scope: ["fake:o/r"] }); + const second = await client.v1.sync.page({ + scope: ["fake:o/r"], + after: first.next ?? "", + }); + expect(assembleSync([first, second]).model.transitions).toHaveLength(1); +}); + +graphSourceConformance({ + name: "the built fake", + createClient: () => createRouterClient(router), + scope: ["fake:o/r"], + missingScope: "fake:o/missing", + malformedScope: "o/r", +}); diff --git a/packages/core/graph/source-contract/tsconfig.build.json b/packages/core/graph/source-contract/tsconfig.build.json new file mode 100644 index 0000000..79dba2e --- /dev/null +++ b/packages/core/graph/source-contract/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "declarationMap": false, + "rootDir": "src", + "outDir": "dist", + "rewriteRelativeImportExtensions": true + }, + "references": [ + { + "path": "../schema/tsconfig.build.json" + } + ] +} diff --git a/packages/core/graph/source-contract/tsconfig.json b/packages/core/graph/source-contract/tsconfig.json new file mode 100644 index 0000000..8895dd1 --- /dev/null +++ b/packages/core/graph/source-contract/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024", "WebWorker"], + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/test-support/**"] +} diff --git a/packages/core/graph/source-contract/tsconfig.node.json b/packages/core/graph/source-contract/tsconfig.node.json new file mode 100644 index 0000000..8a460f4 --- /dev/null +++ b/packages/core/graph/source-contract/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024"], + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", "*.config.ts"] +} diff --git a/packages/core/graph/source-contract/vitest.config.ts b/packages/core/graph/source-contract/vitest.config.ts new file mode 100644 index 0000000..01b9391 --- /dev/null +++ b/packages/core/graph/source-contract/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + projects: [ + { + extends: true, + test: { name: "unit", include: ["src/**/*.test.ts"] }, + }, + { + extends: true, + test: { name: "smoke", include: ["test/package.smoke.test.ts"] }, + }, + ], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16d8eb2..9b76e4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -462,6 +462,58 @@ importers: specifier: 5.0.3 version: 5.0.3(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + packages/core/graph/source-contract: + dependencies: + '@exadev/process-graph-schema': + specifier: 1.0.2 + version: link:../schema + '@orpc/client': + specifier: 2.0.0-beta.41 + version: 2.0.0-beta.41 + '@orpc/contract': + specifier: 2.0.0-beta.41 + version: 2.0.0-beta.41 + '@orpc/server': + specifier: 2.0.0-beta.41 + version: 2.0.0-beta.41 + trilean: + specifier: 1.7.2 + version: 1.7.2 + zod: + specifier: 4.6.5 + version: 4.6.5 + devDependencies: + '@arethetypeswrong/cli': + specifier: 0.18.5 + version: 0.18.5 + '@stryker-mutator/core': + specifier: 10.0.0 + version: 10.0.0(@types/node@26.6.4) + '@stryker-mutator/typescript-checker': + specifier: 10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.6.4))(typescript@6.0.3) + '@stryker-mutator/vitest-runner': + specifier: 10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.6.4))(vitest@5.0.3) + '@types/node': + specifier: 26.6.4 + version: 26.6.4 + '@vitest/coverage-v8': + specifier: 5.0.3 + version: 5.0.3(vitest@5.0.3) + eslint: + specifier: 10.12.0 + version: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) + publint: + specifier: 0.3.25 + version: 0.3.25 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: 5.0.3 + version: 5.0.3(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + packages/targets/process-graph: dependencies: '@exadev/process-graph-dag': @@ -1209,6 +1261,26 @@ packages: '@octokit/types@18.0.0': resolution: {integrity: sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==} + '@orpc/client@2.0.0-beta.41': + resolution: {integrity: sha512-0P6UxBpL6+0+lNu/gct5d9OibW94xp97UReblQVjqsWks7g8GOjzNRvP6b8MIGz1qpCKaNMOFAUdMJVF+BflJw==} + + '@orpc/contract@2.0.0-beta.41': + resolution: {integrity: sha512-zcRzv6N2SGHBXKEDA6n3nsZvtsPCde1kjhO5ZEH6TQw7BNIgq2v4bGM9GEsbczTptSr5B6ehEwE2/+aTlxEglw==} + + '@orpc/server@2.0.0-beta.41': + resolution: {integrity: sha512-66XM0e1p5uP78C77nONozn0igCjkoYiOnUOB1ScP8UE8EeVle0BFDQQViBqttYCYOSVr3P+8u3ru1bpKZk5fUg==} + peerDependencies: + crossws: '>=0.3.4' + fastify: '>=5.6.1' + peerDependenciesMeta: + crossws: + optional: true + fastify: + optional: true + + '@orpc/shared@2.0.0-beta.41': + resolution: {integrity: sha512-bXqVa8wivVpMkdb8X0HnYI1CUA2hVuV1GSsjs2NIJ2oU/H6wRQi/R7XY/PPICYz0qFjwM2x2tUm1EH395fVd3A==} + '@ota-meshi/ast-token-store@0.3.0': resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -1630,6 +1702,35 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-server/aws-lambda@0.10.0': + resolution: {integrity: sha512-UOT2gtDvRaMPEYWsfGIKdIgDillMjF1LdvvpACWfqKn0krkOQ4x7wfhMwwD4rCN5g/5iI5qO8tlvKJLlJtdInQ==} + + '@standard-server/core@0.10.0': + resolution: {integrity: sha512-t3anpLtP/Qr64TT8OsJbH4b7T3/ho2r2MYZMzT972yh/MGj6iH2SizXTtKdrN9vU1LvZ8BKldAtGNVCu8lW/Ow==} + + '@standard-server/fastify@0.10.0': + resolution: {integrity: sha512-kDpujjB0eujp9Da06i+yhHXU1ZO3nJxL365lVys6BS3yVJ6CgNh03xO2tODUvOL7NvX29bE6P6WO+yQedwoURg==} + peerDependencies: + fastify: '>=5.6.1' + peerDependenciesMeta: + fastify: + optional: true + + '@standard-server/fetch@0.10.0': + resolution: {integrity: sha512-PEyo2yCggoBjQzslvxUGzRkJSr2PWaCFvDw8+JJnCEDvEdTv8hzaTCyU2nog+zPu4XFq6zzVYh1Q6A80gGNlQg==} + + '@standard-server/node@0.10.0': + resolution: {integrity: sha512-Jiqzuo/50kQCuD6ET9kDir3tgq4CpMxKHr1kXP/dFkTaxIWKmsgoAlw9oDTpok8TYVvpHtHrnENTBvAavNJpMg==} + + '@standard-server/peer@0.10.0': + resolution: {integrity: sha512-HdNxcZsnYXcHySD31RsGOynQXYWUOOSVWSjdb9di2Gm6FW9EP1mB+h6b+5UkzNSs7hMdhL2z1zlaIFhplznQtg==} + + '@standard-server/shared@0.10.0': + resolution: {integrity: sha512-yHBLREnJzZg9kugvG7zpaFL25wGLbdKgSCavmjOj4wgJ2dwZZ/w4nl503Y+44Kt87Z1gbd2OjwNIz379QAaZ3Q==} + '@stryker-mutator/api@10.0.0': resolution: {integrity: sha512-ZtAJ0ZT3MVRCWJTBE2h90XB/6E+4lifHYtcTyNG6nU2nLekPgTo4gD5esjX6Okxo1b/JB4jJzyxYB54fwKAoJw==} engines: {node: '>=22.0.0'} @@ -2183,6 +2284,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -3550,6 +3655,10 @@ packages: resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} + radash@12.1.1: + resolution: {integrity: sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA==} + engines: {node: '>=14.18.0'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -5032,6 +5141,38 @@ snapshots: dependencies: '@octokit/openapi-types': 29.0.1 + '@orpc/client@2.0.0-beta.41': + dependencies: + '@orpc/shared': 2.0.0-beta.41 + '@standard-server/core': 0.10.0 + '@standard-server/fetch': 0.10.0 + '@standard-server/peer': 0.10.0 + + '@orpc/contract@2.0.0-beta.41': + dependencies: + '@orpc/client': 2.0.0-beta.41 + '@orpc/shared': 2.0.0-beta.41 + '@standard-schema/spec': 1.1.0 + + '@orpc/server@2.0.0-beta.41': + dependencies: + '@orpc/client': 2.0.0-beta.41 + '@orpc/contract': 2.0.0-beta.41 + '@orpc/shared': 2.0.0-beta.41 + '@standard-server/aws-lambda': 0.10.0 + '@standard-server/core': 0.10.0 + '@standard-server/fastify': 0.10.0 + '@standard-server/fetch': 0.10.0 + '@standard-server/node': 0.10.0 + '@standard-server/peer': 0.10.0 + cookie: 2.0.1 + + '@orpc/shared@2.0.0-beta.41': + dependencies: + '@standard-server/shared': 0.10.0 + radash: 12.1.1 + type-fest: 5.10.0 + '@ota-meshi/ast-token-store@0.3.0': {} '@oxc-parser/binding-android-arm-eabi@0.150.0': @@ -5331,6 +5472,42 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.1.0': {} + + '@standard-server/aws-lambda@0.10.0': + dependencies: + '@standard-server/core': 0.10.0 + '@standard-server/fetch': 0.10.0 + '@standard-server/node': 0.10.0 + '@standard-server/shared': 0.10.0 + + '@standard-server/core@0.10.0': + dependencies: + '@standard-server/shared': 0.10.0 + + '@standard-server/fastify@0.10.0': + dependencies: + '@standard-server/core': 0.10.0 + '@standard-server/node': 0.10.0 + + '@standard-server/fetch@0.10.0': + dependencies: + '@standard-server/core': 0.10.0 + '@standard-server/shared': 0.10.0 + + '@standard-server/node@0.10.0': + dependencies: + '@standard-server/core': 0.10.0 + '@standard-server/fetch': 0.10.0 + '@standard-server/shared': 0.10.0 + + '@standard-server/peer@0.10.0': + dependencies: + '@standard-server/core': 0.10.0 + '@standard-server/shared': 0.10.0 + + '@standard-server/shared@0.10.0': {} + '@stryker-mutator/api@10.0.0': dependencies: mutation-testing-metrics: 3.8.4 @@ -5953,6 +6130,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie@2.0.1: {} + core-util-is@1.0.3: {} cosmiconfig-typescript-loader@6.3.0(@types/node@26.6.4)(cosmiconfig@9.0.2(typescript@6.0.3))(typescript@6.0.3): @@ -7420,6 +7599,8 @@ snapshots: dependencies: side-channel: 1.1.1 + radash@12.1.1: {} + rc@1.2.8: dependencies: deep-extend: 0.6.0 diff --git a/tsconfig.build.json b/tsconfig.build.json index 4f0f94b..5d0d6bb 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -13,6 +13,9 @@ { "path": "packages/core/graph/domain/tsconfig.build.json" }, + { + "path": "packages/core/graph/source-contract/tsconfig.build.json" + }, { "path": "packages/core/graph/layout/tsconfig.build.json" },