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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Changelog

## 0.14.0 - 2026-08-18

- Add `runtime.enqueueInternalMessage()` and
`runtime.enqueueInternalMessageInTransaction(connection, options)`, public
entry points for a host package to enqueue an `internal`-delivery-mode
actor message without going through user authorization. The
transaction-scoped variant accepts a caller-supplied `DatabaseConnection`
so the enqueue can commit atomically alongside other writes in the same
transaction; call the new `runtime.announceInternalMessage(message)` after
that transaction commits to wake worker roles the same way a normal
enqueue does.
- Add `runtime.snapshotWithIncarnation(reference)`, returning the same
authorized fields as `snapshot()` alongside the read instance's
`instanceId`, `revision`, and `createdAtMs`, computed from one shared read.
`instanceId` is a random UUID, not a monotonically increasing value, so a
caller that needs to detect actor recreation (a new incarnation
superseding an old one, regardless of revision) should fence on
`createdAtMs` rather than comparing `instanceId` values directly.
`createdAtMs` orders incarnations at millisecond granularity; destroying
and recreating the same actor identity within the same millisecond
produces two incarnations a caller cannot order by `createdAtMs` alone —
see `docs/correctness.md`.

## 0.13.3 - 2026-08-18

- Lower the supported Node.js floor from 24.15.0 to 24.4.0. Node.js 24.4.0 is
Expand Down
10 changes: 10 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ generic signatures; this index explains the supported role of every export.
default.
- `SolidObjectsRuntime`: installation, registration, supervision, and manager
owner. The normal lifecycle is `install()`, `run(signal)`, then `close()`.
`snapshotWithIncarnation(reference)` returns the same authorized fields as
`snapshot()` alongside the read instance's `instanceId`, `revision`, and
`createdAtMs`, computed from the identical read so a caller can fence a
derived write (for example a downstream projection) against a stale or
superseded actor incarnation. `createdAtMs` orders incarnations at
millisecond granularity; see
[Limitations and non-goals](correctness.md#limitations-and-non-goals) for
the same-millisecond boundary.
- `Actor`: base class providing `ref()`, `actorId`, `currentMessage`,
`observables()`, `reject()`, `emit()`, `commitAction()`, `schedule()`,
`sendTo()`, and protected lifecycle hooks.
Expand All @@ -25,6 +33,8 @@ generic signatures; this index explains the supported role of every export.
- `ActorClass`, `ActorReference`, `ActorMessageSender`, `ActorSnapshot`,
`ActorOperationNames`, `ActorQueryNames`, `StagedOperations`, and
`ScheduledOperations`: inferred actor-class and fluent-dispatch types.
- `SnapshotWithIncarnation`: the `{ snapshot, instanceId, revision,
createdAtMs }` shape returned by `SolidObjectsRuntime.snapshotWithIncarnation`.
- `MessageReference`: immutable durable message identity with `id`,
`requestId`, actor identity, `sequence`, `status()`, `result()`, and `wait()`.
- `InvocationOptions`, `AsyncInvocationOptions`, `SnapshotOptions`, and
Expand Down
8 changes: 8 additions & 0 deletions docs/correctness.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@
placement, capacity, database backups, and database failover.
- Redis and PostgreSQL notifications reduce wake-up latency but do not replace
durable polling or become a source of truth.
- `snapshotWithIncarnation`'s `createdAtMs` orders actor incarnations at
millisecond granularity, the same precision every adapter stores
`created_at_ms` at. Destroying and recreating the same actor identity
within the same database-clock millisecond produces two incarnations with
an equal `createdAtMs`; a caller fencing a derived write on it cannot
distinguish which of the two is current in that narrow case. `instanceId`
still changes and detects that a recreation happened; it is a random UUID
and carries no order of its own.
- Large documents, bulk pipelines, globally placed edge state, and global
counters are outside the intended workload. Prefer an ordinary row
transaction when it completely enforces the invariant.
11 changes: 10 additions & 1 deletion docs/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,23 @@ Reference: Ruby `solid_objects` 0.13.3. The JavaScript package began at the
Ruby design's `0.12` capability generation; that version number did not imply
earlier JavaScript releases.

The Node `0.13.3` implementation has capability parity with that reference. Its
The Node `0.14.0` implementation has capability parity with that reference. Its
relational runtime, correctness boundaries, administration, diagnostics,
operator dashboard, realtime projections, browser behavior, and supported
adapters have native equivalents. Rails-specific rendering surfaces are
replaced by transport- and framework-neutral JavaScript APIs. The partial guard
row and the shared planned result-lookup row below are explicit scope
boundaries, not missing Ruby capabilities.

`0.14.0` also adds `runtime.enqueueInternalMessage()`,
`runtime.enqueueInternalMessageInTransaction()`, and
`runtime.snapshotWithIncarnation()`. These are Node-only integration points
for a host package (such as a future commercial scaling layer), not ported
Ruby capabilities: Ruby's equivalent primitives (`SolidObjects::Mailbox#enqueue`,
`ActorSnapshot`) are already reachable in-process without a dedicated public
API, since Ruby has no package-privacy boundary between a gem and its own
dependents the way Node's `exports` map enforces one.

