From cd39dc271c2fc6b16058e0c1dd747969a360c874 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Mon, 14 Sep 2026 11:17:05 -0700 Subject: [PATCH 1/3] feat: type actor operation references Infer concrete staged operations inside actor methods and check effect callback literals independently. Keep registry names and explicit dynamic paths available. Cover inheritance, private members, packaged declarations, and the legacy override compatibility decision in compile-only tests. Refs #46 --- CHANGELOG.md | 5 + docs/api.md | 63 +++++++++- docs/parity.md | 7 ++ scripts/check-parameter-style.mjs | 5 +- scripts/release-artifact-smoke.mjs | 24 ++++ src/actor.ts | 32 ++++- src/index.ts | 2 + src/reference.ts | 6 +- test/actor-operations.types.ts | 128 ++++++++++++++++++++ test/cloudflare/worker.ts | 6 +- test/fixtures/actor-operations-consumer.mts | 57 +++++++++ test/mysql.test.ts | 4 +- test/outboxes.test.ts | 2 +- test/postgresql.test.ts | 4 +- test/reconciliation.test.ts | 2 +- test/reminder-administration.test.ts | 2 +- test/retention.test.ts | 2 +- test/runtime.test.ts | 36 +++++- test/transmit-fixtures.test.ts | 2 +- test/transmit.test.ts | 2 +- test/wake-up.test.ts | 2 +- 21 files changed, 361 insertions(+), 32 deletions(-) create mode 100644 test/actor-operations.types.ts create mode 100644 test/fixtures/actor-operations-consumer.mts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0254862..cc8b2fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ 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. + Legacy schedule/transmit override annotations require a compatibility decision. + ## 0.14.8 - 2026-09-09 - Hold the actor instance and message claim locks through every fenced commit diff --git a/docs/api.md b/docs/api.md index 278c3b6..966be86 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,58 @@ 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. 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 need an updated override signature; that +compatibility adjustment is being reviewed separately from runtime behavior. + ### 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/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..e89d98f 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,21 @@ export interface EffectIntent { failureOperation?: string } +export interface EffectOptions { + arguments?: Record + onSuccess?: Exclude + onFailure?: Exclude +} + +type CallbackActor = Actor & + (string extends Callback ? unknown : Record unknown>) + +type InferredActor = Actor & + Pick< + ActorType, + Extract, keyof ActorType> + > + export interface CommitActionIntent { name: string arguments: JsonObject @@ -202,13 +218,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 +236,9 @@ export abstract class Actor { }) } + transmit( + this: Actor & Record & (Partial | NoInfer), + ): ScheduledOperationsFor> transmit(): ScheduledOperations { return createStagedOperationMap(this.#operations, (operation, argumentsValue) => { this.#intents.effects.push({ @@ -237,6 +253,10 @@ export abstract class Actor { } /** See docs/api.md for when to give a reminder a key. */ + schedule( + this: Actor & Record & (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/test/actor-operations.types.ts b/test/actor-operations.types.ts new file mode 100644 index 0000000..3a9ebef --- /dev/null +++ b/test/actor-operations.types.ts @@ -0,0 +1,128 @@ +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) + } +} 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() } From fd58d76756c45786251820008e4c079fa3bc0b47 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Mon, 14 Sep 2026 11:27:11 -0700 Subject: [PATCH 2/3] refactor: clarify actor inference constraints --- docs/api.md | 4 +++- src/actor.ts | 9 +++++---- test/actor-operations.types.ts | 8 ++++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/api.md b/docs/api.md index 966be86..d3188f8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -250,7 +250,9 @@ class ChatRun extends Actor { 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. Effect and commit-action names remain +`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. diff --git a/src/actor.ts b/src/actor.ts index e89d98f..f415c39 100644 --- a/src/actor.ts +++ b/src/actor.ts @@ -59,8 +59,9 @@ export interface EffectOptions } -type CallbackActor = Actor & - (string extends Callback ? unknown : Record unknown>) +type CallbackActor = string extends Callback + ? Actor + : Actor & Record void> type InferredActor = Actor & Pick< @@ -237,7 +238,7 @@ export abstract class Actor { } transmit( - this: Actor & Record & (Partial | NoInfer), + this: Actor & Pick & (Partial | NoInfer), ): ScheduledOperationsFor> transmit(): ScheduledOperations { return createStagedOperationMap(this.#operations, (operation, argumentsValue) => { @@ -254,7 +255,7 @@ export abstract class Actor { /** See docs/api.md for when to give a reminder a key. */ schedule( - this: Actor & Record & (Partial | NoInfer), + this: Actor & Pick & (Partial | NoInfer), options: ReminderOptions, ): ScheduledOperationsFor> schedule(options: ReminderOptions): ScheduledOperations { diff --git a/test/actor-operations.types.ts b/test/actor-operations.types.ts index 3a9ebef..768b517 100644 --- a/test/actor-operations.types.ts +++ b/test/actor-operations.types.ts @@ -126,3 +126,11 @@ export class ExistingOverride extends Actor { 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" }) +} From d41384dc6e201ace1523c622227a5ee0e8d3fdde Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Mon, 14 Sep 2026 19:46:22 -0700 Subject: [PATCH 3/3] chore: prepare version 0.14.9 Combine operation and payload typing release notes after rebasing onto main. Keep the legacy override compatibility impact explicit. --- CHANGELOG.md | 7 ++++--- docs/api.md | 5 +++-- package.json | 2 +- src/version.ts | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc8b2fd..8365c93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +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. - Legacy schedule/transmit override annotations require a compatibility decision. + 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 d3188f8..b44f247 100644 --- a/docs/api.md +++ b/docs/api.md @@ -267,8 +267,9 @@ 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 need an updated override signature; that -compatibility adjustment is being reviewed separately from runtime behavior. +`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 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/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"