Skip to content

feat: cancel actor reminders - #54

Merged
cardmagic merged 10 commits into
mainfrom
feat/reminder-cancellation
Sep 22, 2026
Merged

cardmagic merged 10 commits into
mainfrom
feat/reminder-cancellation

Conversation

@cardmagic

@cardmagic cardmagic commented Sep 22, 2026

Copy link
Copy Markdown
Owner

The JavaScript counterpart of
cardmagic/solid-objects-ruby#75.

Why

schedule() accepts everyMilliseconds, so an actor can start a repeating
alarm. Nothing stopped one. A search of src/ for unschedule returned no
method, so the only way to stop a recurring reminder was to edit storage by hand
or destroy the actor.

What changed

class Subscription extends Actor {
  static override readonly actorType = "subscriptions"

  status = "trialing"
  renewal: ReminderHandle | null = null

  startTrial(): void {
    this.schedule({ at: new Date(Date.now() + 14 * DAY) }).trialExpired()
  }

  convertToPaid(): void {
    this.status = "active"
    this.unschedule("trialExpired")
    this.renewal = this.schedule({
      at: new Date(Date.now() + MONTH),
      everyMilliseconds: MONTH,
    }).chargeRenewal()
  }

  cancelled(): void {
    this.status = "cancelled"
    if (this.renewal) this.unschedule(this.renewal)
  }

  // convertToPaid cancels trialExpired because that alarm is already armed and
  // would otherwise mark a paying subscription expired. It then arms
  // chargeRenewal, which cancelled() later cancels through the stored handle.

  trialExpired(): void {
    this.status = "expired"
  }

  chargeRenewal(): void {}
}

this.unschedule(this.renewal) and this.unschedule("chargeRenewal") cancel the
same alarm. Prefer the handle when the actor already stored one, because it
cannot drift from the name that armed the reminder.

A keyed alarm cancels by the key that armed it, and unscheduleAll() cancels
every key of one operation:

class Shipment extends Actor {
  static override readonly actorType = "shipments"

  dispatch({ carrierIds }: { carrierIds: string[] }): void {
    for (const carrierId of carrierIds) {
      this.schedule({ at: new Date(Date.now() + DAY), key: carrierId }).chaseCarrier({ carrierId })
    }
  }

  shipped({ carrierId }: { carrierId: string }): void {
    this.unschedule("chaseCarrier", { key: carrierId })
  }

  stopChasing(): void {
    this.unscheduleAll("chaseCarrier")
  }

  chaseCarrier(_options: { carrierId: string }): void {}
}

Cancellations are staged in the same list as the schedules, so they apply in the
order the turn called them, commit with the state change that decided them, and
cancel nothing when a turn throws. Cancelling an alarm that does not exist is not
an error.

unschedule() and unscheduleAll() refuse an operation the actor does not
declare, throwing the UnknownOperation that schedule() already throws. A typo
cancelled nothing quietly before, which is the failure this feature exists to
prevent. A handle skips that check, because the schedule() call that produced
it was already checked.

unschedule() returns nothing. It stages an intent rather than applying one, so
an answer given at call time could be stale by the time the turn commits.

Both engines

The SQL repository deletes by the composed name, and by message_operation when
every key goes, so unscheduleAll needs no prefix match.

The Durable Objects engine needed the same work, which the type checker caught
rather than a test: src/cloudflare/engine.ts also consumes
intents.reminders. Its store deletes by reminder name, and reads its rows to
find the keys of one operation, which is cheap because one object holds one
actor.

The handle

EffectHandle is already { id: string }, a plain object that serialises into
actor state. ReminderHandle is { name: string } and gets the same treatment,
so a handle stored in state still cancels after a deactivation. Orleans
documents that its own reminder handle cannot survive an activation, which is
why that API cancels by name alone. Here the handle is a value, not a
registration.

Passing a handle together with key is a TypeError, because the handle already
names the key.

Behaviour change