## Status vocabulary

- **Native**: the TypeScript runtime provides the capability in a Node-native
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.13.3",
"version": "0.14.0",
"description": "Race-free realtime state per application identity, backed by your SQL database",
"type": "module",
"license": "MIT",
Expand Down
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ export {
type ReminderIntent,
type ReminderOptions,
} from "./actor.js"
export { configure, createRuntime, SolidObjectsRuntime } from "./runtime.js"
export {
configure,
createRuntime,
SolidObjectsRuntime,
type SnapshotWithIncarnation,
} from "./runtime.js"
export { VERSION } from "./version.js"
export { guardApplicationDatabase } from "./application-database.js"
export { runCli, type CliRunOptions } from "./cli.js"
Expand Down
96 changes: 95 additions & 1 deletion src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ import { waitFor, Worker } from "./worker.js"
import { EffectWorker } from "./effect-worker.js"
import type { WakeUpRole } from "./wake-up.js"
import { withDatabaseDeadline } from "./database/deadline.js"
import type { DatabaseConnection } from "./database/types.js"

interface RegisteredActor {
actorClass: ActorClass
Expand All @@ -124,6 +125,13 @@ interface RegisteredActor {
queries: ReadonlySet<string>
}

export interface SnapshotWithIncarnation<ActorType extends Actor> {
snapshot: ActorSnapshot<ActorType>
instanceId: string
revision: string
createdAtMs: number
}

interface PayloadProjectionSnapshot {
state: JsonObject
instanceId: string
Expand Down Expand Up @@ -683,6 +691,27 @@ export class SolidObjectsRuntime {
argumentsValue: {},
authorizationContext: options.authorizationContext,
})
const built = await this.buildSnapshotWithIncarnation(reference)
return built.snapshot
}

async snapshotWithIncarnation<ActorType extends Actor>(
reference: ActorReferenceCore<ActorType>,
options: SnapshotOptions = {},
): Promise<SnapshotWithIncarnation<ActorType>> {
await this.authorize({
kind: "query",
reference,
operation: "__snapshot__",
argumentsValue: {},
authorizationContext: options.authorizationContext,
})
return this.buildSnapshotWithIncarnation(reference)
}

