Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
66 changes: 62 additions & 4 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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()
}
```

Expand All @@ -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()
}
```

Expand Down Expand Up @@ -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<ActorType>` 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
Expand Down
7 changes: 7 additions & 0 deletions docs/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 4 additions & 1 deletion scripts/check-parameter-style.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) ??
Expand Down
24 changes: 24 additions & 0 deletions scripts/release-artifact-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)
Expand Down
33 changes: 27 additions & 6 deletions src/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
createStagedOperations,
type ActorReference,
type ScheduledOperations,
type ScheduledOperationsFor,
type StagedOperations,
} from "./reference.js"
import { jsonObject, normalizeJson } from "./serialization.js"
Expand Down Expand Up @@ -52,6 +53,22 @@ export interface EffectIntent {
failureOperation?: string
}

export interface EffectOptions<Success extends string = string, Failure extends string = Success> {
arguments?: Record<string, unknown>
onSuccess?: Exclude<Success, keyof Actor | "onActivate" | "onDeactivate">
onFailure?: Exclude<Failure, keyof Actor | "onActivate" | "onDeactivate">
}

type CallbackActor<Callback extends string> = string extends Callback
? Actor
: Actor & Record<Callback, (...argumentsValue: never[]) => void>

type InferredActor<Keys extends PropertyKey, ActorType> = Actor &
Pick<
ActorType,
Extract<Exclude<Keys, keyof Actor | "onActivate" | "onDeactivate">, keyof ActorType>
>

export interface CommitActionIntent {
name: string
arguments: JsonObject
Expand Down Expand Up @@ -202,13 +219,10 @@ export abstract class Actor {
})
}

emit(
emit<const Success extends string = never, const Failure extends string = never>(
this: CallbackActor<NoInfer<Success>> & CallbackActor<NoInfer<Failure>>,
name: string,
options: {
arguments?: Record<string, unknown>
onSuccess?: string
onFailure?: string
} = {},
options: EffectOptions<Success, Failure> = {},
): void {
for (const callback of [options.onSuccess, options.onFailure]) {
if (callback !== undefined && !this.#operations.has(String(callback))) {
Expand All @@ -223,6 +237,9 @@ export abstract class Actor {
})
}

transmit<Keys extends keyof this, ActorType>(
this: Actor & Pick<this, Keys> & (Partial<ActorType> | NoInfer<this>),
): ScheduledOperationsFor<InferredActor<Keys, ActorType>>
transmit(): ScheduledOperations {
return createStagedOperationMap(this.#operations, (operation, argumentsValue) => {
this.#intents.effects.push({
Expand All @@ -237,6 +254,10 @@ export abstract class Actor {
}

/** See docs/api.md for when to give a reminder a key. */
schedule<Keys extends keyof this, ActorType>(
this: Actor & Pick<this, Keys> & (Partial<ActorType> | NoInfer<this>),
options: ReminderOptions,
): ScheduledOperationsFor<InferredActor<Keys, ActorType>>
schedule(options: ReminderOptions): ScheduledOperations {
const atMilliseconds = options.at.getTime()
if (!Number.isFinite(atMilliseconds)) throw new TypeError("reminder time must be valid")
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
type ActorIntents,
type CommitActionIntent,
type EffectIntent,
type EffectOptions,
type OutboundMessageIntent,
type OutboundMessageOptions,
type ObservableBroadcast,
Expand Down Expand Up @@ -104,6 +105,7 @@ export {
type ActorReference,
type ActorSnapshot,
type ScheduledOperations,
type ScheduledOperationsFor,
type StagedOperations,
} from "./reference.js"
export type {
Expand Down
6 changes: 5 additions & 1 deletion src/reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type DataKeys<Value> = {

export type ActorOperationNames<ActorType extends Actor> = Exclude<
Extract<FunctionKeys<ActorType>, string>,
Extract<keyof Actor, string>
Extract<keyof Actor, string> | "onActivate" | "onDeactivate"
>

export type ActorQueryNames<ActorType extends Actor> = Exclude<
Expand Down Expand Up @@ -96,6 +96,10 @@ export interface ScheduledOperations {
[operation: string]: (argumentsValue?: Record<string, unknown>) => void
}

export type ScheduledOperationsFor<ActorType extends Actor> = {
[Key in ActorOperationNames<ActorType>]: StagedMethod<ActorType[Key]>
}

export type ActorReference<ActorType extends Actor> = ActorReferenceCore<ActorType> &
DirectMessages<ActorType> &
DirectQueries<ActorType>
Expand Down
2 changes: 1 addition & 1 deletion src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const VERSION = "0.14.8"
export const VERSION = "0.14.9"
Loading