schedule(...).operation() returned void. It now returns ReminderHandle.
This is the change emit made in 0.15.0. transmit() keeps the old contract
through its own StagedOperationMap type, so only scheduling is affected.

Two fixtures asserted the old contract and now assert the new one:
test/actor-operations.types.ts and
test/fixtures/actor-operations-consumer.mts. The consumer fixture is the
stronger one, because it exercises the handle and both cancellations through the
published package surface rather than through src/.

Reading the schedule

reminder() returns one armed alarm and reminders() lists every key of one
operation, matching the Ruby port:

async nextChargeAt(): Promise<number | null> {
  return (await this.reminder("chargeRenewal"))?.runAtMilliseconds ?? null
}

Both are async, because a TypeScript Actor holds no rows. It reads its own
through a reader the runtime supplies at hydration, which is one injection point
for both engines: the SQL runtime reads the reminders table for the instance, and
the Durable Objects engine reads the object's own store.

A read starts from the committed rows and applies the intents staged so far, so
an actor that schedules and then reads sees what the commit will write, and one
that cancels and then reads sees the alarm gone.

key and intervalMilliseconds are null rather than undefined. The first
draft used undefined and a test caught it: returning a ScheduledReminder
straight from an operation failed with InvalidPayload, because undefined does
not serialise. Returning one is the obvious thing to do, so the type makes it
work.

A projection has no reader and throws rather than reporting an armed alarm as
absent, which is the failure this feature exists to prevent.

The type is ScheduledReminder, not ReminderStatus, because the administration
API already exports that name for the status string.

Tests

test/reminder-cancellation.test.ts is new, 10 tests. 9 of the 10 fail with
src/ reverted to main
, confirmed by reverting.

Covered: the handle names the reminder; cancel by name; cancel by handle; a
recurring reminder stops and the scheduler then finds nothing due; cancelling an
absent reminder is not an error; a failed turn cancels nothing; cancel then
schedule in one turn leaves the new time; a malformed handle is rejected; a keyed
cancel spares its siblings; unscheduleAll spares other operations.

test/cloudflare/runtime.test.ts gains two Durable Objects tests: a cancelled
alarm never fires, and keyed cancellation followed by unscheduleAll empties the
reminder table.

Validation

Gate Result
default suite 414 passed, 30 skipped
Durable Objects 50 passed
PostgreSQL 18 50 passed
MySQL 8.4 39 passed, 7 skipped
format:check pass
check pass
build pass
test:package pass
test:recovery pass

Skip counts are unchanged from main; the suite grew by the 10 new tests.

Compatibility

No migration. New exports ReminderHandle, ReminderMutation,
UnscheduleIntent, and UnscheduleAllIntent, all documented in docs/api.md
as the documentation check requires.

A recurring reminder could be started and never stopped. schedule() now
returns a ReminderHandle, and unschedule() removes one alarm by
operation, by operation and key, or by that handle. unscheduleAll()
removes every key of one operation.

Both cancellations are staged in the same list as the schedules, so they
apply in the order the turn called them, commit with the state change
that decided them, and cancel nothing when a turn throws.

Cancellation works on both engines. The SQL repository deletes by the
composed name, or by message_operation when every key goes. The Durable
Objects store deletes by reminder name, and reads its rows to find the
keys of one operation, which is cheap because one object holds one
actor.

The handle is a plain object, as emit already returns, so it serialises
into actor state and still cancels after a deactivation.

schedule() returned void before. transmit() keeps that contract through
its own type, and the packaged consumer fixture now exercises the handle
and both cancellations through the published surface.
@greptile-apps

greptile-apps Bot commented Sep 22, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge, with two outstanding non-blocking simplification findings and no outstanding functional findings.

Findings

  1. P2 Single-use helpers add indirection
  2. P2 Cancellation adds avoidable nesting
Fix with agent prompt
### Issue 1
src/actor.ts:200-207
The repository instruction says “Multiple small functions → inline if used once.” `reminderStatusOf` introduces a separate input shape and copies nearly every field unchanged at its only call site. Its `reminderKeyOf` helper is also used once. Constructing the value directly removes both layers and the duplicated type declaration.