private async buildSnapshotWithIncarnation<ActorType extends Actor>(
reference: ActorReferenceCore<ActorType>,
): Promise<SnapshotWithIncarnation<ActorType>> {
const registered = this.fetchActor(reference.actorType)
const instance = await this.repository.findInstanceByIdentity(
reference.actorType,
Expand Down Expand Up @@ -718,7 +747,12 @@ export class SolidObjectsRuntime {
) {
throw new QueryMutatedState("snapshot getters must not mutate actor state or stage work")
}
return readonlyCopy(snapshot) as ActorSnapshot<ActorType>
return {
snapshot: readonlyCopy(snapshot) as ActorSnapshot<ActorType>,
instanceId: instance?.id ?? "0",
revision: String(instance?.state_revision ?? 0),
createdAtMs: Number(instance?.created_at_ms ?? 0),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Timestamp fence aliases incarnations

When an actor is destroyed and recreated within the same database-clock millisecond, both incarnations receive the same createdAtMs, so a stale derived write can pass the advertised incarnation fence and overwrite or publish data as though it came from the current incarnation.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/runtime.ts
Line: 754

Comment:
**Timestamp fence aliases incarnations**

When an actor is destroyed and recreated within the same database-clock millisecond, both incarnations receive the same `createdAtMs`, so a stale derived write can pass the advertised incarnation fence and overwrite or publish data as though it came from the current incarnation.

---

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

}
}

async subscriptionSnapshot(options: {
Expand Down Expand Up @@ -1443,6 +1477,66 @@ export class SolidObjectsRuntime {
}
}

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 Generic default violates type rule

The new Result = unknown annotation violates the repository requirement to use concrete TypeScript types, forcing consumers that omit the generic to narrow or assert the message result. Replace it with the concrete result type supported by message result storage.

Rule Used: What: Disallow the use of unknown in TypeScript ... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/runtime.ts
Line: 1478

Comment:
**Generic default violates type rule**

The new `Result = unknown` annotation violates the repository requirement to use concrete TypeScript types, forcing consumers that omit the generic to narrow or assert the message result. Replace it with the concrete result type supported by message result storage.

**Rule Used:** What: Disallow the use of `unknown` in TypeScript ... ([source](https://app.greptile.com/craftsmanfounder/-/custom-context?memory=af673ae0-6488-4c8b-8b4a-1bfea4eb4de7))

---

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!


async enqueueInternalMessage<Result = unknown>(options: {
actorType: string
actorId: string
operation: string
argumentsValue?: JsonObject
idempotencyKey?: string
}): Promise<MessageReference<Result>> {
const { actorType, actorId, operation, argumentsValue = {}, idempotencyKey } = options
const actor = this.fetchActor(actorType)
if (!actor.operations.has(operation)) {
throw new UnknownOperation(`unknown operation ${JSON.stringify(operation)}`)
}
const argumentsObject = jsonObject(argumentsValue, { maxBytes: this.settings.maxPayloadBytes })
const message = await this.repository.enqueue({
actorType,
actorId,
operation,
deliveryMode: "internal",
arguments: argumentsObject,
initialState: initialStateFor(actor.definition),
stateVersion: actor.definition.stateVersion,
...(idempotencyKey === undefined ? {} : { idempotencyKey }),
})
this.announceInternalMessage(message)
return this.messageReferenceFromRow<Result>(message)
}

async enqueueInternalMessageInTransaction(
connection: DatabaseConnection,
options: {
actorType: string
actorId: string
operation: string
argumentsValue?: JsonObject
idempotencyKey?: string
},
): Promise<MessageRow> {
const { actorType, actorId, operation, argumentsValue = {}, idempotencyKey } = options
const actor = this.fetchActor(actorType)
if (!actor.operations.has(operation)) {
throw new UnknownOperation(`unknown operation ${JSON.stringify(operation)}`)
}
const argumentsObject = jsonObject(argumentsValue, { maxBytes: this.settings.maxPayloadBytes })
return this.repository.enqueueInTransaction(connection, {
actorType,
actorId,
operation,
deliveryMode: "internal",
arguments: argumentsObject,
initialState: initialStateFor(actor.definition),
stateVersion: actor.definition.stateVersion,
...(idempotencyKey === undefined ? {} : { idempotencyKey }),
})
}

announceInternalMessage(message: MessageRow): void {
this.emitInstrumentation("message.enqueued", messageInstrumentation(message))
this.wakeUp("actors")
}

private async enqueue<Result>(options: {
reference: ActorReferenceCore<Actor>
operation: string
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.13.3"
export const VERSION = "0.14.0"
114 changes: 114 additions & 0 deletions test/internal-messages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { afterEach, describe, expect, it } from "vitest"
import { Actor } from "../src/actor.js"
import { createRuntime, type SolidObjectsRuntime } from "../src/runtime.js"
import type { SolidObjectsConfiguration } from "../src/configuration.js"
import { sqlite } from "../src/database/sqlite.js"
import { UnknownOperation } from "../src/errors.js"

class Counter extends Actor {
static override readonly actorType = "Counter"

count = 0

increment({ amount = 1 }: { amount?: number } = {}): number {
this.count += amount
return this.count
}
}

let runtime: SolidObjectsRuntime | undefined

afterEach(async () => {
await runtime?.close()
runtime = undefined
})

describe("enqueueInternalMessage", () => {
it("processes an internal message even when authorizeMessage rejects every request", async () => {
runtime = createRuntime(configuredSettings({ authorizeMessage: () => false }))
runtime.register(Counter)
await runtime.install()

await runtime.enqueueInternalMessage({
actorType: Counter.actorType,
actorId: "c1",
operation: "increment",
argumentsValue: { amount: 5 },
})

expect(await runtime.worker().runUntilIdle()).toBe(1)
await expect(runtime.ref(Counter, "c1").snapshot()).resolves.toMatchObject({ count: 5 })
})

it("still rejects an operation the actor does not define", async () => {
runtime = createRuntime(configuredSettings())
runtime.register(Counter)
await runtime.install()

await expect(
runtime.enqueueInternalMessage({
actorType: Counter.actorType,
actorId: "c1",
operation: "missing",
}),
).rejects.toBeInstanceOf(UnknownOperation)
})
})

describe("enqueueInternalMessageInTransaction", () => {
it("enqueues inside a caller-supplied transaction and runs after announce", async () => {
runtime = createRuntime(configuredSettings())
runtime.register(Counter)
await runtime.install()
const activeRuntime = runtime

const message = await activeRuntime.settings.database.transaction((connection) =>
activeRuntime.enqueueInternalMessageInTransaction(connection, {
actorType: Counter.actorType,
actorId: "c1",
operation: "increment",
argumentsValue: { amount: 3 },
}),
)
activeRuntime.announceInternalMessage(message)

expect(await activeRuntime.worker().runUntilIdle()).toBe(1)
await expect(activeRuntime.ref(Counter, "c1").snapshot()).resolves.toMatchObject({ count: 3 })
})

it("rolls back with the caller's transaction if the surrounding work fails", async () => {
runtime = createRuntime(configuredSettings())
runtime.register(Counter)
await runtime.install()
const activeRuntime = runtime

await expect(
activeRuntime.settings.database.transaction(async (connection) => {
await activeRuntime.enqueueInternalMessageInTransaction(connection, {
actorType: Counter.actorType,
actorId: "c1",
operation: "increment",
argumentsValue: { amount: 3 },
})
throw new Error("caller failed after enqueueing")
}),
).rejects.toThrow("caller failed after enqueueing")

expect(await activeRuntime.worker().runUntilIdle()).toBe(0)
})
})

function configuredSettings(
overrides: Partial<SolidObjectsConfiguration> = {},
): SolidObjectsConfiguration {
return {
database: sqlite({ path: ":memory:" }),
authorizeMessage: () => true,
authorizeQuery: () => true,
authorizeDestroy: () => true,
pollingIntervalMilliseconds: 1,
syncPollingIntervalMilliseconds: 1,
maxAttempts: 2,
...overrides,
}
}
3 changes: 2 additions & 1 deletion test/process-administration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Actor } from "../src/actor.js"
import { sqlite } from "../src/database/sqlite.js"
import { Unauthorized } from "../src/errors.js"
import { configure, type SolidObjectsRuntime } from "../src/runtime.js"
import { VERSION } from "../src/version.js"

class ProcessActor extends Actor {
static override readonly actorType = "ProcessActor"
Expand Down Expand Up @@ -49,7 +50,7 @@ describe("process administration", () => {
hostProcessId: process.pid,
metadata: {
nodeVersion: process.version,
solidObjectsVersion: "0.13.3",
solidObjectsVersion: VERSION,
},
shutdownState: "running",
shutdownRequestedAt: null,
Expand Down
Loading