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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ A new package is private and reaches consumers through a subpath of the publishe
4. Add dependencies with pnpm: a sibling as `workspace:*` (`pnpm add @exadev/process-graph-<sibling>@workspace:*`), and a third-party library from the catalog in `pnpm-workspace.yaml` (`pnpm add --save-catalog <library>@<exact version>`, which adds the catalog entry when there is none). `pnpm run deps:lint` rejects anything else.
5. Add its `tsconfig.build.json` to the root `tsconfig.build.json` `references`.
6. If it is the first package in its layer, add that layer's workspace entry to `knip.config.ts` (an entry that matches nothing is a knip hint).
7. Expose it through `process-graph`, in `packages/targets/process-graph`: a module under `src/` that re-exports it, named for the subpath (`src/<subpath>.ts`), an entry for that module in `tsdown.config.ts`, the subpath in the `exports` map, the module in the subpath list in `eslint.config.ts`, a case in `src/entrypoints.unit.test.ts` and an import of the subpath in `test/package.smoke.test.ts`. Add the package to `devDependencies` at its exact version (`pnpm add -D --save-exact @exadev/process-graph-<name>@<version>`), never `workspace:*`, since that manifest is published as written. Each third-party library the new subpath needs that the root entry does not goes in `peerDependencies`, marked optional in `peerDependenciesMeta`, at the catalog's exact version (`pnpm add --save-peer --save-exact <library>@<version>`). Where a layer's names collide with another layer's on the same subpath, namespace one layer rather than shadow a name.
7. Expose it through `process-graph`, in `packages/targets/process-graph`: a module under `src/` that re-exports it, named for the subpath (`src/<subpath>.ts`), an entry for that module in `tsdown.config.ts`, the subpath in the `exports` map, the module in the subpath list in `eslint.config.ts`, a case in `src/entrypoints.unit.test.ts` and an import of the subpath in `test/package.smoke.test.ts`. Add the package to `devDependencies` at its exact version (`pnpm add -D --save-exact @exadev/process-graph-<name>@<version>`), never `workspace:*`, since that manifest is published as written. Each third-party library the new subpath needs that the root entry does not goes in `peerDependencies`, marked optional in `peerDependenciesMeta`, as a caret range on the catalog's version, `^<version>`, or at the catalog's exact version when that is a prerelease, which has no compatible-range semantics (`pnpm add --save-peer --save-exact <library>@<version>`, then widen a stable peer's specifier to the caret range; `deps:lint` holds both forms). Where a layer's names collide with another layer's on the same subpath, namespace one layer rather than shadow a name.
8. Write its `README.md`, then `ln -s README.md AGENTS.md && ln -s README.md CLAUDE.md`.
9. Add it to the internal packages table in the root `README.md`, and the subpath to the `process-graph` README.
10. Verify with the repository scripts: `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm test:smoke`, `pnpm exec knip`, `pnpm run deps:lint`.
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ A process and dependency graph model, with the reasoning, command line and React

TypeScript, ESM, pnpm workspaces, Turborepo, Vitest, ESLint with `@exadev/eslint-config`, semantic-release.

## Install

```sh
pnpm add process-graph
```

The root entry needs `zod` and `trilean`, which install with it; each other subpath names the optional peers to install beside it in the [`process-graph` README](packages/targets/process-graph/README.md). A project with an install age gate, such as pnpm's `minimumReleaseAge`, must exempt `trilean` and its own dependency `trilean-regex` as well as `process-graph`, since both are ExaDev packages released alongside it and are often younger than the gate; with pnpm, add all three to `minimumReleaseAgeExclude` in `pnpm-workspace.yaml`.

## Getting started

Node and pnpm versions are pinned in `.tool-versions` and `package.json`'s `packageManager`; install through mise.
Expand All @@ -16,7 +24,7 @@ pnpm build
pnpm test
```

Dependencies are added with `pnpm add`, never by editing a version by hand. Every third-party version is written once, exactly, in the catalog in `pnpm-workspace.yaml`: a private package names it as `catalog:` (`pnpm add --save-catalog <name>@<version>`) and a sibling as `workspace:*`. The published `process-graph` manifest names exact literal versions instead, because `npm publish` ships a protocol specifier verbatim; `pnpm run deps:lint` (`syncpack` and `scripts/check-published-manifests.ts`) holds both rules. The install age gate in `pnpm-workspace.yaml` is never lowered; ExaDev's own packages are the only entries in `minimumReleaseAgeExclude`.
Dependencies are added with `pnpm add`, never by editing a version by hand. Every third-party version is written once, exactly, in the catalog in `pnpm-workspace.yaml`: a private package names it as `catalog:` (`pnpm add --save-catalog <name>@<version>`) and a sibling as `workspace:*`. The published `process-graph` manifest names literal versions instead, because `npm publish` ships a protocol specifier verbatim: its dependencies exactly, and its peer dependencies as caret ranges on the lowest tested version, which the catalog's version satisfies, except a prerelease peer (oRPC's betas, Drizzle's release candidate), which stays exact because a prerelease has no compatible-range semantics; `pnpm run deps:lint` (`syncpack` and `scripts/check-published-manifests.ts`) holds both rules. The install age gate in `pnpm-workspace.yaml` is never lowered; ExaDev's own packages are the only entries in `minimumReleaseAgeExclude`.

## Commands

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"@semantic-release/release-notes-generator": "catalog:",
"@stryker-mutator/api": "catalog:",
"@types/node": "catalog:",
"@types/semver": "catalog:",
"conventional-changelog-conventionalcommits": "catalog:",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
Expand All @@ -71,6 +72,7 @@
"lint-staged": "catalog:",
"prettier": "catalog:",
"semantic-release": "catalog:",
"semver": "catalog:",
"syncpack": "catalog:",
"turbo": "catalog:",
"typescript": "catalog:",
Expand Down
2 changes: 1 addition & 1 deletion packages/adapters/graph/source-github-issues/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ A rate-limited sync resumes from the last page's `next` once the limit resets.

## Tokens

The token comes from a `GitHubTokenPort`, `(owner, signal) => Promise<string>`, asked for the owner of each repository or board read, so different organisations can use different credentials, and with `owner` undefined for the viewer's own listing, which no owner names. The token is sent only in the `Authorization` header and never logged or put in an error.
The token comes from a `GitHubTokenPort`, `(owner, signal) => Promise<string>`, asked for the owner of each repository or board read, so different organisations can use different credentials, and with `owner` undefined for the viewer's own listing, which no owner names. The token is sent only in the `Authorization` header and never logged or put in an error. A port that cannot supply a token ends the call as `unauthorised`, just as GitHub rejecting one would: a `GraphSourceError` it throws is passed through as it is, and any other error is wrapped in one whose message names the token port and the owner (`the GitHub token port gave no token for <owner>: <its message>`) and whose cause is the port's own error.

`storedTokenPort({ list, used })` chooses among tokens a host keeps (a browser's token store, say), each scoped to some owners or to any: a token naming the owner wins, the one naming the fewest owners first and then the most recently used; otherwise the most recently used token for any owner; a request with no owner takes only a token for any owner. Logins match without regard to case. `list` is read on every request, `used` is told which token was chosen so the host can keep its recency, and no fitting token is `unauthorised`. `selectStoredToken` is the choice on its own.

Expand Down
35 changes: 33 additions & 2 deletions packages/adapters/graph/source-github-issues/src/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const githubGraphqlEndpoint = "https://api.github.com/graphql";
* @param owner - The login of the account whose repositories or board the request reads, or `undefined` for a request about the token's own holder (the viewer and the organisations it belongs to), which no owner names.
* @param signal - The caller's abort signal.
* @returns The token.
* @throws GraphSourceError with `unauthorised` when no token can be had, with a message saying how to get one; any other error is reported by the adapter as `unauthorised` too, naming the token port and the owner, with that error as the cause.
*/
export type GitHubTokenPort = (
owner: string | undefined,
Expand Down Expand Up @@ -151,21 +152,51 @@ export interface GraphqlRequest<Schema extends z.ZodType> {
readonly schema: Schema;
}

/**
* Asks the token port for the token a request needs. A port that cannot supply one has failed to provide credentials, which ends the sync exactly as GitHub rejecting them would, so whatever it throws is reported as `unauthorised`: a `GraphSourceError` the port threw as it is, and anything else wrapped in one whose message names the token port and the owner and whose cause is the port's own error. An abort is passed through, since it is the caller's, not the port's.
* @param transport - The token port.
* @param owner - The login the request is about, or `undefined` for the token's own holder.
* @param signal - The caller's abort signal.
* @returns The token.
* @throws GraphSourceError with `unauthorised` when the port fails.
*/
const tokenFor = async (
transport: GraphqlTransport,
owner: string | undefined,
signal: AbortSignal | undefined,
): Promise<string> => {
try {
return await transport.token(owner, signal);
} catch (error: unknown) {
if (error instanceof GraphSourceError || signal?.aborted === true) {
throw error;
}
const reason = error instanceof Error ? error.message : String(error);
throw new GraphSourceError(
{
kind: "unauthorised",
message: `the GitHub token port gave no token${owner === undefined ? "" : ` for ${owner}`}: ${reason}`,
},
{ cause: error },
);
}
};

/**
* Sends one GraphQL request and classifies the answer.
* @param transport - The token, fetch and clock ports.
* @param request - The document, its variables, the owner whose token to use and the schema for `data`.
* @param signal - The caller's abort signal, passed to `fetch`.
* @returns The parsed data and any `NOT_FOUND` or `FORBIDDEN` problems beside it.
* @throws GraphSourceError with `unauthorised` for HTTP 401, and `rate-limited` (with the reset instant when GitHub gives one) for any form of rate limiting.
* @throws GraphSourceError with `unauthorised` for HTTP 401 or a token port that fails, and `rate-limited` (with the reset instant when GitHub gives one) for any form of rate limiting.
* @throws Error for any other failure: an HTTP error, another GraphQL error type, or data that does not match the schema.
*/
export const graphql = async <Schema extends z.ZodType>(
transport: GraphqlTransport,
request: GraphqlRequest<Schema>,
signal: AbortSignal | undefined,
): Promise<QueryResult<z.infer<Schema>>> => {
const token = await transport.token(request.owner, signal);
const token = await tokenFor(transport, request.owner, signal);
const response = await transport.fetch(githubGraphqlEndpoint, {
method: "POST",
headers: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import {
createGraphSourceRouter,
GraphSourceError,
} from "@exadev/process-graph-source-contract";
import { inProcessClient } from "@exadev/process-graph-source-contract/conformance";
import { describe, expect, test } from "vitest";
import type { GraphqlFetch } from "./graphql.ts";
import type { GitHubTokenPort, GraphqlFetch } from "./graphql.ts";
import { createGitHubIssuesSource } from "./source.ts";
import { fakeToken, fixedNow, stubbedRouter } from "./test-support/sources.ts";

const scope = ["github:ExaDev/document-schema.js"];
Expand Down Expand Up @@ -260,3 +265,54 @@ describe("requests", () => {
]);
});
});

describe("a token port that fails", () => {
/** A fetch the request must never reach, since it has no token to send. */
const unreachable: GraphqlFetch = async () =>
Promise.reject(new Error("fetch was called without a token"));

/** A page read through the router with this token port. */
const pageWithToken = async (token: GitHubTokenPort) =>
inProcessClient(
createGraphSourceRouter(
createGitHubIssuesSource({
token,
fetch: unreachable,
now: () => fixedNow,
}),
),
).v1.sync.page({ scope });

test("is UNAUTHORIZED, naming the token port and the owner, with the port's own error as the cause of the source error", async () => {
const original = new Error("the keychain is locked");
const failure = await pageWithToken(async () =>
Promise.reject(original),
).catch((error: unknown) => error);

expect(failure).toMatchObject({
code: "UNAUTHORIZED",
message:
"the GitHub token port gave no token for ExaDev: the keychain is locked",
});
expect(
failure instanceof Error &&
failure.cause instanceof GraphSourceError &&
failure.cause.cause,
).toBe(original);
});

test("passes a GraphSourceError the port throws through as it is", async () => {
const own = new GraphSourceError({
kind: "unauthorised",
message: "no stored GitHub token is for ExaDev or for any owner",
});

await expect(
pageWithToken(async () => Promise.reject(own)),
).rejects.toMatchObject({
code: "UNAUTHORIZED",
message: "no stored GitHub token is for ExaDev or for any owner",
cause: own,
});
});
});
18 changes: 18 additions & 0 deletions packages/adapters/graph/sqlite-node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,22 @@ close();

`openNodeGraphStore(path, options)` opens the file (or `":memory:"`), passing `node:sqlite`'s own options through, and the store migrates the schema on its first operation with the migrations embedded in `graph-store-sqlite`, the same ones the browser driver applies. A file database is switched to write-ahead logging, so a reader in another process is not blocked by a writer; set `timeout` to have a write wait for another process's lock. The store's change feed sees only the writes made through it, so a subscriber in one process does not hear another process's changes.

`prepareDataFile(path, { ownsDirectory, platform })` makes a data file private before the store opens it, since a store holds synced work that other users on the host must not read. It creates the directory when missing, readable by its owner only (`privateDirectoryMode`, `0700`) when the caller owns it, and creates the database file owner-only (`privateFileMode`, `0600`) before SQLite opens it, so the `-wal` and `-shm` files SQLite derives from it are private too, whatever the umask. In a directory the caller owns, an existing directory or store file others can read is narrowed to those modes and the call returns one warning naming what changed, for the caller to show; in a directory someone else chose, the directory and an existing file are left as they are. On Windows, where file modes mean nothing, it only creates the directory.

```ts
import {
openNodeGraphStore,
prepareDataFile,
} from "@exadev/process-graph-sqlite-node";

const path = join(appHome, "graph", "graph.sqlite3");
for (const warning of prepareDataFile(path, {
ownsDirectory: true,
platform: process.platform,
})) {
console.warn(warning);
}
const { store, close } = openNodeGraphStore(path);
```

The contract's conformance kit runs against an in-memory database in process and against a file database over a `MessageChannel`.
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ const permissionBits = 0o777;
/** The bits that give the file's group or anyone else any access. */
const groupAndOtherBits = 0o077;

/** Where a store's data file lives, and how much of its path the command line owns. */
/** Where a store's data file lives, and how much of its path the caller owns. */
export interface DataFileOptions {
/**
* Whether the directory is the command line's own default, which it creates private and keeps private. A directory the user named (`--data` or `PROCESS_GRAPH_DATA`) is created when missing but never has its mode changed.
* Whether the directory is the caller's own, such as an application's default data directory or one a host keeps under its own home, which is created private and kept private. A directory someone else chose, such as a path a user names on a command line, is created when missing but never has its mode changed.
*/
readonly ownsDirectory: boolean;
/** The platform; on Windows, file modes mean nothing and nothing is set. */
Expand Down Expand Up @@ -59,10 +59,13 @@ const tighten = (path: string, mode: number): boolean => {
};

/**
* Makes a data file ready for SQLite to open. The directory is created when missing, private when it is the command line's own. The database file is created private when missing, before SQLite opens it, so the file and the sidecars SQLite derives from it are never readable by others, whatever the umask; a file that already exists is left alone unless the directory is the command line's own. In its own directory, an existing directory or store file that group or others can read is narrowed to the private mode.
* @param path - The database file.
* @param options - How much of the path the command line owns, and the platform.
* @returns One warning line naming what was narrowed, or nothing.
* Makes a data file ready for `openNodeGraphStore` to open, so a store holding synced work is never readable by other users on the host. Call it before opening the store; it does not open the database itself.
*
* The directory is created when missing (with any missing parents), with `privateDirectoryMode` when it is the caller's own. The database file is created with `privateFileMode` when missing, before SQLite opens it, so the file and the `-wal` and `-shm` files SQLite derives from it are never readable by others, whatever the umask. A file that already exists is left alone unless the directory is the caller's own; there, an existing directory or store file (database, `-wal` or `-shm`) that group or others can read is narrowed to the private mode. On Windows, where file modes mean nothing, only the directory is created.
* @param path - The database file; not `":memory:"`, which has no file.
* @param options - Whether the caller owns the directory, and the platform.
* @returns One warning line naming what was narrowed, for the caller to show, or nothing when nothing was.
* @throws Error from the file system when the directory or file cannot be created or its mode read or changed.
*/
export const prepareDataFile = (
path: string,
Expand Down
Loading
Loading