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

## 0.14.6 - 2026-09-03

- Poll effects, reminders, and broadcasts through ordered indexes installed by
schema migration 8. PostgreSQL and MySQL claim one row with
`FOR UPDATE SKIP LOCKED`; candidate probes avoid joins and load actor identity
by primary key after the claim. SQLite keeps its serialized transaction path.
The broadcast revision guard has its own
`(instance_id, state_revision, status)` index instead of rescanning the outbox
for every candidate. Claim transactions use read committed isolation on
PostgreSQL and MySQL so preceding recovery work cannot turn row skipping into
InnoDB gap-lock contention.
- Replace bulk broadcast and reminder recovery updates with separate available
and stale probes. Compare category heads without locking them, lock only the
oldest candidate by primary key, and retry past work already locked by another
claimant. This preserves global delivery order, concurrent progress,
per-instance broadcast revision order, stale-process recovery, and
at-least-once delivery.
- On 50,000 production-shaped rows, SQLite, PostgreSQL 18, and MySQL 8.4 all
move from scans and explicit sorts to ordered index probes. The worst SQLite
broadcast probe fell from 944 ms to 0.018 ms; PostgreSQL's probes finish in
0.02-0.09 ms and MySQL's in 0.10-1.2 ms.

## 0.14.5 - 2026-08-29

- Record the Ruby state warning in the parity ledger. The row said the Ruby gem
Expand Down
8 changes: 6 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,12 @@ rows for `preview()` and rows actually deleted for `prune()`.
`InstrumentationEvent` type the host integration contract. `JsonPrimitive`,
`JsonValue`, `JsonObject`, `DeepReadonly`, `ActorIdentifier`, `MessageContext`,
`MessageStatus`, and `Logger` are shared types. `Database`,
`DatabaseConnection`, `DatabaseFamily`, and `RunResult` support custom database
and commit-action integration.
`DatabaseConnection`, `DatabaseFamily`, `DatabaseTransactionOptions`, and
`RunResult` support custom database and commit-action integration. A custom
PostgreSQL or MySQL adapter must honor
`transaction(callback, { isolationLevel: "read_committed" })`; outbox claiming
uses that isolation with row locks to avoid InnoDB gap-lock contention. SQLite
adapters may treat the option as their ordinary serialized transaction.

`BroadcastEvent.observables` contains changed value-broadcast projections.
`BroadcastEvent.invalidations` contains changed invalidation-only names. The
Expand Down
18 changes: 9 additions & 9 deletions docs/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,15 @@ such boundary between a gem and its dependents.

## Databases and wake-up

| Capability | Status | TypeScript shape or remaining work |
| ------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| SQLite | Native | Uses built-in `node:sqlite`, serialized process-local access, bounded transient writer retries, foreign keys, strict tables, database time, and deadline-bounded access and lock waits. |
| PostgreSQL | Native | Optional `pg` 8.23 peer, bounded pooling, 64-bit schema, row-locked sequences, server checks, and deadline-bounded pool, statement, and lock waits. |
| MySQL | Native | Optional `mysql2` 3.23 peer, bounded pooling, InnoDB schema, row-locked sequences, scoped deadlock retry, and deadline-bounded pool, query, and lock waits. Ruby also tests a second client, `trilogy`; Node has no comparable second MySQL client, so only `mysql2` is tracked here. |
| Durable polling fallback | Native | Every role progresses without a notification service. |
| In-process wake-up | Native | A generation-based default adapter prevents claim-to-wait signal loss; commits wake role-specific waiters and polling remains the fallback. |
| PostgreSQL wake-up | Native | `database.wakeUp()` uses one dedicated event-driven client, role-specific `LISTEN/NOTIFY`, generation fencing, reconnectable listeners, and durable polling fallback. |
| Redis wake-up | Native | An optional `redis` peer provides role-specific Pub/Sub over separate lazy publisher/subscriber connections, with bounded failures and durable polling fallback. |
| Capability | Status | TypeScript shape or remaining work |
| ------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| SQLite | Native | Uses built-in `node:sqlite`, serialized process-local access, bounded transient writer retries, foreign keys, strict tables, database time, and deadline-bounded access and lock waits. |
| PostgreSQL | Native | Optional `pg` 8.23 peer, bounded pooling, 64-bit schema, row-locked sequences, server checks, and deadline-bounded pool, statement, and lock waits. |
| MySQL | Native | Optional `mysql2` 3.23 peer, bounded pooling, InnoDB schema, row-locked sequences, scoped deadlock retry, and deadline-bounded pool, query, and lock waits. Ruby also tests a second client, `trilogy`; Node has no comparable second MySQL client, so only `mysql2` is tracked here. |
| Durable polling fallback | Native | Every role progresses without a notification service. Effects, reminders, and broadcasts use canonical ordered polling indexes; PostgreSQL and MySQL lock only the selected row with `FOR UPDATE SKIP LOCKED`. Broadcasts and reminders compare separate available and stale-recovery probes, preserve the oldest-first choice, and retry past candidates locked by another claimant. |
| In-process wake-up | Native | A generation-based default adapter prevents claim-to-wait signal loss; commits wake role-specific waiters and polling remains the fallback. |
| PostgreSQL wake-up | Native | `database.wakeUp()` uses one dedicated event-driven client, role-specific `LISTEN/NOTIFY`, generation fencing, reconnectable listeners, and durable polling fallback. |
| Redis wake-up | Native | An optional `redis` peer provides role-specific Pub/Sub over separate lazy publisher/subscriber connections, with bounded failures and durable polling fallback. |

