Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,17 @@ 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. |
| [`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. |
| [`graph-source-github-issues`](packages/adapters/graph/source-github-issues/README.md) | The GitHub issues adapter for the `GraphSource` port: issues as bound activities, `blockedBy` as transitions, sub-issues, repositories, organisations and assignees, across organisations and repositories. |
| [`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. |
| [`graph-source-github-issues`](packages/adapters/graph/source-github-issues/README.md) | The GitHub issues adapter for the `GraphSource` port: issues as bound activities, `blockedBy` as transitions, sub-issues, repositories, organisations and assignees, across organisations and repositories. |
| [`graph-store-contract`](packages/core/graph/store-contract/README.md) | The persistence port as an oRPC contract: save, load and delete models, set run state, upsert items from a source, structural ready, blocked-chain and impact queries, and a change feed; 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

Expand Down
1 change: 1 addition & 0 deletions packages/core/graph/store-contract/AGENTS.md
1 change: 1 addition & 0 deletions packages/core/graph/store-contract/CLAUDE.md
96 changes: 96 additions & 0 deletions packages/core/graph/store-contract/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# @exadev/process-graph-store-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-store-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 persistence port for process models and their run overlays, as an [oRPC](https://orpc.dev) router contract. One definition runs in process (a router client called directly), across a `MessagePort` to a worker, or over HTTP, with no change to its consumers. Inputs and outputs are Zod schemas built from `@exadev/process-graph-schema`'s `modelSchema` and `overlaySchema`, so the port carries the model as it is and storage layout never reaches it. `src/` imports nothing Node-only.

```ts
import { createRouterClient } from "@orpc/server";
import {
createFakeGraphStore,
createGraphStoreRouter,
} from "@exadev/process-graph-store-contract";

const store = createRouterClient(
createGraphStoreRouter(createFakeGraphStore()),
);

await store.v1.models.save({ modelId: "release", model });
await store.v1.overlay.set({ modelId: "release", overlay: { plan: "done" } });
await store.v1.query.ready({ modelId: "release" }); // { elements: ["build"] }

for await (const change of await store.v1.changes({ modelId: "release" })) {
// { modelId: "release", kind: "overlay", revision: 3 }
}
```

## Procedures

The contract is keyed by version: `graphStoreContract.v1`. A breaking change adds a `v2` beside it, so one server serves both while clients move across.

| Procedure | Input | Output |
| -------------------- | ----------------------------- | ----------------------------------------- |
| `models.list` | none | Each model's id and revision, by id |
| `models.save` | `modelId`, `model` | The revision the save took |
| `models.load` | `modelId` | `modelId`, `revision`, `model`, `overlay` |
| `models.delete` | `modelId` | Whether there was a model to delete |
| `overlay.set` | `modelId`, `overlay` | The revision the change took |
| `items.upsert` | `modelId`, `items` | The revision the change took |
| `query.ready` | `modelId` | The ready elements, in model order |
| `query.blockedChain` | `modelId`, `element` | The unfinished work upstream |
| `query.impact` | `modelId`, `element` | The unfinished work downstream |
| `changes` | `modelId`, optionally `since` | An event iterator of changes |

Every call takes the caller's abort signal through the oRPC client options. Failures are the contract's typed errors: `NOT_FOUND` (with `modelId`, and `element` for a query about an element the model lacks) and `UNPROCESSABLE_CONTENT` (with the `issues` that `validate` and `validateOverlay` report). Input that does not match the schemas is oRPC's `BAD_REQUEST`.

## Writes

Every write is atomic and checked: a change that would leave the model invalid, or the overlay naming an element the model does not declare, is rejected whole and the store is unchanged. A write whose signal has fired before it commits changes nothing.

- `models.save` replaces the model and keeps the run state only of elements the new model still declares.
- `overlay.set` sets the run state of the elements it names and leaves the others.
- `items.upsert` merges items read from a source: participants, bound activities and transitions replace the record with the same id in its place, or are appended in their given order; overlay entries replace the run state of the elements they name. It creates the model from an empty one when there is none. A bound activity's `source` is required and equals its id.

Each write takes the next revision from one store-wide sequence, so revisions only grow, a model's revision is the revision of its last change, and a model deleted and saved again never reuses an earlier one.

## Queries

The queries are structural: they read the stored transitions and run state, and nothing else, so a store can answer them in its own query language without loading the model or evaluating anything. For reasoning that follows conditions, artefacts and three-valued status, load the model and use `@exadev/process-graph-domain`.

- Transitions that close a legal decision loop (`loopTransitions` from `graph-domain`) are not dependencies.
- Decisions and pseudo-states are control elements: traversals pass through them and never report them.
- An element is done when its run state is `done`. Traversal stops at a done element and never reports it.
- `query.ready` is every element whose run state is absent or `open`, that is not a control element, and whose blocked chain is empty. On a model with no conditions and no artefacts it agrees with `graph-domain`'s `readySet`.
- `query.blockedChain` is everything not done upstream of the element, each with the fewest transitions to reach it, ordered by that distance and then by model order. A done element waits on nothing.
- `query.impact` is the same downstream, whatever the element's own state.

## Changes

`changes` yields every later change to one model as `{ modelId, kind, revision }`, with `kind` one of `saved`, `overlay`, `items` or `deleted`. The router subscribes before it returns, so a caller holding the iterator is already subscribed and misses nothing it writes next. Given `since`, a revision the caller last saw, the first event is `{ modelId, kind: "missed" }` when the model is no longer at that revision (it changed, or it has gone), and the caller should load it again.

A subscription's memory is bounded. A change that commits while the subscriber is waiting goes straight to it; one that commits while the subscriber is busy is held, up to the subscription's capacity. When a change arrives and the subscription already holds that many, the held changes are replaced by one `{ modelId, kind: "missed" }`, and later changes follow it in order. A subscriber that keeps reading therefore misses nothing, and one that stops reading costs the store a fixed amount of memory and learns that it must load the model again. The capacity is a `ChangeFeed` option (`new ChangeFeed({ capacity })`, or `subscribe(modelId, { capacity })` for one subscription); the default, `defaultSubscriptionCapacity`, holds one change, because the only thing a change asks of a subscriber is to read the model again, and the model it reads after one pending change already reflects every later one. The conformance kit checks that a subscriber that stops reading under sustained writes is told it missed whatever it did not receive.

A store's `ChangeFeed` only sees the writes made through that store. Where several processes or tabs write one database, each bridges the others' changes into its own feed.

## Implementing a store

An adapter implements `GraphStore` (plain async functions taking a request object with the abort signal) and serves it with `createGraphStoreRouter`, which owns abort handling, error mapping and subscriptions. `checkStored`, `mergeBoundItems` and `overlayWithin` give every store the same write rules, and `structureOf`, `readyOf`, `blockedChainOf` and `impactOf` are the reference queries `createFakeGraphStore` uses.

Every adapter runs the conformance kit, through a real oRPC client:

```ts
import { createRouterClient } from "@orpc/server";
import { graphStoreConformance } from "@exadev/process-graph-store-contract/conformance";

graphStoreConformance({
name: "my store, in process",
createClient: async () => ({
client: createRouterClient(createGraphStoreRouter(await openEmptyStore())),
close: () => Promise.resolve(),
}),
});
```

The kit is a separate entry point with an optional `vitest` peer dependency, so the runtime entry carries no test framework. Run it over each transport the adapter is served on; this package runs it against the fake both in process and over a `MessageChannel`.

oRPC clients are proxies over functions, so a procedure named `apply`, `call` or `bind` would be shadowed by `Function.prototype` on the client; the contract avoids those names.
9 changes: 9 additions & 0 deletions packages/core/graph/store-contract/eslint.config.ts
Original file line number Diff line number Diff line change
@@ -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,
}),
);
91 changes: 91 additions & 0 deletions packages/core/graph/store-contract/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
{
"name": "@exadev/process-graph-store-contract",
"version": "0.0.0",
"description": "The persistence port for process models and run overlays as an oRPC contract: versioned procedures with Zod inputs and outputs, an in-memory fake and a conformance kit every store adapter runs.",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/ExaDev/process-graph.git",
"directory": "packages/core/graph/store-contract"
},
"homepage": "https://github.com/ExaDev/process-graph/tree/main/packages/core/graph/store-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",
"store",
"orpc",
"contract",
"conformance"
],
"devDependencies": {
"@arethetypeswrong/cli": "0.18.5",
"@orpc/client": "2.0.0-beta.41",
"@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-domain": "1.0.2",
"@exadev/process-graph-schema": "1.0.2",
"@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
}
}
}
Loading
Loading