Before:
```typescript
view.set(
  intent.name,
  reminderStatusOf({
    name: intent.name,
    operation: intent.operation,
    runAtMilliseconds: intent.atMilliseconds,
    intervalMilliseconds: intent.intervalMilliseconds ?? null,
    missedPolicy: intent.missedPolicy,
    status: "scheduled",
  }),
)
```

After, removing both helper declarations:
```typescript
view.set(intent.name, {
  name: intent.name,
  operation: intent.operation,
  key:
    intent.name === intent.operation
      ? null
      : intent.name.slice(intent.operation.length + 1),
  runAtMilliseconds: intent.atMilliseconds,
  intervalMilliseconds: intent.intervalMilliseconds ?? null,
  missedPolicy: intent.missedPolicy,
  status: "scheduled",
  handle: { name: intent.name },
})
```

The same directive applies to `handleName`, which has one caller.

Before:
```typescript
return handleName(operationOrHandle, options.key)
```

After, removing `handleName`:
```typescript
if (options.key !== undefined) {
  throw new TypeError("a reminder handle already names its key")
}
const name = operationOrHandle?.name
if (typeof name !== "string" || name.length === 0) {
  throw new TypeError("a reminder handle returned by schedule is required")
}
return name
```

Preserve the composed-name explanation above `reminderName` when removing these helpers. This repository requirement must be satisfied before merging.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

### Issue 2
src/actor.ts:196-201
The `cancel === "all"` branch nests `if (status.operation === intent.operation)` inside its loop. This violates the repository directive to avoid nested conditionals when filtering or guard clauses can express the same logic.

Filter the matching entries first, then delete them in a loop without an inner conditional. This preserves the early return and separates selection from deletion. The repository requirement must be satisfied before merging.

```suggestion
  if (intent.cancel === "all") {
    const matching = [...view].filter(
      ([, status]) => status.operation === intent.operation,
    )
    for (const [name] of matching) view.delete(name)
    return
  }
```

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds transactional reminder cancellation and schedule inspection across SQL backends and Cloudflare Durable Objects.

  • Scheduling returns a serializable ReminderHandle; transmission retains its void return type.
  • Cancellation supports individual handles, operation/key pairs, and every reminder for an operation.
  • Reminder reads overlay staged intents on committed records and exclude completed reminders.
  • Includes regression coverage for cancellation ordering, rollback, migrated rows, claimed occurrences, and snapshot reads.

There are no changes since the previous review and no accepted new findings.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Actor schedules or cancels a reminder] --> B[Ordered reminder intents]
  B --> C{Turn outcome}
  C -->|Success| D[Commit state and reminder mutations]
  C -->|Failure| E[Discard staged mutations]
  D --> F[SQL repository]
  D --> G[Durable Object storage]
  H[Committed reminder records] --> I[Exclude completed records]
  I --> J[Apply staged intents in order]
  B --> J
  J --> K[Actor reminder read]
Loading

Reviews (11) · Last reviewed commit: "fix: do not report a spent reminder as a..."

Comment thread src/actor.ts
Comment thread src/repository.ts Outdated
Comment thread src/repository.ts
Comment thread src/actor.ts Outdated
Comment thread test/cloudflare/runtime.test.ts Outdated
Comment thread docs/api.md
Four defects from review.

transmit() kept a public overload returning ScheduledOperationsFor, so
callers saw the scheduling map after its calls started returning a
handle. Only the implementation signature had changed, which callers
never see. transmit() now has its own TransmittedOperationsFor, and both
the type test and the packaged consumer fixture assert that its calls
return nothing.

unscheduleAll compared message_operation alone. That column is null on
every row written before the keyed-reminder migration, where the name is
still the operation, so an old recurring reminder survived the cancel
and kept firing. It now compares COALESCE(message_operation, operation),
which is the fallback the five dispatch sites already use.

