Skip to content

fix(workflow): keep cancelled rows as entities so their GraphQL getters resolve - #35

Merged
fantasywind merged 2 commits into
Rytass:mainfrom
coollugi:fix/adhoc-cancel-preserves-entity-prototype
Sep 7, 2026
Merged

fix(workflow): keep cancelled rows as entities so their GraphQL getters resolve#35
fantasywind merged 2 commits into
Rytass:mainfrom
coollugi:fix/adhoc-cancel-preserves-entity-prototype

Conversation

@coollugi

@coollugi coollugi commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

What breaks today

Withdrawing a pending countersign answers Internal server error, while the
directive is in fact cancelled. Reproduced end to end on a deployed instance:

POST /graphql  →  200
{"errors":[{"message":"Internal server error",
  "path":["cancelAdhocDirective","targetValueJson"],
  "extensions":{"code":"INTERNAL_SERVER_ERROR"}}],"data":null}

Reading the directive back afterwards returns status: CANCELLED. The write
lands; only the reply fails. The approver sees a red toast and retries a
directive that no longer exists.

Why

cancelAdhocDirective spread the loaded row before saving it:

const cancelledDirective = await directiveRepository.save({
  ...directive,
  status: AdhocDirectiveStatusEnum.CANCELLED,
});

AdhocDirective.targetValueJson is @Field(() => String) backed by a
prototype getter (adhoc-directive.entity.ts). The spread produces a plain
object, the getter is gone, the non-nullable field cannot resolve, and Apollo
reports it as INTERNAL_SERVER_ERROR.

The sibling writes in this service already avoid it — requestAdhocCountersign
and configureAdhocNotification both go through repository.create(...), which
keeps the prototype.

Scope

Only two call sites both spread-save and return the result to a resolver:

Site Entity Getter fields Status
cancelAdhocDirective AdhocDirective 1 reproduced
cancelApprovalInstance ApprovalInstance 5 latent

The other spread-saves in this file either return void or hand back a
re-fetched entity, so they are left alone. cancelApprovalInstance has not
surfaced because its client wrapper selects only id and state, but the
mutation is reachable by any consumer, so it is fixed here as well.

The fix uses Object.assign(new XEntity(), row, { … }), already the idiom in
this file (workflow-engine.service.ts uses it for TaskEntity in three
places).

Tests

returns a cancelled ad-hoc directive that still resolves its GraphQL getters
fails on main with:

expect(received).toBeInstanceOf(expected)
Expected constructor: AdhocDirectiveEntity
Received constructor: Object

and passes with the fix. Full suite: 37 suites / 460 tests green, pnpm typecheck and pnpm lint clean (the 5 lint warnings are pre-existing and in
other files).

No regression test for cancelApprovalInstance. The instance repository
stub re-wraps its argument via Object.assign(createApprovalInstance(), entity),
restoring the prototype the production code had dropped, so such a test passes
with or without the fix — I confirmed that by reverting the fix and watching it
stay green, then deleted the test rather than ship a vacuous one. Making it
testable means teaching the shared stub to preserve what it is given, which
felt out of scope for a bug fix but is worth a look.

Affected versions

The spread has been there since cancelAdhocDirective was introduced in
1f56dbb (2026-06-05); 24 released tags carry it, up to and including
v0.13.3. There is no "just upgrade" path.

🤖 Generated with Claude Code

https://claude.ai/code/session_018STtLMQTp1GVCYFp9GPSfg

…rs resolve

`cancelAdhocDirective` spread the loaded directive into a plain object before
saving it. The saved value is returned straight to the resolver, and
`AdhocDirective.targetValueJson` is a non-nullable field backed by a prototype
getter, so the spread dropped the getter and the mutation answered
`INTERNAL_SERVER_ERROR` — after the cancellation had already been committed.

To the approver the withdraw looks like it failed while the row is in fact
`CANCELLED`, so the natural reaction is to retry a directive that is already
gone. Reproduced end to end against a deployed instance: the toast reads
"Internal server error" and the GraphQL response carries
`path: ["cancelAdhocDirective", "targetValueJson"]`, yet the directive reads
back as `CANCELLED`.

`cancelApprovalInstance` has the same shape and returns an entity with five
getter-backed JSON fields, so any caller selecting one of them hits the same
failure. Its client wrapper happens to select only `id` and `state`, which is
why it has not surfaced yet; the API is reachable by other consumers, so it is
fixed here too.

Only the directive path carries a regression test. The instance repository stub
re-wraps whatever it is handed via `Object.assign(createApprovalInstance(), …)`,
so it restores the prototype the production code had dropped and a test written
against it passes either way — verified by reverting the fix and watching it
stay green. Closing that gap means changing the shared stub, which is left out
of a bug fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018STtLMQTp1GVCYFp9GPSfg
… given

The mock rebuilt every saved row as `Object.assign(createApprovalInstance(),
entity)`, so it handed back an `ApprovalInstanceEntity` even when the caller
had spread the row into a plain object — the exact regression the cancel paths
guard against was invisible to the suite.

`save` now keeps the prototype it was handed, and `findOne` rehydrates, which
is what the database actually does: a row read back is always an entity, and
only `save` sees the shape the caller passed.

Adds the missing `cancelApprovalInstance` case. Both cancel tests were checked
by reverting each fix in turn; each one fails alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fantasywind
fantasywind merged commit 3416802 into Rytass:main Sep 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants