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
1 change: 1 addition & 0 deletions packages/adapters/graph/source-github-issues/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"vitest": "catalog:"
},
"dependencies": {
"@exadev/process-graph-schema": "workspace:*",
"@exadev/process-graph-source-contract": "workspace:*",
"zod": "catalog:"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { BoundActivity } from "@exadev/process-graph-schema";
import type {
BoundActivity,
Gap,
Relation,
SourceParticipant,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
},
"exclude": ["src/**/*.test.ts", "src/test-support/**"],
"references": [
{
"path": "../../../core/graph/schema/tsconfig.build.json"
},
{
"path": "../../../core/graph/source-contract/tsconfig.build.json"
}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/graph/schema/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ An activity's actor and a message's ends must be actors; a state machine project

An activity may carry `source`, a source-qualified identifier such as `github:org/repo#12`, tying it to the external record it stands for. The source is the activity's identity in its system, so two activities may not share one (`duplicate-source`). Activities without a source are unaffected.

`boundActivitySchema` is the one definition of a bound activity: the model's activity with `source` required and equal to its `id`, so the same record reached twice is the same element. `BoundActivity` is its type. The source contract's sync pages and the store contract's upserted items both use this schema rather than defining their own, so what a source emits is exactly what a store accepts. It is built from the same object schema as the model's own activity, so the two cannot drift, and it is declared as the named interface `BoundActivitySchema` for the same reason as `ModelSchema` below.

## Affected entities

An activity may list the `entity` participants whose lifecycle it changes in `affects`. It is typed to the entity space only, so naming a role or a system is a compile error, and `validate` reports the same mistake in a loaded model (`affects-not-entity`). A state machine projection uses it to keep only the activities that change its subject.
Expand Down
15 changes: 11 additions & 4 deletions packages/core/graph/schema/src/declaration-emit.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import { join } from "node:path";
import ts from "typescript";
import { expect, test } from "vitest";

/** A consumer's module that embeds `modelSchema` in a schema of its own and exports it, so its declaration file has to write the schema's type. */
/** A consumer's module that embeds `modelSchema` and `boundActivitySchema` in a schema of its own and exports it, so its declaration file has to write each schema's type. */
const consumerSource = `import { z } from "zod";
import { modelSchema } from "./index.ts";
import { boundActivitySchema, modelSchema } from "./index.ts";

export const envelope = z.object({ model: modelSchema });
export const envelope = z.object({
model: modelSchema,
activities: z.array(boundActivitySchema),
});
`;

/**
Expand Down Expand Up @@ -64,13 +67,17 @@ const declarationsOf = (
const unbounded = 0;

test(
"a consumer's schema embedding modelSchema emits declarations that name ModelSchema rather than writing it out",
"a consumer's schema embedding modelSchema and boundActivitySchema emits declarations that name ModelSchema and BoundActivitySchema rather than writing them out",
{ timeout: unbounded },
() => {
const { declarations, diagnostics } = declarationsOf(consumerSource);

expect(diagnostics).toEqual([]);
expect(declarations).toMatch(/model: import\("[^"]+"\)\.ModelSchema;/);
expect(declarations).toMatch(
/activities: z\.ZodArray<import\("[^"]+"\)\.BoundActivitySchema>;/,
);
expect(declarations).not.toContain("participants");
expect(declarations).not.toContain("consumes");
},
);
8 changes: 7 additions & 1 deletion packages/core/graph/schema/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,11 @@ export {
type OverlayIssue,
} from "./overlay.ts";
export type { OverlayStatus } from "./vocabulary.ts";
export { modelSchema, type ModelSchema } from "./schema.ts";
export {
boundActivitySchema,
modelSchema,
type BoundActivity,
type BoundActivitySchema,
type ModelSchema,
} from "./schema.ts";
export { ModelParseError, parseModel, type ModelIssue } from "./parse.ts";
56 changes: 44 additions & 12 deletions packages/core/graph/schema/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,22 @@ const artefactSchema = record({
deliverable: z.boolean().exactOptional(),
});

/** The strict object schema of an `activity` element, before it is made read-only: the one definition both the model's activity and a bound activity are built from. */
const activityObjectSchema = z.strictObject({
kind: z.literal("activity"),
id,
extensions: extensionsSchema.exactOptional(),
label: z.string(),
actor: actorRef.exactOptional(),
action: actionRef.exactOptional(),
source: z.string().exactOptional(),
affects: z.array(entityRef).readonly().exactOptional(),
consumes: z.array(artefactRef).readonly().exactOptional(),
produces: z.array(artefactRef).readonly().exactOptional(),
});

const elementSchema = z.discriminatedUnion("kind", [
record({
kind: z.literal("activity"),
id,
extensions: extensionsSchema.exactOptional(),
label: z.string(),
actor: actorRef.exactOptional(),
action: actionRef.exactOptional(),
source: z.string().exactOptional(),
affects: z.array(entityRef).readonly().exactOptional(),
consumes: z.array(artefactRef).readonly().exactOptional(),
produces: z.array(artefactRef).readonly().exactOptional(),
}),
activityObjectSchema.readonly(),
record({
kind: z.literal("decision"),
id,
Expand Down Expand Up @@ -165,3 +168,32 @@ export interface ModelSchema extends z.ZodReadonly<typeof modelObjectSchema> {
* Every object is strict. The inferred type is exactly `Model<OpenIds>`, which a type-level test pins in both directions.
*/
export const modelSchema: ModelSchema = modelObjectSchema.readonly();

/** The strict object schema inside `boundActivitySchema`, before it is made read-only. */
const boundActivityObjectSchema = activityObjectSchema
.extend({ source: z.string().min(1) })
.refine((activity) => activity.id === activity.source, {
message: "a bound activity's id must equal its source",
path: ["id"],
});

/**
* The type of `boundActivitySchema`, declared by name for the same reason as `ModelSchema`: a consumer's declaration file that embeds the schema, such as a source page's or a store's item list, refers to `BoundActivitySchema` rather than writing out the activity with trilean's JSON value inline.
*/
export interface BoundActivitySchema extends z.ZodReadonly<
typeof boundActivityObjectSchema
> {
/** The strict object schema inside, whose `shape` holds each field's own schema. */
readonly unwrap: () => typeof boundActivityObjectSchema;
}

/**
* An activity bound to the external record it stands for: the model's `activity` element with `source` required, where the model leaves it optional, and equal to `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.
*
* This is the one definition of a bound activity: a source's sync page and a store's upserted items both use it, so what a source emits is exactly what a store accepts.
*/
export const boundActivitySchema: BoundActivitySchema =
boundActivityObjectSchema.readonly();

/** An activity bound to the external record it stands for, as `boundActivitySchema` parses it. */
export type BoundActivity = z.output<BoundActivitySchema>;
73 changes: 71 additions & 2 deletions packages/core/graph/schema/src/schema.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { describe, expect, expectTypeOf, it } from "vitest";
import type { z } from "zod";
import type { Model, OpenIds } from "./index.ts";
import { modelSchema } from "./schema.ts";
import type { Element, Extensions, Model, OpenIds } from "./index.ts";
import {
boundActivitySchema,
modelSchema,
type BoundActivity,
} from "./schema.ts";
import { order } from "./test-support/order.ts";

describe("modelSchema", () => {
Expand All @@ -13,3 +17,68 @@ describe("modelSchema", () => {
expect(modelSchema.parse(order)).toEqual(order);
});
});

describe("boundActivitySchema", () => {
const bound = {
kind: "activity",
id: "github:o/r#1",
source: "github:o/r#1",
label: "One",
};

it("is exactly the model's activity with a required source", () => {
expectTypeOf<BoundActivity>().toEqualTypeOf<{
readonly kind: "activity";
readonly id: string;
readonly extensions?: Extensions;
readonly label: string;
readonly actor?: string;
readonly action?: string;
readonly source: string;
readonly affects?: readonly string[];
readonly consumes?: readonly string[];
readonly produces?: readonly string[];
}>();
expectTypeOf<BoundActivity>().toExtend<Element<string, OpenIds>>();
});

it("accepts an activity whose id is its source, which is also a model element", () => {
expect(boundActivitySchema.parse(bound)).toStrictEqual(bound);
expect(modelSchema.unwrap().shape.elements.parse([bound])).toStrictEqual([
bound,
]);
});

it.each([
{ name: "no source", value: { kind: "activity", id: "a", label: "A" } },
{
name: "an empty source",
value: { kind: "activity", id: "", source: "", label: "A" },
},
{
name: "an id that is not its source",
value: { ...bound, source: "github:o/r#2" },
},
{
name: "another element kind",
value: { kind: "decision", id: "d", source: "d", label: "D" },
},
{
name: "a field the model does not define",
value: { ...bound, url: "https://example.com/issues/1" },
},
])("rejects $name", ({ value }) => {
expect(boundActivitySchema.safeParse(value).success).toBe(false);
});

it("reports an id that is not its source at the id", () => {
const result = boundActivitySchema.safeParse({
...bound,
source: "github:o/r#2",
});

expect(result.error?.issues).toMatchObject([
{ path: ["id"], message: "a bound activity's id must equal its source" },
]);
});
});
2 changes: 1 addition & 1 deletion packages/core/graph/source-contract/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const { model, overlay, relations, gaps } = assembleSync(pages);

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.
- **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. Its schema is graph-schema's `boundActivitySchema`, the same one the store contract's `items.upsert` accepts.
- **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.
Expand Down
2 changes: 0 additions & 2 deletions packages/core/graph/source-contract/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
export { graphSourceContract, type GraphSourceClient } from "./contract.ts";
export {
badRequestReasons,
boundActivitySchema,
filterStates,
gapReasons,
gapSchema,
Expand All @@ -25,7 +24,6 @@ export {
syncPageSchema,
transitionSchema,
type BadRequestReason,
type BoundActivity,
type FilterState,
type Gap,
type GapReason,
Expand Down
24 changes: 6 additions & 18 deletions packages/core/graph/source-contract/src/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { modelSchema, overlaySchema } from "@exadev/process-graph-schema";
import {
boundActivitySchema,
modelSchema,
overlaySchema,
} from "@exadev/process-graph-schema";
import { z } from "zod";

/**
Expand All @@ -12,21 +16,6 @@ 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;

Expand Down Expand Up @@ -56,7 +45,7 @@ export const gapSchema = z
.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.
* One page of a sync: bound activities (graph-schema's `boundActivitySchema`), 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.
*/
Expand Down Expand Up @@ -162,7 +151,6 @@ export const sourceDescriptionSchema = z
/** Why a request was rejected as malformed. */
export const badRequestReasons = ["invalid-cursor", "invalid-scope"] as const;

export type BoundActivity = z.infer<typeof boundActivitySchema>;
export type SourceParticipant = z.infer<typeof participantSchema>;
export type SourceTransition = z.infer<typeof transitionSchema>;
export type Relation = z.infer<typeof relationSchema>;
Expand Down
66 changes: 26 additions & 40 deletions packages/core/graph/source-contract/src/schema.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,44 @@
import type {
Element,
OpenIds,
Participant,
Transition,
import {
boundActivitySchema,
type Extensions,
type OpenIds,
type Participant,
type Transition,
} from "@exadev/process-graph-schema";
import { describe, expect, expectTypeOf, test } from "vitest";
import {
boundActivitySchema,
type BoundActivity,
type SourceParticipant,
type SourceTransition,
syncInputSchema,
syncPageSchema,
type SyncPage,
} from "./schema.ts";

describe("boundActivitySchema", () => {
test("is the model's activity variant", () => {
expectTypeOf<BoundActivity["kind"]>().toEqualTypeOf<"activity">();
expectTypeOf<BoundActivity>().toExtend<Element<string, OpenIds>>();
describe("a sync page's records", () => {
test("are the model's own", () => {
expectTypeOf<SourceParticipant>().toEqualTypeOf<Participant<string>>();
expectTypeOf<SourceTransition>().toExtend<Transition<string, OpenIds>>();
});

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("hold activities exactly as the model's activity with a required source", () => {
expectTypeOf<SyncPage["activities"][number]>().toEqualTypeOf<{
readonly kind: "activity";
readonly id: string;
readonly extensions?: Extensions;
readonly label: string;
readonly actor?: string;
readonly action?: string;
readonly source: string;
readonly affects?: readonly string[];
readonly consumes?: readonly string[];
readonly produces?: readonly string[];
}>();
});

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);
test("hold activities parsed by graph-schema's boundActivitySchema itself", () => {
expect(syncPageSchema.unwrap().shape.activities.unwrap().element).toBe(
boundActivitySchema,
);
});
});

Expand Down
Loading
Loading