A cancellation could land between claimReminder and enqueueReminder.
The reload then found no row and raised LostActivation, which is not
UnknownOperation, so the scheduler rethrew and a supervised scheduler
exited. enqueueReminder now separates a cancelled reminder from a lost
claim: a row that is gone returns false and the occurrence is dropped
quietly, while a row whose claim changed still raises.

The two dispatches nested their cancellation branches inside an `in`
check. ReminderIntent now carries an absent `cancel`, so the union
discriminates on one property and both dispatches read as guard clauses.

The contract said an occurrence already claimed still runs. That was
wrong in the window this change defines: a cancellation cannot recall a
message the scheduler already wrote, but it does pre-empt a claimed
occurrence that has not been enqueued.
@cardmagic

Copy link
Copy Markdown
Owner Author

All four fixed in 3d93301. Three were real defects and I have added a regression test for each.

1. transmit() overload. Correct and the most serious. I changed only the implementation signature, which callers never see; the public overload still returned ScheduledOperationsFor, so this.transmit().finish() typed as returning a handle while the dispatch returned undefined. transmit() now has its own TransmittedOperationsFor, and both test/actor-operations.types.ts and the packaged consumer fixture assert that its calls return void.

2. message_operation fallback. Correct. The migration leaves that column null on existing rows and five dispatch sites already use message_operation ?? operation. unscheduleAll now compares COALESCE(message_operation, operation), and I confirmed the test fails with the plain column and passes with the coalesce.

One refinement to the suggestion: my first regression test nulled message_operation on a keyed row, which is a state that cannot exist, because keys arrived with that same migration. The test now models the real migrated row, where the name is still the operation.

3. The claim, cancel, enqueue window. Correct, and the consequence was worse than a dropped occurrence: LostActivation is not UnknownOperation, so runOnce rethrew and a supervised scheduler would exit. enqueueReminder now separates the two causes. A row that is gone was cancelled, so it returns false and the runtime skips the wake-up and the reminder.enqueued event. A row whose claim changed still raises.

I took your second option, cancellation winning, because that is what the Ruby port already does with next unless locked_reminder, and the two implementations should not disagree on this. The contract said a claimed occurrence still runs; it now says a cancel cannot recall an occurrence that is already a message but does pre-empt a claimed one.

4. Nested conditionals. Fixed in both dispatches. The flat form did not type-check at first, because in was doing the discrimination, so ReminderIntent now carries an absent cancel and the union discriminates on one property.

Validated: default suite 416 passed, Durable Objects 50, PostgreSQL 62, MySQL 51, plus format:check, check, build, test:package, test:cloudflare and test:recovery.

@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

Comment thread src/repository.ts Outdated
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

The two failure outcomes nested inside one check. They read as guard
clauses now, and the existence query still runs only when the reload
found nothing, so a successful enqueue makes no extra read.
@cardmagic

Copy link
Copy Markdown
Owner Author

Issue 2 is fixed in e504508. The two outcomes read as guard clauses now, and the existence query still runs only when the reload found nothing, so a successful enqueue makes no extra read.

Issues 1 and 3 were already fixed in 3d93301, the commit this review covers, so I think they carried over from the previous round rather than describing the current tree.

transmit() (src/actor.ts:320-322):

transmit<Keys extends keyof this, ActorType>(
  this: Actor & Pick<this, Keys> & (Partial<ActorType> | NoInfer<this>),
): TransmittedOperationsFor<InferredActor<Keys, ActorType>>

The public overload returns TransmittedOperationsFor, whose calls return void. Both fixtures assert it: test/actor-operations.types.ts has expectTypeOf(this.transmit().recoverIfStuck({ generation: 1 })).toEqualTypeOf<void>(), and test/fixtures/actor-operations-consumer.mts has const transmitted: void = this.transmit().recoverIfStuck({ generation: 1 }) against the published package.

