diff --git a/packages/adapters/graph/source-github-issues/package.json b/packages/adapters/graph/source-github-issues/package.json index 42a7d04..3c1052b 100644 --- a/packages/adapters/graph/source-github-issues/package.json +++ b/packages/adapters/graph/source-github-issues/package.json @@ -76,6 +76,7 @@ "vitest": "catalog:" }, "dependencies": { + "@exadev/process-graph-schema": "workspace:*", "@exadev/process-graph-source-contract": "workspace:*", "zod": "catalog:" } diff --git a/packages/adapters/graph/source-github-issues/src/page-builder.ts b/packages/adapters/graph/source-github-issues/src/page-builder.ts index 1b41803..ac351e7 100644 --- a/packages/adapters/graph/source-github-issues/src/page-builder.ts +++ b/packages/adapters/graph/source-github-issues/src/page-builder.ts @@ -1,5 +1,5 @@ +import type { BoundActivity } from "@exadev/process-graph-schema"; import type { - BoundActivity, Gap, Relation, SourceParticipant, diff --git a/packages/adapters/graph/source-github-issues/tsconfig.build.json b/packages/adapters/graph/source-github-issues/tsconfig.build.json index 756f03a..7f4eac1 100644 --- a/packages/adapters/graph/source-github-issues/tsconfig.build.json +++ b/packages/adapters/graph/source-github-issues/tsconfig.build.json @@ -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" } diff --git a/packages/core/graph/schema/README.md b/packages/core/graph/schema/README.md index b0b2600..1f394a2 100644 --- a/packages/core/graph/schema/README.md +++ b/packages/core/graph/schema/README.md @@ -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. diff --git a/packages/core/graph/schema/src/declaration-emit.unit.test.ts b/packages/core/graph/schema/src/declaration-emit.unit.test.ts index 44dd674..aaffec9 100644 --- a/packages/core/graph/schema/src/declaration-emit.unit.test.ts +++ b/packages/core/graph/schema/src/declaration-emit.unit.test.ts @@ -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), +}); `; /** @@ -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;/, + ); expect(declarations).not.toContain("participants"); + expect(declarations).not.toContain("consumes"); }, ); diff --git a/packages/core/graph/schema/src/index.ts b/packages/core/graph/schema/src/index.ts index 6549c2f..6be3396 100644 --- a/packages/core/graph/schema/src/index.ts +++ b/packages/core/graph/schema/src/index.ts @@ -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"; diff --git a/packages/core/graph/schema/src/schema.ts b/packages/core/graph/schema/src/schema.ts index 895d1b7..92620d3 100644 --- a/packages/core/graph/schema/src/schema.ts +++ b/packages/core/graph/schema/src/schema.ts @@ -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, @@ -165,3 +168,32 @@ export interface ModelSchema extends z.ZodReadonly { * Every object is strict. The inferred type is exactly `Model`, 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; diff --git a/packages/core/graph/schema/src/schema.unit.test.ts b/packages/core/graph/schema/src/schema.unit.test.ts index 6766cda..93e79c6 100644 --- a/packages/core/graph/schema/src/schema.unit.test.ts +++ b/packages/core/graph/schema/src/schema.unit.test.ts @@ -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", () => { @@ -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().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().toExtend>(); + }); + + 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" }, + ]); + }); +}); diff --git a/packages/core/graph/source-contract/README.md b/packages/core/graph/source-contract/README.md index 964cce1..bb43735 100644 --- a/packages/core/graph/source-contract/README.md +++ b/packages/core/graph/source-contract/README.md @@ -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. diff --git a/packages/core/graph/source-contract/src/index.ts b/packages/core/graph/source-contract/src/index.ts index 5ccc365..4bdf09f 100644 --- a/packages/core/graph/source-contract/src/index.ts +++ b/packages/core/graph/source-contract/src/index.ts @@ -6,7 +6,6 @@ export { graphSourceContract, type GraphSourceClient } from "./contract.ts"; export { badRequestReasons, - boundActivitySchema, filterStates, gapReasons, gapSchema, @@ -25,7 +24,6 @@ export { syncPageSchema, transitionSchema, type BadRequestReason, - type BoundActivity, type FilterState, type Gap, type GapReason, diff --git a/packages/core/graph/source-contract/src/schema.ts b/packages/core/graph/source-contract/src/schema.ts index b2f71ee..a42f19f 100644 --- a/packages/core/graph/source-contract/src/schema.ts +++ b/packages/core/graph/source-contract/src/schema.ts @@ -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"; /** @@ -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; @@ -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. */ @@ -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; export type SourceParticipant = z.infer; export type SourceTransition = z.infer; export type Relation = z.infer; diff --git a/packages/core/graph/source-contract/src/schema.unit.test.ts b/packages/core/graph/source-contract/src/schema.unit.test.ts index b3fb14f..d875eea 100644 --- a/packages/core/graph/source-contract/src/schema.unit.test.ts +++ b/packages/core/graph/source-contract/src/schema.unit.test.ts @@ -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().toEqualTypeOf<"activity">(); - expectTypeOf().toExtend>(); +describe("a sync page's records", () => { + test("are the model's own", () => { 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("hold activities exactly as the model's activity with a required source", () => { + expectTypeOf().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, + ); }); }); diff --git a/packages/core/graph/store-contract/README.md b/packages/core/graph/store-contract/README.md index abce6ba..19c6622 100644 --- a/packages/core/graph/store-contract/README.md +++ b/packages/core/graph/store-contract/README.md @@ -59,7 +59,7 @@ Every write is atomic and checked: a change that would leave the model invalid, - `models.save` replaces the model and keeps the run state only of elements the new model still declares, the relations only of what it still declares, and the facts about artefacts only of those it still declares. Named facts are kept. - `overlay.set` sets the run state of the elements it names and leaves the others. - `facts.set` records each artefact's provision and each named fact's value it gives, and keeps the rest; `facts.clear` forgets the artefacts and named facts it lists, or the whole record with `{ all: true }`. -- `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; each relation not already stored is appended. It creates the model from an empty one when there is none. A bound activity's `source` is required and equals its id. +- `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; each relation not already stored is appended. It creates the model from an empty one when there is none. A bound activity's `source` is required and equals its id: its schema is graph-schema's `boundActivitySchema`, the same one a source's sync page uses, so a page's items pass unchanged. ## Relations diff --git a/packages/core/graph/store-contract/src/index.ts b/packages/core/graph/store-contract/src/index.ts index f75c204..721f8de 100644 --- a/packages/core/graph/store-contract/src/index.ts +++ b/packages/core/graph/store-contract/src/index.ts @@ -5,7 +5,6 @@ */ export { graphStoreContract, type GraphStoreClient } from "./contract.ts"; export { - boundActivitySchema, boundItemsSchema, changeKinds, factChangeSchema, @@ -22,7 +21,6 @@ export { storeChangeSchema, storedFactsSchema, storedModelSchema, - type BoundActivity, type BoundItems, type ChangeKind, type FactChange, diff --git a/packages/core/graph/store-contract/src/schema.ts b/packages/core/graph/store-contract/src/schema.ts index 0e69894..86df195 100644 --- a/packages/core/graph/store-contract/src/schema.ts +++ b/packages/core/graph/store-contract/src/schema.ts @@ -1,5 +1,9 @@ import type { FactRecord } from "@exadev/process-graph-domain"; -import { modelSchema, overlaySchema } from "@exadev/process-graph-schema"; +import { + boundActivitySchema, + modelSchema, + overlaySchema, +} from "@exadev/process-graph-schema"; import { ComputedValueSchema } from "trilean"; import { z } from "zod"; @@ -8,9 +12,6 @@ import { z } from "zod"; */ const modelShape = modelSchema.unwrap().shape; -/** The model's `activity` element: the first variant of its element union. A type test pins the order, so reordering the union is a compile error. */ -const [activityElementSchema] = modelShape.elements.unwrap().element.options; - /** The name a store files a model under. Any non-empty string; the store gives it no structure. */ export const modelIdSchema = z.string().min(1); @@ -19,18 +20,6 @@ export const modelIdSchema = z.string().min(1); */ export const revisionSchema = z.int().positive(); -/** - * An activity bound to the external record it stands for. `source` is required here, where the model leaves it optional: a bound item's identity is its source record, and the source-qualified identifier is its element id. - */ -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 store keeps beside a model, the same kinds a source reports. */ export const relationKinds = ["child-of", "assigned-to", "part-of"] as const; @@ -46,7 +35,7 @@ export const relationSchema = z .readonly(); /** - * Items read from a source, to be merged into a stored model by identifier. Each participant, activity and transition replaces the one with the same id or is appended after the existing ones; the overlay's entries replace the run state of the elements they name; each relation not already stored is appended. Everything else in the model is left as it is. + * Items read from a source, to be merged into a stored model by identifier. Activities are graph-schema's `boundActivitySchema`, the same schema a source's sync page uses, so a page's items pass unchanged. Each participant, activity and transition replaces the one with the same id or is appended after the existing ones; the overlay's entries replace the run state of the elements they name; each relation not already stored is appended. Everything else in the model is left as it is. */ export const boundItemsSchema = z .strictObject({ @@ -162,7 +151,6 @@ export const issueSchema = z export type ModelId = z.infer; export type Revision = z.infer; -export type BoundActivity = z.infer; export type BoundItems = z.infer; export type Relation = z.infer; export type RelationKind = (typeof relationKinds)[number]; diff --git a/packages/core/graph/store-contract/src/schema.unit.test.ts b/packages/core/graph/store-contract/src/schema.unit.test.ts index b08540b..b94d309 100644 --- a/packages/core/graph/store-contract/src/schema.unit.test.ts +++ b/packages/core/graph/store-contract/src/schema.unit.test.ts @@ -1,7 +1,12 @@ -import { modelSchema, validate } from "@exadev/process-graph-schema"; +import { + boundActivitySchema, + modelSchema, + validate, + type Extensions, +} from "@exadev/process-graph-schema"; import { describe, expect, expectTypeOf, test } from "vitest"; import { everyFieldModel, queryModel } from "./conformance.ts"; -import { boundActivitySchema, type BoundActivity } from "./schema.ts"; +import { boundItemsSchema, type BoundItems } from "./schema.ts"; describe("fixtures", () => { test.each([ @@ -13,36 +18,25 @@ describe("fixtures", () => { }); }); -describe("boundActivitySchema", () => { - test("is the model's activity with a required source", () => { - expectTypeOf().toMatchObjectType<{ +describe("boundItemsSchema", () => { + test("holds activities exactly as the model's activity with a required source", () => { + expectTypeOf().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("accepts an activity whose id is its source", () => { - const activity = { - kind: "activity", - id: "github:o/r#1", - label: "One", - source: "github:o/r#1", - }; - - expect(boundActivitySchema.parse(activity)).toEqual(activity); - }); - - test.each([ - { name: "no source", value: { kind: "activity", id: "a", label: "A" } }, - { - name: "an id that is not its source", - value: { kind: "activity", id: "a", label: "A", source: "github:o/r#1" }, - }, - { - name: "another element kind", - value: { kind: "decision", id: "d", label: "D" }, - }, - ])("rejects $name", ({ value }) => { - expect(boundActivitySchema.safeParse(value).success).toBe(false); + test("holds activities parsed by graph-schema's boundActivitySchema itself", () => { + expect(boundItemsSchema.unwrap().shape.activities.unwrap().element).toBe( + boundActivitySchema, + ); }); }); diff --git a/packages/core/graph/sync/src/contracts.unit.test.ts b/packages/core/graph/sync/src/contracts.unit.test.ts new file mode 100644 index 0000000..37841e6 --- /dev/null +++ b/packages/core/graph/sync/src/contracts.unit.test.ts @@ -0,0 +1,158 @@ +import { + syncPageSchema, + type SyncPage, +} from "@exadev/process-graph-source-contract"; +import { + boundItemsSchema, + type BoundItems, +} from "@exadev/process-graph-store-contract"; +import { describe, expect, expectTypeOf, test } from "vitest"; +import { z } from "zod"; +import { pageOf } from "./test-support/fixtures.ts"; + +/** The activity schema of a source's sync page. */ +const pageActivitySchema = syncPageSchema + .unwrap() + .shape.activities.unwrap().element; + +/** The activity schema of a store's upserted items. */ +const itemsActivitySchema = boundItemsSchema + .unwrap() + .shape.activities.unwrap().element; + +/** An activity carrying every field the model gives an activity. */ +const everyField = { + kind: "activity", + id: "github:o/r#1", + source: "github:o/r#1", + label: "One", + actor: "github:o/r", + action: "review", + extensions: { swimlane: { lane: 2, note: ["a", null, true] } }, + affects: ["order"], + consumes: ["spec"], + produces: ["build"], +}; + +const emptyPage = { + participants: [], + transitions: [], + relations: [], + overlay: {}, + gaps: [], +}; + +const emptyItems = { + participants: [], + transitions: [], + overlay: {}, +}; + +describe("a sync page's activities and a store's upserted activities", () => { + test("have the same inferred type", () => { + expectTypeOf().toEqualTypeOf< + BoundItems["activities"][number] + >(); + }); + + test("have the same JSON Schema, an activity with a required non-empty source", () => { + const page = z.toJSONSchema(pageActivitySchema, { io: "input" }); + + expect(z.toJSONSchema(itemsActivitySchema, { io: "input" })).toStrictEqual( + page, + ); + expect(page).toMatchObject({ + type: "object", + additionalProperties: false, + required: ["kind", "id", "label", "source"], + properties: { + kind: { type: "string", const: "activity" }, + id: { type: "string" }, + label: { type: "string" }, + source: { type: "string", minLength: 1 }, + actor: { type: "string" }, + action: { type: "string" }, + affects: { type: "array", items: { type: "string" } }, + consumes: { type: "array", items: { type: "string" } }, + produces: { type: "array", items: { type: "string" } }, + }, + }); + }); + + test.each([ + { name: "an activity whose id is its source", value: everyField }, + { + name: "an activity with only its required fields", + value: { kind: "activity", id: "a:1", source: "a:1", label: "A" }, + }, + ])("both accept $name unchanged", ({ value }) => { + expect( + syncPageSchema.parse({ ...emptyPage, activities: [value] }).activities, + ).toStrictEqual([value]); + expect( + boundItemsSchema.parse({ ...emptyItems, activities: [value] }).activities, + ).toStrictEqual([value]); + }); + + test.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: { kind: "activity", id: "a:1", source: "a:2", label: "A" }, + }, + { + name: "another element kind", + value: { kind: "decision", id: "a:1", source: "a:1", label: "A" }, + }, + { + name: "a field the model does not define", + value: { ...everyField, url: "https://example.com/issues/1" }, + }, + ])("both reject $name", ({ value }) => { + expect( + syncPageSchema.safeParse({ ...emptyPage, activities: [value] }).success, + ).toBe(false); + expect( + boundItemsSchema.safeParse({ ...emptyItems, activities: [value] }) + .success, + ).toBe(false); + }); +}); + +test("a sync page's items pass boundItemsSchema unchanged", () => { + const page = syncPageSchema.parse( + pageOf([ + { + id: "github:acme/api#1", + label: "Parent", + repository: "github:acme/api", + state: "open", + assignees: ["github:@ada"], + }, + { + id: "github:acme/api#2", + label: "Child", + repository: "github:acme/api", + state: "done", + blockedBy: ["github:acme/api#1"], + parent: "github:acme/api#1", + }, + ]), + ); + const items = { + participants: page.participants, + activities: page.activities, + transitions: page.transitions, + relations: page.relations, + overlay: page.overlay, + }; + + expect(page.activities).toHaveLength(2); + expect(page.transitions).toHaveLength(1); + expect(page.relations).not.toEqual([]); + expect(boundItemsSchema.parse(items)).toStrictEqual(items); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1830478..efa2b4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -508,6 +508,9 @@ importers: packages/adapters/graph/source-github-issues: dependencies: + '@exadev/process-graph-schema': + specifier: workspace:* + version: link:../../../core/graph/schema '@exadev/process-graph-source-contract': specifier: workspace:* version: link:../../../core/graph/source-contract