Every wake-up adapter above is opt-in. Neither runtime selects one
automatically. An application that configures nothing keeps polling. Each
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.5",
"version": "0.14.6",
"description": "Race-free realtime state per application identity, backed by your SQL database",
"type": "module",
"license": "MIT",
Expand Down
5 changes: 3 additions & 2 deletions src/application-database.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { applicationWritesForbidden } from "./context.js"
import type { Database, DatabaseConnection } from "./database/types.js"
import type { Database, DatabaseConnection, DatabaseTransactionOptions } from "./database/types.js"
import { ApplicationWriteForbidden } from "./errors.js"

export function guardApplicationDatabase(database: Database): Database {
Expand Down Expand Up @@ -27,8 +27,9 @@ class GuardedApplicationDatabase implements Database {

transaction<Result>(
callback: (connection: DatabaseConnection) => Promise<Result>,
options: DatabaseTransactionOptions = {},
): Promise<Result> {
return this.database.transaction((connection) => callback(guardConnection(connection)))
return this.database.transaction((connection) => callback(guardConnection(connection)), options)
}

close(): Promise<void> {
Expand Down
11 changes: 10 additions & 1 deletion src/database/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import mysqlDriver, {
type RowDataPacket,
} from "mysql2/promise"
import type { ExecuteValues } from "mysql2"
import type { Database, DatabaseConnection, RunResult } from "./types.js"
import type {
Database,
DatabaseConnection,
DatabaseTransactionOptions,
RunResult,
} from "./types.js"
import {
acquireBeforeDatabaseDeadline,
databaseDeadlineRemainingMilliseconds,
Expand Down Expand Up @@ -152,6 +157,7 @@ export class MySQLDatabase implements Database {

async transaction<Result>(
callback: (connection: DatabaseConnection) => Promise<Result>,
options: DatabaseTransactionOptions = {},
): Promise<Result> {
return withDatabaseTransaction(this, async () => {
const deadlineActive = databaseDeadlineRemainingMilliseconds() !== undefined
Expand All @@ -160,6 +166,9 @@ export class MySQLDatabase implements Database {
(lateConnection) => lateConnection.release(),
)
try {
if (options.isolationLevel === "read_committed") {
await connection.query("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")
}
await connection.beginTransaction()
const remaining = requireDatabaseDeadlineRemaining()
if (remaining !== undefined) {
Expand Down
14 changes: 12 additions & 2 deletions src/database/postgresql.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import "../platform/node.js"
import { Pool, TypeOverrides, types, type PoolClient, type PoolConfig } from "pg"
import { postgresqlSql } from "./postgresql-sql.js"
import type { Database, DatabaseConnection, RunResult } from "./types.js"
import type {
Database,
DatabaseConnection,
DatabaseTransactionOptions,
RunResult,
} from "./types.js"
import { PostgreSQLWakeUpAdapter, type PostgreSQLWakeUpFailure } from "../wake-up/postgresql.js"
import {
acquireBeforeDatabaseDeadline,
Expand Down Expand Up @@ -154,6 +159,7 @@ export class PostgreSQLDatabase implements Database {

async transaction<Result>(
callback: (connection: DatabaseConnection) => Promise<Result>,
options: DatabaseTransactionOptions = {},
): Promise<Result> {
return withDatabaseTransaction(this, async () => {
const deadlineActive = databaseDeadlineRemainingMilliseconds() !== undefined
Expand All @@ -162,7 +168,11 @@ export class PostgreSQLDatabase implements Database {
(lateClient) => lateClient.release(),
)
try {
await client.query("BEGIN")
await client.query(
options.isolationLevel === "read_committed"
? "BEGIN ISOLATION LEVEL READ COMMITTED"
: "BEGIN",
)
const remaining = requireDatabaseDeadlineRemaining()
if (remaining !== undefined) {
await applyPostgreSQLDeadline({ client, milliseconds: remaining, scope: "transaction" })
Expand Down
5 changes: 5 additions & 0 deletions src/database/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export interface RunResult {
lastInsertId?: string
}

export interface DatabaseTransactionOptions {
isolationLevel?: "read_committed"
}

export interface DatabaseConnection {
run(sql: string, parameters?: readonly unknown[]): Promise<RunResult>
get<Row extends object>(sql: string, parameters?: readonly unknown[]): Promise<Row | undefined>
Expand All @@ -19,6 +23,7 @@ export interface Database {
connection<Result>(callback: (connection: DatabaseConnection) => Promise<Result>): Promise<Result>
transaction<Result>(
callback: (connection: DatabaseConnection) => Promise<Result>,
options?: DatabaseTransactionOptions,
): Promise<Result>
close(): Promise<void>
}
4 changes: 2 additions & 2 deletions src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,11 +202,11 @@ export class Doctor {
message: `incompatible schema identity ${wrongIdentity.schema_identity}`,
})
}
if (versions.join(",") !== "1,2,3,4,5,6,7") {
if (versions.join(",") !== "1,2,3,4,5,6,7,8") {
return check({
name: "schema",
status: "fail",
message: `expected schema migrations 1, 2, 3, 4, 5, 6; found ${versions.join(", ")}`,
message: `expected schema migrations 1, 2, 3, 4, 5, 6, 7, 8; found ${versions.join(", ")}`,
})
}
return check({
Expand Down
8 changes: 7 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,13 @@ export type {
MessageStatus,
SnapshotOptions,
} from "./types.js"
export type { Database, DatabaseConnection, DatabaseFamily, RunResult } from "./database/types.js"
export type {
Database,
DatabaseConnection,
DatabaseFamily,
DatabaseTransactionOptions,
RunResult,
} from "./database/types.js"
export {
ApplicationWriteForbidden,
ActorCallCycle,
Expand Down
Loading