unschedule() (src/actor.ts:364) returns early on the string case and uses flat guards after it, with no nesting. The Cloudflare dispatch is flat too; ReminderIntent gained an absent cancel so the union discriminates on one property, which is what the earlier in check was doing.

Gates after this change: default suite 416 passed, Durable Objects 50, PostgreSQL 28, MySQL 24, plus format:check, check, build, test:package and test:cloudflare.

@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

1 similar comment
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

The reminder helper asserted an unknown instance into a fabricated
shape and typed exec as Function, which threw away the row type. It
takes the state the callback already supplies, as the neighbouring test
does, and names the row type on exec.

The typed operation reference still said both maps return void. A
scheduled call returns a handle now and a transmitted one returns void,
so it says that.
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

The example cancelled chaseCarrier from a Subscription that never
declared or scheduled it, and used status, renewal, and MONTH without
declaring them. It is two classes now, one per case, each declaring the
operations it cancels.
schedule() already threw UnknownOperation for an operation the actor does
not declare, because the staged operation map asserts it. unschedule()
and unscheduleAll() took any string, composed a name from it, and deleted
nothing. A typo cancelled quietly and left a recurring reminder running,
which is the failure this feature exists to prevent.

Both now check the operation against the actor's declared operations and
throw the same error. A handle skips the check, because the schedule()
call that produced it was already checked.
@cardmagic

Copy link
Copy Markdown
Owner Author

Added the operation-name check, and verified the whole feature end to end in a throwaway app.

The gap. schedule() threw UnknownOperation for an operation the actor does not declare, because the staged operation map asserts it. unschedule() and unscheduleAll() took any string, composed a name from it, and deleted nothing. A typo cancelled quietly and left a recurring reminder running, which is the failure this feature exists to prevent. Both now check against the actor's declared operations and throw the same error. A handle skips the check, because the schedule() call that produced it was already checked.

End to end. pnpm pack produced a tarball, a fresh Node 24 app installed it with npm install, and drove a real runtime with a real reminder scheduler against a SQLite file, reading the solid_objects_reminders table directly through node:sqlite rather than through the library. Seventeen checks, all passing: the handle, cancel by name, cancel by handle, cancelling nothing, the unknown-operation refusal leaving the reminder armed, a malformed handle, a handle passed with a key, keyed cancel sparing its siblings, unscheduleAll sparing another operation, and a recurring reminder that fired repeatedly and then stayed stopped after the cancel. tsc --noEmit against the published types is clean.

Two things a consumer meets that are worth knowing, neither a defect:

Actor.ref() needs the default runtime, so an app wires it with configure(). createRuntime() returns an isolated runtime and its actors are reached through runtime.ref(ActorClass, id). Reaching for Actor.ref after createRuntime fails with a clear message.

A direct call that fails reports MessageFailed, so the underlying UnknownOperation is in the durable record rather than the raised error. That is existing behaviour, and it is why the test asserts the reminder survived rather than matching the message text.

Gates: default suite 417 passed, Durable Objects 50, plus format:check, check, build, test:package and test:cloudflare.

@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

reminder returns one armed alarm as a ScheduledReminder and reminders
lists every key of one operation, matching what the Ruby port already
offers. Both are async, because a TypeScript actor holds no rows and
reads its own through a reader the runtime supplies at hydration. That
is one injection point, so both engines get it: the SQL runtime reads
the reminders table for the instance, and the Durable Objects engine
reads the object's own store.

A read starts from the committed rows and applies the intents staged so
far, so an actor that schedules and then reads sees what the commit will
write, and one that cancels and then reads sees the alarm gone.

key and intervalMilliseconds are null rather than undefined. The first
draft used undefined and a test caught it: returning a ScheduledReminder
straight from an operation failed with InvalidPayload, because undefined
does not serialise. Returning one is the obvious thing to do, so the
type makes it work.

A projection has no reader and throws rather than reporting an armed
alarm as absent, which is the failure this whole feature exists to
prevent.

