diff --git a/CHANGELOG.md b/CHANGELOG.md index 0254862..8365c93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,16 @@ # Changelog -## Unreleased +## 0.14.9 - 2026-09-14 - Export effect failure/success payload types and `SerializedError` from the root and core entry points. Check SQL and Cloudflare callback construction against the same contracts without changing the delivered messages. +- Infer operation names and arguments for schedule/transmit calls inside actor + methods and check literal effect callback names. Export `ScheduledOperationsFor` + and `EffectOptions`; retain explicit dynamic-name paths and runtime validation. + Subclasses with explicit legacy `ScheduledOperations` return annotations on + `schedule` or `transmit` must update their override signatures to match the + generic Actor methods. ## 0.14.8 - 2026-09-09 diff --git a/docs/api.md b/docs/api.md index 278c3b6..b44f247 100644 --- a/docs/api.md +++ b/docs/api.md @@ -54,8 +54,11 @@ authorization, capability boundaries, and release validation. - `reference.live`: read-only live signals for an actor, enabled by the `solid-objects/signals` entry point documented below. - `ActorClass`, `ActorReference`, `ActorMessageSender`, `ActorSnapshot`, - `ActorOperationNames`, `ActorQueryNames`, `StagedOperations`, and - `ScheduledOperations`: inferred actor-class and fluent-dispatch types. + `ActorOperationNames`, `ActorQueryNames`, `StagedOperations`, + `ScheduledOperationsFor`, and `ScheduledOperations`: inferred actor-class and + fluent-dispatch types, plus the legacy dynamic scheduling map. +- `EffectOptions`: effect arguments and independently checked success/failure + callback names. Effect names themselves belong to the runtime's global registry. - `SnapshotWithIncarnation`: the `{ snapshot, instanceId, revision, createdAtMs }` shape returned by `SolidObjectsRuntime.snapshotWithIncarnation`. - `MessageReference`: immutable durable message identity with `id`, @@ -130,7 +133,7 @@ operation. If you arm one alarm per queued item, only the last one remains: // Wrong. Every entry overwrites the previous entry's alarm. add({ entry }: { entry: Entry }): void { this.entries = [...this.entries, entry] - this.schedule({ at: new Date(entry.waitUntil) }).deliver!() + this.schedule({ at: new Date(entry.waitUntil) }).deliver() } ``` @@ -140,7 +143,7 @@ own identifier for the item and names that item's alarm, so each item gets one: ```typescript add({ entry }: { entry: Entry }): void { this.entries = [...this.entries, entry] - this.schedule({ at: new Date(entry.waitUntil), key: entry.id }).deliver!() + this.schedule({ at: new Date(entry.waitUntil), key: entry.id }).deliver() } ``` @@ -213,6 +216,61 @@ The generic parameters express your application's contract; they do not add runtime validation or infer types from `registerEffect()`. Keep registered effect results and the handler's declared argument/result types in agreement. +### Typed operation references + +`schedule` and `transmit` infer this actor's operation names and arguments, including +inside actor methods and for inherited application operations. The returned +`ScheduledOperationsFor` values return `void` and preserve required, +optional, and zero-argument operation signatures. No non-null assertion is needed: + +```typescript +class ChatRun extends Actor { + generation = 0 + status = "idle" + + start({ generation }: { generation: number }): void { + this.generation = generation + this.schedule({ at: new Date(Date.now() + 60_000), key: "watchdog" }).recoverIfStuck({ + generation, + }) + this.emit("run_model", { arguments: { generation }, onFailure: "failTurn" }) + } + + recoverIfStuck({ generation }: { generation: number }): void { + if (generation !== this.generation) return + this.status = "recovering" + } + + failTurn({ error }: { error: { message: string } }): void { + this.status = error.message + } +} +``` + +Misspelled operations/callbacks, state properties, queries, and Actor infrastructure +are rejected. `emit` checks each callback independently: widening one callback to +`string` does not disable literal checking of the other. A deliberately widened +`string` callback retains runtime validation. Object properties can also widen to +`string`; preserve literals with `as const` or specialize `EffectOptions` to keep +static checking when options are stored in a variable. Effect and commit-action names remain +strings because their registries are runtime-wide; inferring registered names needs +a separate registry typing design. + +For deliberately dynamic scheduling, retain the exported legacy map explicitly: + +```typescript +const dynamicActor: Actor = this +const operations: ScheduledOperations = dynamicActor.schedule({ at: deadline }) +operations[operationName]!({ generation }) +``` + +This opts out of operation-name and argument inference and retains the existing +runtime operation checks. Direct calls, queries, and `sendTo` keep their inference. +Subclasses that override `schedule` or `transmit` with an explicit legacy +`ScheduledOperations` return annotation must update their override signatures to +match the generic Actor methods. This is a compile-time compatibility change; +runtime scheduling and transmission behavior are unchanged. + ### Runtime managers Every manager below is available as a property on `SolidObjectsRuntime`; the diff --git a/docs/parity.md b/docs/parity.md index c6e5d2d..42fa817 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -29,6 +29,13 @@ such boundary between a gem and its dependents. ## Status vocabulary +Operation-reference typing is runtime-specific: TypeScript infers scheduled and +transmitted operations from the concrete receiver, and checks literal effect +callback names. Ruby offers opt-in RBS generation from declared application types +in [solid-objects-ruby#66](https://github.com/cardmagic/solid-objects-ruby/pull/66). +Both preserve runtime operation validation and global effect/commit-action names; +this does not imply automatic TypeScript-style inference in Ruby. + - **Native**: the TypeScript runtime provides the capability in a Node-native shape. - **Partial**: the core exists, but an important Ruby guarantee or operational diff --git a/package.json b/package.json index 63c737a..49b80fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-objects", - "version": "0.14.8", + "version": "0.14.9", "description": "Race-free realtime state per application identity, backed by your SQL database", "type": "module", "license": "MIT", diff --git a/scripts/check-parameter-style.mjs b/scripts/check-parameter-style.mjs index e181ea0..fc29e20 100644 --- a/scripts/check-parameter-style.mjs +++ b/scripts/check-parameter-style.mjs @@ -29,7 +29,10 @@ function findViolations(filePath) { const violations = [] function visit(node) { - if (ts.isFunctionLike(node) && node.parameters.length > 2) { + if ( + ts.isFunctionLike(node) && + node.parameters.filter((parameter) => parameter.name.getText(source) !== "this").length > 2 + ) { const position = source.getLineAndCharacterOfPosition(node.getStart(source)) const name = node.name?.getText(source) ?? diff --git a/scripts/release-artifact-smoke.mjs b/scripts/release-artifact-smoke.mjs index 4741f4e..cb8b8e5 100644 --- a/scripts/release-artifact-smoke.mjs +++ b/scripts/release-artifact-smoke.mjs @@ -52,6 +52,30 @@ try { await run("npm", ["init", "--yes"], { cwd: projectDirectory }) await run("npm", ["install", "--ignore-scripts", tarballPath], { cwd: projectDirectory }) + await writeFile( + join(projectDirectory, "actor-operations-consumer.mts"), + await readFile(join(repositoryRoot, "test/fixtures/actor-operations-consumer.mts")), + ) + await run( + process.execPath, + [ + join(repositoryRoot, "node_modules/typescript/bin/tsc"), + "--noEmit", + "--strict", + "--noUncheckedIndexedAccess", + "--exactOptionalPropertyTypes", + "--target", + "ES2024", + "--module", + "NodeNext", + "--moduleResolution", + "NodeNext", + "--skipLibCheck", + "actor-operations-consumer.mts", + ], + { cwd: projectDirectory }, + ) + const installedPackage = JSON.parse( await readFile(join(projectDirectory, "node_modules/solid-objects/package.json"), "utf8"), ) diff --git a/src/actor.ts b/src/actor.ts index 95748e0..f415c39 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -8,6 +8,7 @@ import { createStagedOperations, type ActorReference, type ScheduledOperations, + type ScheduledOperationsFor, type StagedOperations, } from "./reference.js" import { jsonObject, normalizeJson } from "./serialization.js" @@ -52,6 +53,22 @@ export interface EffectIntent { failureOperation?: string } +export interface EffectOptions { + arguments?: Record + onSuccess?: Exclude + onFailure?: Exclude +} + +type CallbackActor = string extends Callback + ? Actor + : Actor & Record void> + +type InferredActor = Actor & + Pick< + ActorType, + Extract, keyof ActorType> + > + export interface CommitActionIntent { name: string arguments: JsonObject @@ -202,13 +219,10 @@ export abstract class Actor { }) } - emit( + emit( + this: CallbackActor> & CallbackActor>, name: string, - options: { - arguments?: Record - onSuccess?: string - onFailure?: string - } = {}, + options: EffectOptions = {}, ): void { for (const callback of [options.onSuccess, options.onFailure]) { if (callback !== undefined && !this.#operations.has(String(callback))) { @@ -223,6 +237,9 @@ export abstract class Actor { }) } + transmit( + this: Actor & Pick & (Partial | NoInfer), + ): ScheduledOperationsFor> transmit(): ScheduledOperations { return createStagedOperationMap(this.#operations, (operation, argumentsValue) => { this.#intents.effects.push({ @@ -237,6 +254,10 @@ export abstract class Actor { } /** See docs/api.md for when to give a reminder a key. */ + schedule( + this: Actor & Pick & (Partial | NoInfer), + options: ReminderOptions, + ): ScheduledOperationsFor> schedule(options: ReminderOptions): ScheduledOperations { const atMilliseconds = options.at.getTime() if (!Number.isFinite(atMilliseconds)) throw new TypeError("reminder time must be valid") diff --git a/src/index.ts b/src/index.ts index 185eb80..add7d09 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export { type ActorIntents, type CommitActionIntent, type EffectIntent, + type EffectOptions, type OutboundMessageIntent, type OutboundMessageOptions, type ObservableBroadcast, @@ -104,6 +105,7 @@ export { type ActorReference, type ActorSnapshot, type ScheduledOperations, + type ScheduledOperationsFor, type StagedOperations, } from "./reference.js" export type { diff --git a/src/reference.ts b/src/reference.ts index f23d032..6dced92 100644 --- a/src/reference.ts +++ b/src/reference.ts @@ -22,7 +22,7 @@ type DataKeys = { export type ActorOperationNames = Exclude< Extract, string>, - Extract + Extract | "onActivate" | "onDeactivate" > export type ActorQueryNames = Exclude< @@ -96,6 +96,10 @@ export interface ScheduledOperations { [operation: string]: (argumentsValue?: Record) => void } +export type ScheduledOperationsFor = { + [Key in ActorOperationNames]: StagedMethod +} + export type ActorReference = ActorReferenceCore & DirectMessages & DirectQueries diff --git a/src/version.ts b/src/version.ts index 46c3fe7..f5bce34 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.14.8" +export const VERSION = "0.14.9" diff --git a/test/actor-operations.types.ts b/test/actor-operations.types.ts new file mode 100644 index 0000000..768b517 --- /dev/null +++ b/test/actor-operations.types.ts @@ -0,0 +1,136 @@ +import { expectTypeOf } from "vitest" +import { + Actor, + type ActorReference, + type ScheduledOperations, + type ScheduledOperationsFor, +} from "../src/index.js" +import type { ScheduledOperationsFor as CoreScheduledOperationsFor } from "../src/core.js" +import { validateDefinition, type ValidatedActorDefinition } from "../src/definition.js" + +class ParentRun extends Actor { + recoverIfStuck({ generation }: { generation: number }) { + return generation + } + inheritedWatchdog() { + this.schedule({ at: new Date(0) }).recoverIfStuck({ generation: 1 }) + } +} + +export class ChatRun extends ParentRun { + static override actorType = "typed-chat" + generation = 0 + #activation = 1 + override onActivate(): void {} + tuple: [{ generation: number }] = [{ generation: 1 }] + get status() { + return "running" + } + private helper() { + return this.#activation + } + optional(argumentsValue?: { generation: number }) { + return argumentsValue?.generation + } + finish() { + return this.helper() + } + failTurn({ error }: { error: { message: string } }) { + return error.message + } + + start() { + const operations = this.schedule({ at: new Date(0), key: "watchdog" }) + expectTypeOf(operations.recoverIfStuck({ generation: 1 })).toEqualTypeOf() + operations.finish() + operations.optional() + operations.optional({ generation: 1 }) + this.transmit().recoverIfStuck({ generation: 1 }) + this.transmit().finish() + this.transmit().optional() + this.emit("run_model", { onSuccess: "finish", onFailure: "failTurn" }) + this.emit("other_effect") + this.commitAction("global_commit_action") + const dynamicCallback: string = "failTurn" + this.emit("run_model", { onFailure: dynamicCallback }) + const dynamicActor: Actor = this + const dynamicOperations: ScheduledOperations = dynamicActor.schedule({ at: new Date(0) }) + dynamicOperations[dynamicCallback]!({ generation: 1 }) + // @ts-expect-error misspelled operation + operations.recoverIfStcuk({ generation: 1 }) + // @ts-expect-error required argument + operations.recoverIfStuck() + // @ts-expect-error wrong argument value + operations.recoverIfStuck({ generation: "1" }) + // @ts-expect-error extra argument field + operations.recoverIfStuck({ generation: 1, extra: true }) + // @ts-expect-error zero-argument operation + operations.finish({ generation: 1 }) + // @ts-expect-error private methods are not operations + operations.helper() + // @ts-expect-error queries are not operations + operations.status() + // @ts-expect-error tuple state is not an operation + operations.tuple({ generation: 1 }) + // @ts-expect-error infrastructure is not an operation + operations.schedule({ at: new Date(0) }) + // @ts-expect-error public lifecycle overrides remain infrastructure + operations.onActivate() + // @ts-expect-error transmit typo + this.transmit().recoverIfStcuk({ generation: 1 }) + // @ts-expect-error transmit arguments + this.transmit().recoverIfStuck({ generation: "1" }) + // @ts-expect-error a dynamic success name does not widen failure literals + this.emit("run_model", { onSuccess: dynamicCallback, onFailure: "failTrun" }) + // @ts-expect-error a dynamic failure name does not widen success literals + this.emit("run_model", { onSuccess: "finsih", onFailure: dynamicCallback }) + // @ts-expect-error failure callback typo + this.emit("run_model", { onFailure: "failTrun" }) + // @ts-expect-error success callback typo + this.emit("run_model", { onSuccess: "finsih" }) + // @ts-expect-error query callback + this.emit("run_model", { onFailure: "status" }) + // @ts-expect-error state callback + this.emit("run_model", { onFailure: "generation" }) + // @ts-expect-error private callback + this.emit("run_model", { onFailure: "helper" }) + // @ts-expect-error infrastructure callback + this.emit("run_model", { onFailure: "schedule" }) + // @ts-expect-error public lifecycle overrides are not callbacks + this.emit("run_model", { onFailure: "onActivate" }) + } +} + +export function checkReferences(reference: ActorReference, actor: ChatRun) { + expectTypeOf(reference.recoverIfStuck({ generation: 1 })).toEqualTypeOf>() + expectTypeOf(reference.status).toEqualTypeOf>() + actor.sendTo(reference).recoverIfStuck({ generation: 1 }) + const scheduled: ScheduledOperationsFor = actor.schedule({ at: new Date(0) }) + expectTypeOf(scheduled).toEqualTypeOf>() + const definition: ValidatedActorDefinition = validateDefinition(ChatRun) + return definition +} + +export function genericActor(actor: ActorType, callback: string) { + actor.emit("dynamic_effect", { onFailure: callback }) + const operations: ScheduledOperations = actor.schedule({ at: new Date(0) }) + return operations +} + +export class ExistingOverride extends Actor { + override emit(name: string, options: { onFailure?: string } = {}) { + super.emit(name, options) + } + // @ts-expect-error broad legacy override cannot promise concrete operation keys + override schedule(options: { at: Date }): ScheduledOperations { + return super.schedule(options) + } +} + +export function scheduleConstrainedActor< + ActorType extends Actor & { recoverIfStuck(argumentsValue: { generation: number }): void }, +>(actor: ActorType): void { + actor.schedule({ at: new Date(0) }).recoverIfStuck({ generation: 1 }) + // @ts-expect-error generic receiver retains its argument constraint + actor.schedule({ at: new Date(0) }).recoverIfStuck({ generation: "1" }) +} diff --git a/test/cloudflare/worker.ts b/test/cloudflare/worker.ts index 1f4caec..a280e3a 100644 --- a/test/cloudflare/worker.ts +++ b/test/cloudflare/worker.ts @@ -63,7 +63,7 @@ export class Counter extends Actor { } arm(options: { at: number }): void { - this.schedule({ at: new Date(options.at) }).increment!() + this.schedule({ at: new Date(options.at) }).increment() } forward(options: { target: string }): void { @@ -96,7 +96,7 @@ export class Counter extends Actor { this.count = 100 this.emit("increment") this.sendTo(Counter.ref("should-not-receive")).increment() - this.schedule({ at: new Date(0) }).increment!() + this.schedule({ at: new Date(0) }).increment() throw new NonRetryableError("rollback staged work") } @@ -115,7 +115,7 @@ export class Counter extends Actor { at: new Date(options.at), everyMilliseconds: options.interval, missed: options.missed, - }).increment!() + }).increment() } effect(): void { diff --git a/test/fixtures/actor-operations-consumer.mts b/test/fixtures/actor-operations-consumer.mts new file mode 100644 index 0000000..c3cba74 --- /dev/null +++ b/test/fixtures/actor-operations-consumer.mts @@ -0,0 +1,57 @@ +import { + Actor, + type ActorReference, + type ScheduledOperationsFor, + type EffectOptions, +} from "solid-objects" +import type { ScheduledOperationsFor as CoreScheduledOperationsFor } from "solid-objects/core" + +class ParentRun extends Actor { + recoverIfStuck({ generation }: { generation: number }): number { + return generation + } +} + +export class ChatRun extends ParentRun { + generation = 0 + #active = true + private helper(): boolean { + return this.#active + } + finish(): void { + this.#active = this.helper() + } + optional(argumentsValue?: { generation: number }): void { + this.generation = argumentsValue?.generation ?? 0 + } + start(): void { + const operations = this.schedule({ at: new Date(0), key: "watchdog" }) + const result: void = operations.recoverIfStuck({ generation: 1 }) + void result + operations.finish() + operations.optional() + this.transmit().recoverIfStuck({ generation: 1 }) + this.emit("run_model", { onFailure: "finish" }) + // @ts-expect-error operation typo + operations.recoverIfStcuk({ generation: 1 }) + // @ts-expect-error argument type + operations.recoverIfStuck({ generation: "1" }) + // @ts-expect-error required argument + operations.recoverIfStuck() + // @ts-expect-error private method + operations.helper() + // @ts-expect-error callback typo + this.emit("run_model", { onFailure: "finsih" }) + // @ts-expect-error infrastructure callback + this.emit("run_model", { onSuccess: "schedule" }) + } +} + +export function checkPublicTypes(actor: ChatRun, reference: ActorReference) { + const operations: ScheduledOperationsFor = actor.schedule({ at: new Date(0) }) + const core: CoreScheduledOperationsFor = operations + const options: EffectOptions<"finish"> = { onSuccess: "finish" } + actor.emit("run_model", options) + actor.sendTo(reference).recoverIfStuck({ generation: 1 }) + return core +} diff --git a/test/mysql.test.ts b/test/mysql.test.ts index c272127..933131c 100644 --- a/test/mysql.test.ts +++ b/test/mysql.test.ts @@ -21,7 +21,7 @@ class TransmitProofCounter extends Actor { increment({ amount = 1 }: { amount?: number } = {}): number { this.count += amount this.applied = [...this.applied, amount] - this.transmit().increment!({ amount }) + this.transmit().increment({ amount }) return this.count } } @@ -52,7 +52,7 @@ class MySQLWorkflow extends Actor { start(): void { this.count += 1 this.emit("echo", { arguments: { value: "effect" }, onSuccess: "effectSucceeded" }) - this.schedule({ at: new Date(0) }).reminderFired!({ value: "reminder" }) + this.schedule({ at: new Date(0) }).reminderFired({ value: "reminder" }) } effectSucceeded({ result }: { result: string }): void { diff --git a/test/outboxes.test.ts b/test/outboxes.test.ts index aa18ecc..1a5eb18 100644 --- a/test/outboxes.test.ts +++ b/test/outboxes.test.ts @@ -103,7 +103,7 @@ class Alarm extends Actor { fired = 0 arm(): void { - this.schedule({ at: new Date(0) }).fire!() + this.schedule({ at: new Date(0) }).fire() } fire(): void { diff --git a/test/postgresql.test.ts b/test/postgresql.test.ts index 812a412..99bc2a3 100644 --- a/test/postgresql.test.ts +++ b/test/postgresql.test.ts @@ -55,7 +55,7 @@ class PostgreSQLWorkflow extends Actor { arguments: { value: "effect" }, onSuccess: "effectSucceeded", }) - this.schedule({ at: new Date(0) }).reminderFired!({ value: "reminder" }) + this.schedule({ at: new Date(0) }).reminderFired({ value: "reminder" }) } effectSucceeded({ result }: { result: string }): void { @@ -119,7 +119,7 @@ class TransmitProofCounter extends Actor { increment({ amount = 1 }: { amount?: number } = {}): number { this.count += amount this.applied = [...this.applied, amount] - this.transmit().increment!({ amount }) + this.transmit().increment({ amount }) return this.count } } diff --git a/test/reconciliation.test.ts b/test/reconciliation.test.ts index 6bfa2f1..3858ddb 100644 --- a/test/reconciliation.test.ts +++ b/test/reconciliation.test.ts @@ -24,7 +24,7 @@ class ReconciledActor extends Actor { } scheduleCheck(): void { - this.schedule({ at: new Date(Date.now() + 86_400_000) }).check!() + this.schedule({ at: new Date(Date.now() + 86_400_000) }).check() } check(): void {} diff --git a/test/reminder-administration.test.ts b/test/reminder-administration.test.ts index 64a4b7d..a07b6ac 100644 --- a/test/reminder-administration.test.ts +++ b/test/reminder-administration.test.ts @@ -11,7 +11,7 @@ class ReminderActor extends Actor { count = 0 arm({ at }: { at: string }): void { - this.schedule({ at: new Date(at) }).increment!() + this.schedule({ at: new Date(at) }).increment() } increment(): void { diff --git a/test/retention.test.ts b/test/retention.test.ts index 97ee644..5f99fcd 100644 --- a/test/retention.test.ts +++ b/test/retention.test.ts @@ -25,7 +25,7 @@ class RetentionActor extends Actor { } scheduleIncrement(): void { - this.schedule({ at: new Date(Date.now() + DAY) }).increment!() + this.schedule({ at: new Date(Date.now() + DAY) }).increment() } } diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 3abfb37..a46d3e2 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -3,6 +3,7 @@ import { Actor } from "../src/actor.js" import { configure, createRuntime, type SolidObjectsRuntime } from "../src/runtime.js" import type { SolidObjectsConfiguration } from "../src/configuration.js" import { sqlite } from "../src/database/sqlite.js" +import type { ScheduledOperations } from "../src/reference.js" import { UnknownOperation } from "../src/errors.js" import type { MessageReference } from "../src/reference.js" @@ -33,31 +34,35 @@ class Counter extends Actor { at: new Date("2030-01-02T03:04:05.000Z"), everyMilliseconds: 60_000, missed: "all", - }).increment!({ amount }) + }).increment({ amount }) } armKeyed({ item, at }: { item: string; at: number }): void { - this.schedule({ at: new Date(at), key: item }).increment!({ amount: 1 }) + this.schedule({ at: new Date(at), key: item }).increment({ amount: 1 }) } armEmptyKey(): void { - this.schedule({ at: new Date("2030-01-02T03:04:05.000Z"), key: "" }).increment!({ amount: 1 }) + this.schedule({ at: new Date("2030-01-02T03:04:05.000Z"), key: "" }).increment({ amount: 1 }) } armOversizedKey(): void { - this.schedule({ at: new Date("2030-01-02T03:04:05.000Z"), key: "k".repeat(300) }).increment!({ + this.schedule({ at: new Date("2030-01-02T03:04:05.000Z"), key: "k".repeat(300) }).increment({ amount: 1, }) } armSeparatorKey(): void { - this.schedule({ at: new Date("2030-01-02T03:04:05.000Z"), key: "group:7" }).increment!({ + this.schedule({ at: new Date("2030-01-02T03:04:05.000Z"), key: "group:7" }).increment({ amount: 1, }) } armUnknown(): void { - this.schedule({ at: new Date("2030-01-02T03:04:05.000Z") }).missing!() + const actor: Actor = this + const operations: ScheduledOperations = actor.schedule({ + at: new Date("2030-01-02T03:04:05.000Z"), + }) + operations.missing!() } } @@ -297,6 +302,25 @@ describe("actor-owned delivery", () => { }) describe("actor reminders", () => { + it("preserves dynamic callback validation before staging", () => { + const actor = new Counter("callbacks") + actor.prepare(new Set(["increment"])) + const unknownCallback: string = "missing" + + expect(() => actor.emit("effect", { onFailure: unknownCallback })).toThrow(UnknownOperation) + expect(() => actor.emit("effect", { onSuccess: unknownCallback })).toThrow(UnknownOperation) + expect(actor.hasIntents()).toBe(false) + expect(actor.emit("effect", { onSuccess: "increment", onFailure: "increment" })).toBeUndefined() + expect(actor.drainIntents().effects).toEqual([ + { + name: "effect", + arguments: {}, + successOperation: "increment", + failureOperation: "increment", + }, + ]) + }) + it("stages the selected message and its arguments", async () => { runtime = configuredRuntime() await runtime.install() diff --git a/test/transmit-fixtures.test.ts b/test/transmit-fixtures.test.ts index 6b0639f..b167c36 100644 --- a/test/transmit-fixtures.test.ts +++ b/test/transmit-fixtures.test.ts @@ -28,7 +28,7 @@ class TransmitCounter extends Actor { increment({ amount = 1 }: { amount?: number } = {}): number { this.value += amount - this.transmit().increment!({ amount }) + this.transmit().increment({ amount }) return this.value } } diff --git a/test/transmit.test.ts b/test/transmit.test.ts index abdd8eb..c7f1d1a 100644 --- a/test/transmit.test.ts +++ b/test/transmit.test.ts @@ -19,7 +19,7 @@ class TransmitCounter extends Actor { increment({ amount = 1 }: { amount?: number } = {}): number { this.count += amount - this.transmit().increment!({ amount }) + this.transmit().increment({ amount }) return this.count } } diff --git a/test/wake-up.test.ts b/test/wake-up.test.ts index 732e556..ae3eedc 100644 --- a/test/wake-up.test.ts +++ b/test/wake-up.test.ts @@ -27,7 +27,7 @@ class WakeSource extends Actor { createWork(): void { this.count += 1 this.emit("wakeEffect") - this.schedule({ at: new Date(Date.now() + 60_000) }).createWork!() + this.schedule({ at: new Date(Date.now() + 60_000) }).createWork() this.sendTo(WakeTarget.ref("target")).receive() }