ScheduledReminder rather than ReminderStatus, because the
administration API already exports that name for the status string.
@cardmagic

Copy link
Copy Markdown
Owner Author

Reading the schedule is in scope now, so this matches Ruby #75.

reminder() returns one armed alarm as a ScheduledReminder and reminders() lists every key of one operation. Both are async, because a TypeScript actor holds no rows: it reads its own through a reader the runtime supplies at hydrateActor. That is one injection point, so both engines get it. The SQL runtime reads the reminders table for the instance; the Durable Objects engine reads the object's own store.

A read starts from the committed rows and applies the intents staged so far, so a schedule-then-read sees what the commit will write and a cancel-then-read sees the alarm gone.

Two decisions worth recording.

key and intervalMilliseconds are null, not undefined. The first draft used undefined and a test failed with InvalidPayload: undefined is not supported at key, because returning a ScheduledReminder straight from an operation cannot serialise. Returning one is the obvious thing a caller does, so the type now allows it.

The type is ScheduledReminder rather than ReminderStatus, because reminder-administration.ts already exports ReminderStatus as the status string union.

A projection has no reader and throws, rather than reporting an armed alarm as absent. That is the silent-wrong-answer failure the earlier rounds were all about.

Verified: default suite 422 passed, Durable Objects 51, PostgreSQL 34, MySQL 30, plus format:check, check, build, test:package and test:cloudflare.

Also re-ran the throwaway consumer app against a fresh pnpm pack tarball. Twenty-one checks now, including the four new reading ones, all passing against a live runtime and scheduler with assertions read from SQLite directly.

@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

Comment thread src/actor.ts Outdated
Comment thread src/actor.ts Outdated
Comment on lines +200 to +207
function reminderStatusOf(options: {
name: string
operation: string
runAtMilliseconds: number
intervalMilliseconds: number | null
missedPolicy: "all" | "latest"
status: ReminderStatus
}): ScheduledReminder {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Single-use helpers add indirection

The repository instruction says “Multiple small functions → inline if used once.” reminderStatusOf introduces a separate input shape and copies nearly every field unchanged at its only call site. Its reminderKeyOf helper is also used once. Constructing the value directly removes both layers and the duplicated type declaration.

Before:

view.set(
  intent.name,
  reminderStatusOf({
    name: intent.name,
    operation: intent.operation,
    runAtMilliseconds: intent.atMilliseconds,
    intervalMilliseconds: intent.intervalMilliseconds ?? null,
    missedPolicy: intent.missedPolicy,
    status: "scheduled",
  }),
)

After, removing both helper declarations:

view.set(intent.name, {
  name: intent.name,
  operation: intent.operation,
  key:
    intent.name === intent.operation
      ? null
      : intent.name.slice(intent.operation.length + 1),
  runAtMilliseconds: intent.atMilliseconds,
  intervalMilliseconds: intent.intervalMilliseconds ?? null,
  missedPolicy: intent.missedPolicy,
  status: "scheduled",
  handle: { name: intent.name },
})

The same directive applies to handleName, which has one caller.

Before:

return handleName(operationOrHandle, options.key)

After, removing handleName:

if (options.key !== undefined) {
  throw new TypeError("a reminder handle already names its key")
}
const name = operationOrHandle?.name
if (typeof name !== "string" || name.length === 0) {
  throw new TypeError("a reminder handle returned by schedule is required")
}
return name

Preserve the composed-name explanation above reminderName when removing these helpers. This repository requirement must be satisfied before merging.

Context Used: # De-AI Code Review In this review, suggest simplifying this code by removing "AI slop": Remove: - Comments that restate what the code already shows - Docstrings that just repeat the function signature - Defensive code for impossible scenarios ... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/actor.ts
Line: 200-207

Comment:
**Single-use helpers add indirection**

The repository instruction says “Multiple small functions → inline if used once.” `reminderStatusOf` introduces a separate input shape and copies nearly every field unchanged at its only call site. Its `reminderKeyOf` helper is also used once. Constructing the value directly removes both layers and the duplicated type declaration.

Before:
```typescript
view.set(
  intent.name,
  reminderStatusOf({
    name: intent.name,
    operation: intent.operation,
    runAtMilliseconds: intent.atMilliseconds,
    intervalMilliseconds: intent.intervalMilliseconds ?? null,
    missedPolicy: intent.missedPolicy,
    status: "scheduled",
  }),
)
```

After, removing both helper declarations:
```typescript
view.set(intent.name, {
  name: intent.name,
  operation: intent.operation,
  key:
    intent.name === intent.operation
      ? null
      : intent.name.slice(intent.operation.length + 1),
  runAtMilliseconds: intent.atMilliseconds,
  intervalMilliseconds: intent.intervalMilliseconds ?? null,
  missedPolicy: intent.missedPolicy,
  status: "scheduled",
  handle: { name: intent.name },
})
```

The same directive applies to `handleName`, which has one caller.

Before:
```typescript
return handleName(operationOrHandle, options.key)
```

After, removing `handleName`:
```typescript
if (options.key !== undefined) {
  throw new TypeError("a reminder handle already names its key")
}
const name = operationOrHandle?.name
if (typeof name !== "string" || name.length === 0) {
  throw new TypeError("a reminder handle returned by schedule is required")
}
return name
```

Preserve the composed-name explanation above `reminderName` when removing these helpers. This repository requirement must be satisfied before merging.

**Context Used:** # De-AI Code Review  In this review, suggest simplifying this code by removing "AI slop":  **Remove:** - Comments that restate what the code already shows - Docstrings that just repeat the function signature - Defensive code for impossible scenarios ... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4c6a1a5. Both helpers are gone and the value is constructed at its only call site inside applyReminderIntent, including the key slice that reminderKeyOf held.

Comment thread src/actor.ts
Comment on lines +221 to +226
if (intent.cancel === "all") {
for (const [name, status] of view) {
if (status.operation === intent.operation) view.delete(name)
}
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Cancellation adds avoidable nesting

The cancel === "all" branch nests if (status.operation === intent.operation) inside its loop. This violates the repository directive to avoid nested conditionals when filtering or guard clauses can express the same logic.

Filter the matching entries first, then delete them in a loop without an inner conditional. This preserves the early return and separates selection from deletion. The repository requirement must be satisfied before merging.

Suggested change
if (intent.cancel === "all") {
for (const [name, status] of view) {
if (status.operation === intent.operation) view.delete(name)
}
return
}
if (intent.cancel === "all") {
const matching = [...view].filter(
([, status]) => status.operation === intent.operation,
)
for (const [name] of matching) view.delete(name)
return
}

Context Used: # De-AI Code Review In this review, suggest simplifying this code by removing "AI slop": Remove: - Comments that restate what the code already shows - Docstrings that just repeat the function signature - Defensive code for impossible scenarios ... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/actor.ts
Line: 221-226

Comment:
**Cancellation adds avoidable nesting**

The `cancel === "all"` branch nests `if (status.operation === intent.operation)` inside its loop. This violates the repository directive to avoid nested conditionals when filtering or guard clauses can express the same logic.

Filter the matching entries first, then delete them in a loop without an inner conditional. This preserves the early return and separates selection from deletion. The repository requirement must be satisfied before merging.

```suggestion
  if (intent.cancel === "all") {
    const matching = [...view].filter(
      ([, status]) => status.operation === intent.operation,
    )
    for (const [name] of matching) view.delete(name)
    return
  }
```

**Context Used:** # De-AI Code Review  In this review, suggest simplifying this code by removing "AI slop":  **Remove:** - Comments that restate what the code already shows - Docstrings that just repeat the function signature - Defensive code for impossible scenarios ... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3d93301. unschedule() is four lines with no branches at all now, because the name resolution moved into #reminderNameOf, which returns early on the string case. The Cloudflare dispatch is two flat if blocks ending in continue.

One thing worth recording: the flat form did not type-check at first, because "cancel" in intent was doing the discrimination. ReminderIntent carries an absent cancel now, so the union discriminates on one property.

A projection hydrated its actor without a reader, so an observable that
asked what was armed threw instead of answering. The Ruby port reads
from an observable, and a test there covers it, so this side did the
same. Both Durable Objects projections and the SQL snapshot projection
now pass the reader they already have the instance for.

Record the one difference that stays. ScheduledReminder carries no
occurrence count, because the SQL backends track one and Durable Objects
does not, and reporting it for one backend only would be worse than
leaving it out.
@cardmagic

Copy link
Copy Markdown
Owner Author

Audited the two implementations against each other after adding reading. Three gaps, one real bug on this side, now fixed.

A projection had no reader, so an observable that asked what was armed threw. The Ruby port reads from an observable and has a test for it, so this was a scope gap rather than a deliberate limit. Both Durable Objects projections and the SQL snapshot projection now pass the reader they already have the instance for, and a test covers reading through a snapshot.

Operation validation in the readers was already correct here; the Ruby side was missing it and is fixed there.

One difference stays, and it is principled. Ruby's ReminderStatus carries an occurrence count because its reminders table tracks one. ScheduledReminder leaves it out, because this port also runs on Durable Objects and that engine tracks no occurrence at all. Reporting it for one backend only would be worse than leaving it out, so it is documented in docs/api.md rather than faked.

The remaining differences are language shape rather than scope: reading is async here because a TypeScript actor holds no rows, and the field names and units follow each language's conventions, runAtMilliseconds against next_run_at and intervalMilliseconds against interval_seconds.

Gates: default suite 423 passed, Durable Objects 51, plus format:check, check, build, test:package and test:cloudflare.

@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

Comment thread src/runtime.ts
Comment thread test/reminder-cancellation.test.ts Outdated
A one-shot keeps its row after it fires, as completed, and the view
returned it. A next-run lookup reported an old time rather than nothing,
and an existence check refused to re-arm an alarm that could never fire
again. The shared view now drops completed rows, so both engines agree
without either having to remember.

buildSnapshotWithIncarnation hydrated without a reader, so a snapshot
getter that read its schedule threw. The earlier test missed it because
it used an operation rather than a getter, so the read ran in an
ordinary turn. It is a getter now and asserts through the snapshot.

Inline the two single-use helpers that built a ScheduledReminder, and
replace the double assertion in the malformed-handle test with an
expected type error.
@cardmagic

Copy link
Copy Markdown
Owner Author

All four fixed in 4c6a1a5. Two were real defects and your critique of my test was correct.

Spent one-shot reported as armed. Correct, and it was in both ports. The filter is in the shared view rather than in each engine, so a future backend cannot forget it. Paused rows stay, because a paused reminder still exists and status tells the caller it will not fire until resumed. Regression test arms a due one-shot, reads it, runs the scheduler, reads again. I confirmed the same defect in Ruby with a probe and fixed it there too.

buildSnapshotWithIncarnation had no reader, and you were right that my test missed it: armedName() was an operation, so the read ran in an ordinary turn while the snapshot was only checked for existence. It is a getter now and the test asserts snapshot.armedName. Both fixes were verified by reverting them, which fails exactly those two tests.

The double assertion is replaced with @ts-expect-error on the malformed handle.

The two single-use helpers are inlined; the value is constructed at its only call site.

Gates: default suite 424 passed, Durable Objects 51, PostgreSQL 36, MySQL 32, plus format:check, check, build, test:package and test:cloudflare.

@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

1 similar comment
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

The Ruby port covers a read from on_activate and this side did not. The
test starts a runtime, arms an alarm, closes it, and starts a second one
against the same file, so onActivate runs again with the reminder
already armed. It fails when the turn reader is removed.
@cardmagic
cardmagic merged commit 4693ebe into main Sep 22, 2026
18 checks 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.

1 participant