From 1a1275faf43dd50174630bff8db1087883b49d79 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 9 Sep 2026 02:10:58 -0700 Subject: [PATCH 1/5] fix: make fenced commits atomic Lease recovery could act on stale reads while an actor commit was open. Lock the instance and claim through completion. Revalidate lease and process cleanup candidates after lock waits. --- CHANGELOG.md | 7 ++ docs/parity.md | 2 +- src/repository.ts | 188 ++++++++++++++++++++++++++++------ test/mysql.test.ts | 57 +++++++++++ test/postgresql.test.ts | 105 +++++++++++++++++++ test/support/fenced-commit.ts | 38 +++++++ 6 files changed, 363 insertions(+), 34 deletions(-) create mode 100644 test/support/fenced-commit.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a1aed2..00ed401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +- Hold the actor instance and message claim locks through every fenced commit + on PostgreSQL and MySQL. Lease recovery and stale-process cleanup revalidate + those rows after waiting, so they cannot requeue a message that completed in + the meantime. + ## 0.14.7 - 2026-09-05 - Read SQL message results and completion status in one statement so concurrent diff --git a/docs/parity.md b/docs/parity.md index 433ff0a..c36a4ca 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -48,7 +48,7 @@ is needed for the JavaScript stale-result race fix. | ------------------------------------------------------------------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Actor registry, durable identity, JSON state, and adjacent state migrations | Native | Ordinary classes, static actor types, inferred state, explicit migrations, and isolated runtime context across every actor-instance callback. | | Fluent committed calls and background delivery | Native | `await reference.operation()` and `reference.send.operation()`. | -| Ordered mailbox, sequence allocation, idempotency, retries, dead letters, leases, renewal, and fenced commits | Native | Relational ready/claimed membership tables, distinct generated request IDs and caller idempotency keys, durable history, and adapter-appropriate sequence locking. | +| Ordered mailbox, sequence allocation, idempotency, retries, dead letters, leases, renewal, and fenced commits | Native | Relational ready/claimed membership tables, distinct generated request IDs and caller idempotency keys, durable history, adapter-appropriate sequence locking, and PostgreSQL/MySQL row locks held from fence validation through commit. | | Domain rejection and strict poison ordering | Native | Rejections accept JavaScript identifier-style codes and roll back without retry; invalid codes fail terminally, while retryable failures block later operations until completion or dead-lettering. | | Bounded activation passes and hot-actor fairness | Native | Configurable turn-count and elapsed-time budgets bound each pass, then move only that actor's already-due memberships behind actors already waiting. | | Bounded claim candidate scan | Native | A configurable ordered scan continues to another ready actor when a worker loses the first candidate's lease race. | diff --git a/src/repository.ts b/src/repository.ts index 80cdf52..0d83286 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -43,6 +43,21 @@ export interface SyncDiagnosticsRecord { nowMilliseconds: number } +interface ActivationFenceRow { + activation_owner_id: string | null + activation_token: string | null + activation_generation: number | bigint + activation_expires_at_ms: number | bigint | null +} + +interface MessageClaimFenceRow { + instance_id: string + sequence: number | bigint + process_id: string + activation_token: string + activation_generation: number | bigint +} + export class Repository { constructor(private readonly settings: RuntimeSettings) {} @@ -165,22 +180,61 @@ export class Repository { const claims = await connection.all<{ message_id: string instance_id: string - sequence: number | bigint }>( - `SELECT message_id, instance_id, sequence + `SELECT message_id, instance_id FROM ${this.table("claimed_messages")} WHERE process_id = ?`, [processId], ) for (const claim of claims) { - await connection.run(`DELETE FROM ${this.table("claimed_messages")} WHERE message_id = ?`, [ - claim.message_id, - ]) + const instance = await this.lockActivationFence(connection, claim.instance_id) + if (!instance) continue + const currentClaim = await this.lockMessageClaim(connection, claim.message_id) + if ( + !currentClaim || + currentClaim.instance_id !== claim.instance_id || + currentClaim.process_id !== processId + ) { + continue + } + const removed = await connection.run( + `DELETE FROM ${this.table("claimed_messages")} + WHERE message_id = ? AND instance_id = ? AND process_id = ? AND activation_token = ? + AND activation_generation = ?`, + [ + claim.message_id, + claim.instance_id, + processId, + currentClaim.activation_token, + currentClaim.activation_generation, + ], + ) + if (removed.changes !== 1) continue await connection.run( `INSERT INTO ${this.table("ready_messages")} (message_id, instance_id, sequence, available_at_ms) VALUES (?, ?, ?, ?) ON CONFLICT(message_id) DO NOTHING`, - [claim.message_id, claim.instance_id, claim.sequence, now], + [claim.message_id, claim.instance_id, currentClaim.sequence, now], ) + if ( + instance.activation_owner_id === processId && + instance.activation_token === currentClaim.activation_token && + BigInt(instance.activation_generation) === BigInt(currentClaim.activation_generation) + ) { + await connection.run( + `UPDATE ${this.table("instances")} + SET activation_owner_id = NULL, activation_token = NULL, + activation_expires_at_ms = NULL, updated_at_ms = ? + WHERE id = ? AND activation_owner_id = ? AND activation_token = ? + AND activation_generation = ?`, + [ + now, + claim.instance_id, + processId, + currentClaim.activation_token, + currentClaim.activation_generation, + ], + ) + } } await connection.run( `UPDATE ${this.table("instances")} @@ -1921,6 +1975,35 @@ export class Repository { return " FOR UPDATE SKIP LOCKED" } + private rowLockClause(): string { + if (this.settings.database.family === "sqlite") return "" + return " FOR UPDATE" + } + + private lockActivationFence( + connection: DatabaseConnection, + instanceId: string, + ): Promise { + return connection.get( + `SELECT activation_owner_id, activation_token, activation_generation, + activation_expires_at_ms + FROM ${this.table("instances")} WHERE id = ?${this.rowLockClause()}`, + [instanceId], + ) + } + + private lockMessageClaim( + connection: DatabaseConnection, + messageId: string, + ): Promise { + return connection.get( + `SELECT instance_id, sequence, process_id, activation_token, activation_generation + FROM ${this.table("claimed_messages")} + WHERE message_id = ?${this.rowLockClause()}`, + [messageId], + ) + } + private async loadActorIdentity( connection: DatabaseConnection, instanceId: string, @@ -1961,45 +2044,84 @@ export class Repository { [now], ) for (const claim of expired) { - await connection.run(`DELETE FROM ${this.table("claimed_messages")} WHERE message_id = ?`, [ - claim.message_id, - ]) + const instance = await this.lockActivationFence(connection, claim.instance_id) + if ( + !instance || + instance.activation_expires_at_ms === null || + Number(instance.activation_expires_at_ms) > now + ) { + continue + } + const currentClaim = await this.lockMessageClaim(connection, claim.message_id) + if ( + !currentClaim || + currentClaim.instance_id !== claim.instance_id || + currentClaim.process_id !== instance.activation_owner_id || + currentClaim.activation_token !== instance.activation_token || + BigInt(currentClaim.activation_generation) !== BigInt(instance.activation_generation) + ) { + continue + } + const removed = await connection.run( + `DELETE FROM ${this.table("claimed_messages")} + WHERE message_id = ? AND instance_id = ? AND process_id = ? AND activation_token = ? + AND activation_generation = ?`, + [ + claim.message_id, + claim.instance_id, + currentClaim.process_id, + currentClaim.activation_token, + currentClaim.activation_generation, + ], + ) + if (removed.changes !== 1) continue await connection.run( `INSERT INTO ${this.table("ready_messages")}(message_id, instance_id, sequence, available_at_ms) VALUES (?, ?, ?, ?) ON CONFLICT(message_id) DO NOTHING`, - [claim.message_id, claim.instance_id, claim.sequence, now], + [claim.message_id, claim.instance_id, currentClaim.sequence, now], ) - await connection.run( + const released = await connection.run( `UPDATE ${this.table("instances")} SET activation_owner_id = NULL, activation_token = NULL, activation_expires_at_ms = NULL, - updated_at_ms = ? WHERE id = ?`, - [now, claim.instance_id], + updated_at_ms = ? + WHERE id = ? AND activation_owner_id = ? AND activation_token = ? + AND activation_generation = ? AND activation_expires_at_ms <= ?`, + [ + now, + claim.instance_id, + currentClaim.process_id, + currentClaim.activation_token, + currentClaim.activation_generation, + now, + ], ) + if (released.changes !== 1) throw new Error("expired activation could not be released") } } private async assertFence(connection: DatabaseConnection, turn: ClaimedTurn): Promise { + const instance = await this.lockActivationFence(connection, turn.instance.id) const now = await connection.nowMilliseconds() - const fence = await connection.get<{ found: number | bigint }>( - `SELECT 1 AS found FROM ${this.table("instances")} instances - JOIN ${this.table("claimed_messages")} claimed ON claimed.instance_id = instances.id - WHERE instances.id = ? AND instances.activation_owner_id = ? AND instances.activation_token = ? - AND instances.activation_generation = ? AND instances.activation_expires_at_ms > ? - AND claimed.message_id = ? AND claimed.process_id = ? AND claimed.activation_token = ? - AND claimed.activation_generation = ?`, - [ - turn.instance.id, - turn.processId, - turn.activationToken, - turn.activationGeneration, - now, - turn.message.id, - turn.processId, - turn.activationToken, - turn.activationGeneration, - ], - ) - if (!fence) throw new LostActivation("activation fence no longer matches") + if ( + !instance || + instance.activation_owner_id !== turn.processId || + instance.activation_token !== turn.activationToken || + BigInt(instance.activation_generation) !== turn.activationGeneration || + instance.activation_expires_at_ms === null || + Number(instance.activation_expires_at_ms) <= now + ) { + throw new LostActivation("activation fence no longer matches") + } + const claim = await this.lockMessageClaim(connection, turn.message.id) + if ( + !claim || + claim.instance_id !== turn.instance.id || + claim.process_id !== turn.processId || + claim.activation_token !== turn.activationToken || + BigInt(claim.activation_generation) !== turn.activationGeneration + ) { + throw new LostActivation("message claim no longer matches") + } return now } diff --git a/test/mysql.test.ts b/test/mysql.test.ts index 86f40e2..40939e6 100644 --- a/test/mysql.test.ts +++ b/test/mysql.test.ts @@ -9,6 +9,7 @@ import { withDatabaseDeadline } from "../src/database/deadline.js" import type { JsonObject } from "../src/types.js" import { createDashboard } from "../src/web/index.js" import { captureAttempt, PausingClaimDatabase } from "./support/pausing-claim-database.js" +import { deferred, delay, waitForActivationExpiration } from "./support/fenced-commit.js" const connectionString = process.env.SOLID_OBJECTS_DATABASE_URL class TransmitProofCounter extends Actor { @@ -94,6 +95,18 @@ class MySQLMigratingActor extends Actor { } } +class MySQLFencedCommitActor extends Actor { + static override readonly actorType = "MySQLFencedCommitActor" + + count = 0 + + increment(): number { + this.count += 1 + this.commitAction("pause_fenced_commit") + return this.count + } +} + let runtime: SolidObjectsRuntime | undefined let database: MySQLDatabase | undefined @@ -133,6 +146,50 @@ describe("MySQL SQL compatibility", () => { }) describeMySQL("MySQL adapter", () => { + it("keeps a fenced commit exclusive after its lease expires", async () => { + if (!connectionString) throw new Error("MySQL connection string is required") + database = mysql({ connectionString, maximumConnections: 5 }) + runtime = configure({ + database, + tableNamePrefix: "mysql_test_", + authorizeMessage: () => true, + authorizeQuery: () => true, + authorizeDestroy: () => true, + leaseDurationMilliseconds: 100, + leaseRenewalIntervalMilliseconds: 20, + logger: quietLogger, + }) + runtime.register(MySQLFencedCommitActor) + const commitStarted = deferred() + const releaseCommit = deferred() + runtime.registerCommitAction("pause_fenced_commit", async () => { + commitStarted.resolve() + await releaseCommit.promise + }) + await runtime.install() + const actorId = `fenced-${crypto.randomUUID()}` + const message = await MySQLFencedCommitActor.ref(actorId).send.increment() + const firstWorker = runtime.worker() + const firstRun = firstWorker.runOnce() + await commitStarted.promise + await waitForActivationExpiration(runtime, MySQLFencedCommitActor.actorType, actorId) + await runtime.repository.registerProcess("replacement-worker", "worker") + const replacementClaim = runtime.repository.claim("replacement-worker") + const replacementBeforeRelease = await Promise.race([ + replacementClaim.then(() => "settled" as const), + delay(50).then(() => "pending" as const), + ]) + releaseCommit.resolve() + + const [firstResult, replacement] = await Promise.all([firstRun, replacementClaim]) + await firstWorker.stop() + + expect(replacementBeforeRelease).toBe("pending") + expect(firstResult).toBe(1) + expect(replacement).toBeUndefined() + await expect(message.result()).resolves.toBe(1) + }) + it("lets concurrent effect claimants skip locked work", async () => { if (!connectionString) throw new Error("MySQL connection string is required") database = mysql({ connectionString, maximumConnections: 5 }) diff --git a/test/postgresql.test.ts b/test/postgresql.test.ts index b850d03..1ce4fa1 100644 --- a/test/postgresql.test.ts +++ b/test/postgresql.test.ts @@ -16,6 +16,7 @@ import { withDatabaseDeadline } from "../src/database/deadline.js" import type { JsonObject } from "../src/types.js" import { createDashboard } from "../src/web/index.js" import { captureAttempt, PausingClaimDatabase } from "./support/pausing-claim-database.js" +import { deferred, delay, waitForActivationExpiration } from "./support/fenced-commit.js" const connectionString = process.env.SOLID_OBJECTS_DATABASE_URL const describePostgreSQL = connectionString?.startsWith("postgresql:") ? describe : describe.skip @@ -97,6 +98,18 @@ class PostgreSQLMigratingActor extends Actor { } } +class PostgreSQLFencedCommitActor extends Actor { + static override readonly actorType = "PostgreSQLFencedCommitActor" + + count = 0 + + increment(): number { + this.count += 1 + this.commitAction("pause_fenced_commit") + return this.count + } +} + class TransmitProofCounter extends Actor { static override readonly actorType = "TransmitProofCounter" @@ -167,6 +180,98 @@ describe("PostgreSQL SQL parameters", () => { }) describePostgreSQL("PostgreSQL adapter", () => { + it("keeps a fenced commit exclusive after its lease expires", async () => { + if (!connectionString) throw new Error("PostgreSQL connection string is required") + database = postgresql({ connectionString, maximumConnections: 5 }) + runtime = configure({ + database, + tableNamePrefix: "postgresql_test_", + authorizeMessage: () => true, + authorizeQuery: () => true, + authorizeDestroy: () => true, + leaseDurationMilliseconds: 100, + leaseRenewalIntervalMilliseconds: 20, + logger: quietLogger, + }) + runtime.register(PostgreSQLFencedCommitActor) + const commitStarted = deferred() + const releaseCommit = deferred() + runtime.registerCommitAction("pause_fenced_commit", async () => { + commitStarted.resolve() + await releaseCommit.promise + }) + await runtime.install() + const actorId = `fenced-${crypto.randomUUID()}` + const message = await PostgreSQLFencedCommitActor.ref(actorId).send.increment() + const firstWorker = runtime.worker() + const firstRun = firstWorker.runOnce() + await commitStarted.promise + await waitForActivationExpiration(runtime, PostgreSQLFencedCommitActor.actorType, actorId) + await runtime.repository.registerProcess("replacement-worker", "worker") + const replacementClaim = runtime.repository.claim("replacement-worker") + const replacementBeforeRelease = await Promise.race([ + replacementClaim.then(() => "settled" as const), + delay(50).then(() => "pending" as const), + ]) + releaseCommit.resolve() + + const [firstResult, replacement] = await Promise.all([firstRun, replacementClaim]) + await firstWorker.stop() + + expect(replacementBeforeRelease).toBe("pending") + expect(firstResult).toBe(1) + expect(replacement).toBeUndefined() + await expect(message.result()).resolves.toBe(1) + }) + + it("does not requeue a commit after process cleanup waits for it", async () => { + if (!connectionString) throw new Error("PostgreSQL connection string is required") + database = postgresql({ connectionString, maximumConnections: 5 }) + runtime = configure({ + database, + tableNamePrefix: "postgresql_test_", + authorizeMessage: () => true, + authorizeQuery: () => true, + authorizeDestroy: () => true, + logger: quietLogger, + }) + runtime.register(PostgreSQLFencedCommitActor) + const commitStarted = deferred() + const releaseCommit = deferred() + runtime.registerCommitAction("pause_fenced_commit", async () => { + commitStarted.resolve() + await releaseCommit.promise + }) + await runtime.install() + const message = await PostgreSQLFencedCommitActor.ref( + `process-cleanup-${crypto.randomUUID()}`, + ).send.increment() + const firstWorker = runtime.worker() + const firstRun = firstWorker.runOnce() + await commitStarted.promise + const processCleanup = runtime.repository.stopProcess(firstWorker.processId) + const cleanupBeforeRelease = await Promise.race([ + processCleanup.then(() => "settled" as const), + delay(50).then(() => "pending" as const), + ]) + releaseCommit.resolve() + + const [firstResult] = await Promise.all([firstRun, processCleanup]) + const ready = await database.connection((connection) => + connection.get<{ found: number | bigint }>( + `SELECT 1 AS found FROM ${runtime?.repository.table("ready_messages")} + WHERE message_id = ?`, + [message.id], + ), + ) + await firstWorker.stop() + + expect(cleanupBeforeRelease).toBe("pending") + expect(firstResult).toBe(1) + expect(ready).toBeUndefined() + await expect(message.result()).resolves.toBe(1) + }) + it("does not hide broadcast work across concurrent recovery probes", async () => { if (!connectionString) throw new Error("PostgreSQL connection string is required") database = postgresql({ connectionString, maximumConnections: 5 }) diff --git a/test/support/fenced-commit.ts b/test/support/fenced-commit.ts new file mode 100644 index 0000000..142eedc --- /dev/null +++ b/test/support/fenced-commit.ts @@ -0,0 +1,38 @@ +import type { SolidObjectsRuntime } from "../../src/runtime.js" + +export function deferred(): { promise: Promise; resolve: () => void } { + let resolve = () => {} + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +export function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +export async function waitForActivationExpiration( + runtime: SolidObjectsRuntime, + actorType: string, + actorId: string, +): Promise { + const expiration = await runtime.settings.database.connection((connection) => + connection.get<{ activation_expires_at_ms: number | bigint }>( + `SELECT activation_expires_at_ms FROM ${runtime.repository.table("instances")} + WHERE actor_type = ? AND actor_id = ?`, + [actorType, actorId], + ), + ) + if (!expiration) throw new Error("active actor instance was not found") + const expiresAt = Number(expiration.activation_expires_at_ms) + const deadline = Date.now() + 2_000 + while (Date.now() < deadline) { + const now = await runtime.settings.database.connection((connection) => + connection.nowMilliseconds(), + ) + if (now > expiresAt) return + await delay(Math.min(Math.max(expiresAt - now + 1, 1), 10)) + } + throw new Error("activation lease did not expire") +} From 78287783ed6daae4ca05fffa8d168497efd1fb96 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 9 Sep 2026 02:21:38 -0700 Subject: [PATCH 2/5] test: harden fenced commit races Always release paused commits when an assertion or setup step fails. Give real-database concurrency tests enough time on slower CI runners. --- test/mysql.test.ts | 38 ++++++++++--------- test/postgresql.test.ts | 84 ++++++++++++++++++++++------------------- 2 files changed, 67 insertions(+), 55 deletions(-) diff --git a/test/mysql.test.ts b/test/mysql.test.ts index 40939e6..7904d40 100644 --- a/test/mysql.test.ts +++ b/test/mysql.test.ts @@ -172,23 +172,27 @@ describeMySQL("MySQL adapter", () => { const firstWorker = runtime.worker() const firstRun = firstWorker.runOnce() await commitStarted.promise - await waitForActivationExpiration(runtime, MySQLFencedCommitActor.actorType, actorId) - await runtime.repository.registerProcess("replacement-worker", "worker") - const replacementClaim = runtime.repository.claim("replacement-worker") - const replacementBeforeRelease = await Promise.race([ - replacementClaim.then(() => "settled" as const), - delay(50).then(() => "pending" as const), - ]) - releaseCommit.resolve() - - const [firstResult, replacement] = await Promise.all([firstRun, replacementClaim]) - await firstWorker.stop() - - expect(replacementBeforeRelease).toBe("pending") - expect(firstResult).toBe(1) - expect(replacement).toBeUndefined() - await expect(message.result()).resolves.toBe(1) - }) + try { + await waitForActivationExpiration(runtime, MySQLFencedCommitActor.actorType, actorId) + await runtime.repository.registerProcess("replacement-worker", "worker") + const replacementClaim = runtime.repository.claim("replacement-worker") + const replacementBeforeRelease = await Promise.race([ + replacementClaim.then(() => "settled" as const), + delay(50).then(() => "pending" as const), + ]) + releaseCommit.resolve() + + const [firstResult, replacement] = await Promise.all([firstRun, replacementClaim]) + await firstWorker.stop() + + expect(replacementBeforeRelease).toBe("pending") + expect(firstResult).toBe(1) + expect(replacement).toBeUndefined() + await expect(message.result()).resolves.toBe(1) + } finally { + releaseCommit.resolve() + } + }, 15_000) it("lets concurrent effect claimants skip locked work", async () => { if (!connectionString) throw new Error("MySQL connection string is required") diff --git a/test/postgresql.test.ts b/test/postgresql.test.ts index 1ce4fa1..812a412 100644 --- a/test/postgresql.test.ts +++ b/test/postgresql.test.ts @@ -206,23 +206,27 @@ describePostgreSQL("PostgreSQL adapter", () => { const firstWorker = runtime.worker() const firstRun = firstWorker.runOnce() await commitStarted.promise - await waitForActivationExpiration(runtime, PostgreSQLFencedCommitActor.actorType, actorId) - await runtime.repository.registerProcess("replacement-worker", "worker") - const replacementClaim = runtime.repository.claim("replacement-worker") - const replacementBeforeRelease = await Promise.race([ - replacementClaim.then(() => "settled" as const), - delay(50).then(() => "pending" as const), - ]) - releaseCommit.resolve() - - const [firstResult, replacement] = await Promise.all([firstRun, replacementClaim]) - await firstWorker.stop() - - expect(replacementBeforeRelease).toBe("pending") - expect(firstResult).toBe(1) - expect(replacement).toBeUndefined() - await expect(message.result()).resolves.toBe(1) - }) + try { + await waitForActivationExpiration(runtime, PostgreSQLFencedCommitActor.actorType, actorId) + await runtime.repository.registerProcess("replacement-worker", "worker") + const replacementClaim = runtime.repository.claim("replacement-worker") + const replacementBeforeRelease = await Promise.race([ + replacementClaim.then(() => "settled" as const), + delay(50).then(() => "pending" as const), + ]) + releaseCommit.resolve() + + const [firstResult, replacement] = await Promise.all([firstRun, replacementClaim]) + await firstWorker.stop() + + expect(replacementBeforeRelease).toBe("pending") + expect(firstResult).toBe(1) + expect(replacement).toBeUndefined() + await expect(message.result()).resolves.toBe(1) + } finally { + releaseCommit.resolve() + } + }, 15_000) it("does not requeue a commit after process cleanup waits for it", async () => { if (!connectionString) throw new Error("PostgreSQL connection string is required") @@ -249,28 +253,32 @@ describePostgreSQL("PostgreSQL adapter", () => { const firstWorker = runtime.worker() const firstRun = firstWorker.runOnce() await commitStarted.promise - const processCleanup = runtime.repository.stopProcess(firstWorker.processId) - const cleanupBeforeRelease = await Promise.race([ - processCleanup.then(() => "settled" as const), - delay(50).then(() => "pending" as const), - ]) - releaseCommit.resolve() - - const [firstResult] = await Promise.all([firstRun, processCleanup]) - const ready = await database.connection((connection) => - connection.get<{ found: number | bigint }>( - `SELECT 1 AS found FROM ${runtime?.repository.table("ready_messages")} - WHERE message_id = ?`, - [message.id], - ), - ) - await firstWorker.stop() + try { + const processCleanup = runtime.repository.stopProcess(firstWorker.processId) + const cleanupBeforeRelease = await Promise.race([ + processCleanup.then(() => "settled" as const), + delay(50).then(() => "pending" as const), + ]) + releaseCommit.resolve() + + const [firstResult] = await Promise.all([firstRun, processCleanup]) + const ready = await database.connection((connection) => + connection.get<{ found: number | bigint }>( + `SELECT 1 AS found FROM ${runtime?.repository.table("ready_messages")} + WHERE message_id = ?`, + [message.id], + ), + ) + await firstWorker.stop() - expect(cleanupBeforeRelease).toBe("pending") - expect(firstResult).toBe(1) - expect(ready).toBeUndefined() - await expect(message.result()).resolves.toBe(1) - }) + expect(cleanupBeforeRelease).toBe("pending") + expect(firstResult).toBe(1) + expect(ready).toBeUndefined() + await expect(message.result()).resolves.toBe(1) + } finally { + releaseCommit.resolve() + } + }, 15_000) it("does not hide broadcast work across concurrent recovery probes", async () => { if (!connectionString) throw new Error("PostgreSQL connection string is required") From ebf62c0d48a0f8dd491e27059a03ca19bdb05a19 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 9 Sep 2026 02:21:55 -0700 Subject: [PATCH 3/5] chore: update audited test dependencies CI began rejecting newly disclosed Vitest and Sharp advisories. Use patched test tooling and keep the Sharp override limited to vulnerable transitive versions. --- package.json | 4 +- pnpm-lock.yaml | 438 +++++++++++++++++++++++--------------------- pnpm-workspace.yaml | 6 + 3 files changed, 233 insertions(+), 215 deletions(-) diff --git a/package.json b/package.json index 819b8c7..8eb2916 100644 --- a/package.json +++ b/package.json @@ -136,14 +136,14 @@ "@types/node": "^24.0.0", "@types/pg": "^8.21.0", "@types/ws": "^8.18.1", - "@vitest/coverage-v8": "^4.1.10", + "@vitest/coverage-v8": "^4.1.11", "mysql2": "^3.23.3", "pg": "^8.23.0", "prettier": "^3.9.6", "redis": "^6.2.1", "signal-polyfill": "^0.2.2", "typescript": "^5.9.0", - "vitest": "^4.1.10", + "vitest": "^4.1.11", "wrangler": "^4.129.0", "ws": "^8.21.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d63f715..11e0d4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,13 +4,16 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + sharp@<0.35.4: ^0.35.4 + importers: .: devDependencies: '@cloudflare/vitest-plugin': specifier: ^1.1.4 - version: 1.1.4(@cloudflare/workers-types@5.20260904.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10) + version: 1.1.4(@cloudflare/workers-types@5.20260904.1)(@types/node@24.13.3)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11) '@cloudflare/workers-types': specifier: ^5.20260904.1 version: 5.20260904.1 @@ -30,8 +33,8 @@ importers: specifier: ^8.18.1 version: 8.18.1 '@vitest/coverage-v8': - specifier: ^4.1.10 - version: 4.1.10(vitest@4.1.10) + specifier: ^4.1.11 + version: 4.1.11(vitest@4.1.11) mysql2: specifier: ^3.23.3 version: 3.23.3(@types/node@24.13.3) @@ -51,11 +54,11 @@ importers: specifier: ^5.9.0 version: 5.9.3 vitest: - specifier: ^4.1.10 - version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)) + specifier: ^4.1.11 + version: 4.1.11(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)) wrangler: specifier: ^4.129.0 - version: 4.129.0(@cloudflare/workers-types@5.20260904.1) + version: 4.129.0(@cloudflare/workers-types@5.20260904.1)(@types/node@24.13.3) ws: specifier: ^8.21.3 version: 8.21.3 @@ -303,160 +306,160 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.2': - resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.2': - resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.2': - resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.1': - resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.1': - resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.1': - resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.1': - resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.1': - resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.1': - resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.1': - resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.1': - resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.1': - resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.1': - resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.35.2': - resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.2': - resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.2': - resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.2': - resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.2': - resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.2': - resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.2': - resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.2': - resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.35.2': - resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.2': - resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.2': - resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.2': - resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.2': - resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -465,8 +468,8 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -652,20 +655,20 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@vitest/coverage-v8@4.1.10': - resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} peerDependencies: - '@vitest/browser': 4.1.10 - vitest: 4.1.10 + '@vitest/browser': 4.1.11 + vitest: 4.1.11 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/expect@4.1.10': - resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} - '@vitest/mocker@4.1.10': - resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -675,27 +678,27 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.10': - resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} - '@vitest/runner@4.1.10': - resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} - '@vitest/snapshot@4.1.10': - resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} - '@vitest/spy@4.1.10': - resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - '@vitest/utils@4.1.10': - resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - ast-v8-to-istanbul@1.0.5: - resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + ast-v8-to-istanbul@1.0.6: + resolution: {integrity: sha512-fvpl29helSO2w/z7utIbrkNXILdrLwDwAMH2I/zPKlGf5244+gf+B4cyS1sANcrPY2h+hWCGSgC8N61s/+AF9A==} aws-ssl-profiles@1.1.2: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} @@ -729,8 +732,8 @@ packages: error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} - es-module-lexer@2.3.1: - resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} @@ -956,8 +959,8 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.5: - resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} playwright-core@1.62.1: @@ -1012,9 +1015,14 @@ packages: engines: {node: '>=10'} hasBin: true - sharp@0.35.2: - resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1051,8 +1059,8 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@1.3.0: - resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} engines: {node: '>=18'} tinyglobby@0.2.17: @@ -1124,20 +1132,20 @@ packages: yaml: optional: true - vitest@4.1.10: - resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.10 - '@vitest/browser-preview': 4.1.10 - '@vitest/browser-webdriverio': 4.1.10 - '@vitest/coverage-istanbul': 4.1.10 - '@vitest/coverage-v8': 4.1.10 - '@vitest/ui': 4.1.10 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -1247,18 +1255,19 @@ snapshots: optionalDependencies: workerd: 1.20260903.1 - '@cloudflare/vitest-plugin@1.1.4(@cloudflare/workers-types@5.20260904.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10)': + '@cloudflare/vitest-plugin@1.1.4(@cloudflare/workers-types@5.20260904.1)(@types/node@24.13.3)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11)': dependencies: - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 cjs-module-lexer: 1.2.3 esbuild: 0.28.1 - miniflare: 5.20260903.0-alpha - vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)) - wrangler: 4.129.0(@cloudflare/workers-types@5.20260904.1) + miniflare: 5.20260903.0-alpha(@types/node@24.13.3) + vitest: 4.1.11(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)) + wrangler: 4.129.0(@cloudflare/workers-types@5.20260904.1)(@types/node@24.13.3) zod: 4.4.3 transitivePeerDependencies: - '@cloudflare/workers-types' + - '@types/node' - bufferutil - utf-8-validate @@ -1368,123 +1377,123 @@ snapshots: '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.35.2': + '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/sharp-libvips-darwin-arm64': 1.3.3 optional: true - '@img/sharp-darwin-x64@0.35.2': + '@img/sharp-darwin-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.1 + '@img/sharp-libvips-darwin-x64': 1.3.3 optional: true - '@img/sharp-freebsd-wasm32@0.35.2': + '@img/sharp-freebsd-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.2 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.1': + '@img/sharp-libvips-darwin-arm64@1.3.3': optional: true - '@img/sharp-libvips-darwin-x64@1.3.1': + '@img/sharp-libvips-darwin-x64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm64@1.3.1': + '@img/sharp-libvips-linux-arm64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm@1.3.1': + '@img/sharp-libvips-linux-arm@1.3.3': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.1': + '@img/sharp-libvips-linux-ppc64@1.3.3': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.1': + '@img/sharp-libvips-linux-riscv64@1.3.3': optional: true - '@img/sharp-libvips-linux-s390x@1.3.1': + '@img/sharp-libvips-linux-s390x@1.3.3': optional: true - '@img/sharp-libvips-linux-x64@1.3.1': + '@img/sharp-libvips-linux-x64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.1': + '@img/sharp-libvips-linuxmusl-x64@1.3.3': optional: true - '@img/sharp-linux-arm64@0.35.2': + '@img/sharp-linux-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.1 + '@img/sharp-libvips-linux-arm64': 1.3.3 optional: true - '@img/sharp-linux-arm@0.35.2': + '@img/sharp-linux-arm@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.1 + '@img/sharp-libvips-linux-arm': 1.3.3 optional: true - '@img/sharp-linux-ppc64@0.35.2': + '@img/sharp-linux-ppc64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.1 + '@img/sharp-libvips-linux-ppc64': 1.3.3 optional: true - '@img/sharp-linux-riscv64@0.35.2': + '@img/sharp-linux-riscv64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.1 + '@img/sharp-libvips-linux-riscv64': 1.3.3 optional: true - '@img/sharp-linux-s390x@0.35.2': + '@img/sharp-linux-s390x@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.1 + '@img/sharp-libvips-linux-s390x': 1.3.3 optional: true - '@img/sharp-linux-x64@0.35.2': + '@img/sharp-linux-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.1 + '@img/sharp-libvips-linux-x64': 1.3.3 optional: true - '@img/sharp-linuxmusl-arm64@0.35.2': + '@img/sharp-linuxmusl-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 optional: true - '@img/sharp-linuxmusl-x64@0.35.2': + '@img/sharp-linuxmusl-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 optional: true - '@img/sharp-wasm32@0.35.2': + '@img/sharp-wasm32@0.35.4': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.2': + '@img/sharp-webcontainers-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.2 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-win32-arm64@0.35.2': + '@img/sharp-win32-arm64@0.35.4': optional: true - '@img/sharp-win32-ia32@0.35.2': + '@img/sharp-win32-ia32@0.35.4': optional: true - '@img/sharp-win32-x64@0.35.2': + '@img/sharp-win32-x64@0.35.4': optional: true '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping@0.3.9': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@oxc-project/types@0.143.0': {} @@ -1599,11 +1608,11 @@ snapshots: dependencies: '@types/node': 24.13.3 - '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.10 - ast-v8-to-istanbul: 1.0.5 + '@vitest/utils': 4.1.11 + ast-v8-to-istanbul: 1.0.6 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-reports: 3.2.0 @@ -1611,52 +1620,52 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)) + vitest: 4.1.11(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)) - '@vitest/expect@4.1.10': + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1))': + '@vitest/mocker@4.1.11(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1))': dependencies: - '@vitest/spy': 4.1.10 + '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.1) - '@vitest/pretty-format@4.1.10': + '@vitest/pretty-format@4.1.11': dependencies: tinyrainbow: 3.1.1 - '@vitest/runner@4.1.10': + '@vitest/runner@4.1.11': dependencies: - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.11 pathe: 2.0.3 - '@vitest/snapshot@4.1.10': + '@vitest/snapshot@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.10': {} + '@vitest/spy@4.1.11': {} - '@vitest/utils@4.1.10': + '@vitest/utils@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.10 + '@vitest/pretty-format': 4.1.11 convert-source-map: 2.0.0 tinyrainbow: 3.1.1 assertion-error@2.0.1: {} - ast-v8-to-istanbul@1.0.5: + ast-v8-to-istanbul@1.0.6: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 @@ -1680,7 +1689,7 @@ snapshots: error-stack-parser-es@1.0.5: {} - es-module-lexer@2.3.1: {} + es-module-lexer@2.3.2: {} esbuild@0.28.1: optionalDependencies: @@ -1717,9 +1726,9 @@ snapshots: expect-type@1.4.0: {} - fdir@6.5.0(picomatch@4.0.5): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.5 + picomatch: 4.0.7 fsevents@2.3.2: optional: true @@ -1813,7 +1822,7 @@ snapshots: magic-string@0.30.21: dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 magicast@0.5.4: dependencies: @@ -1825,15 +1834,16 @@ snapshots: dependencies: semver: 7.8.5 - miniflare@5.20260903.0-alpha: + miniflare@5.20260903.0-alpha(@types/node@24.13.3): dependencies: '@cspotcode/source-map-support': 0.8.1 - sharp: 0.35.2 + sharp: 0.35.4(@types/node@24.13.3) undici: 7.29.0 workerd: 1.20260903.1 ws: 8.21.0 youch: 4.1.0-beta.10 transitivePeerDependencies: + - '@types/node' - bufferutil - utf-8-validate @@ -1897,7 +1907,7 @@ snapshots: picocolors@1.1.1: {} - picomatch@4.0.5: {} + picomatch@4.0.7: {} playwright-core@1.62.1: {} @@ -1960,37 +1970,38 @@ snapshots: semver@7.8.5: {} - sharp@0.35.2: + sharp@0.35.4(@types/node@24.13.3): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.2 - '@img/sharp-darwin-x64': 0.35.2 - '@img/sharp-freebsd-wasm32': 0.35.2 - '@img/sharp-libvips-darwin-arm64': 1.3.1 - '@img/sharp-libvips-darwin-x64': 1.3.1 - '@img/sharp-libvips-linux-arm': 1.3.1 - '@img/sharp-libvips-linux-arm64': 1.3.1 - '@img/sharp-libvips-linux-ppc64': 1.3.1 - '@img/sharp-libvips-linux-riscv64': 1.3.1 - '@img/sharp-libvips-linux-s390x': 1.3.1 - '@img/sharp-libvips-linux-x64': 1.3.1 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 - '@img/sharp-libvips-linuxmusl-x64': 1.3.1 - '@img/sharp-linux-arm': 0.35.2 - '@img/sharp-linux-arm64': 0.35.2 - '@img/sharp-linux-ppc64': 0.35.2 - '@img/sharp-linux-riscv64': 0.35.2 - '@img/sharp-linux-s390x': 0.35.2 - '@img/sharp-linux-x64': 0.35.2 - '@img/sharp-linuxmusl-arm64': 0.35.2 - '@img/sharp-linuxmusl-x64': 0.35.2 - '@img/sharp-webcontainers-wasm32': 0.35.2 - '@img/sharp-win32-arm64': 0.35.2 - '@img/sharp-win32-ia32': 0.35.2 - '@img/sharp-win32-x64': 0.35.2 + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 24.13.3 siginfo@2.0.0: {} @@ -2014,12 +2025,12 @@ snapshots: tinybench@2.9.0: {} - tinyexec@1.3.0: {} + tinyexec@1.3.1: {} tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 tinyrainbow@3.1.1: {} @@ -2039,7 +2050,7 @@ snapshots: vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1): dependencies: lightningcss: 1.33.0 - picomatch: 4.0.5 + picomatch: 4.0.7 postcss: 8.5.26 rolldown: 1.2.3 tinyglobby: 0.2.17 @@ -2048,31 +2059,31 @@ snapshots: esbuild: 0.28.1 fsevents: 2.3.3 - vitest@4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)): + vitest@4.1.11(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)): dependencies: - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 - es-module-lexer: 2.3.1 + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 expect-type: 1.4.0 magic-string: 0.30.21 obug: 2.1.4 pathe: 2.0.3 - picomatch: 4.0.5 + picomatch: 4.0.7 std-env: 4.2.0 tinybench: 2.9.0 - tinyexec: 1.3.0 + tinyexec: 1.3.1 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 - '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) transitivePeerDependencies: - msw @@ -2089,13 +2100,13 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260903.1 '@cloudflare/workerd-windows-64': 1.20260903.1 - wrangler@4.129.0(@cloudflare/workers-types@5.20260904.1): + wrangler@4.129.0(@cloudflare/workers-types@5.20260904.1)(@types/node@24.13.3): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260903.1) blake3-wasm: 2.1.5 esbuild: 0.28.1 - miniflare: 5.20260903.0-alpha + miniflare: 5.20260903.0-alpha(@types/node@24.13.3) path-to-regexp: 6.3.0 unenv: 2.0.0-rc.24 workerd: 1.20260903.1 @@ -2103,6 +2114,7 @@ snapshots: '@cloudflare/workers-types': 5.20260904.1 fsevents: 2.3.3 transitivePeerDependencies: + - '@types/node' - bufferutil - utf-8-validate diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 02365ef..0f8fd9d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,9 @@ allowBuilds: esbuild: true workerd: true +minimumReleaseAgeExclude: + - vitest@4.1.11 + - "@vitest/mocker@4.1.11" + - sharp@0.35.4 +overrides: + sharp@<0.35.4: ^0.35.4 From b700423f2886540742317d53c2eea51d8d58ca8c Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 9 Sep 2026 02:24:54 -0700 Subject: [PATCH 4/5] fix: align process cleanup guards Flatten activation release behind an early guard to match repository style. Exercise the cleanup interleaving against MySQL as well as PostgreSQL. --- src/repository.ts | 33 ++++++++++++++--------------- test/mysql.test.ts | 52 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 17 deletions(-) diff --git a/src/repository.ts b/src/repository.ts index 0d83286..c66a87b 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -215,26 +215,25 @@ export class Repository { VALUES (?, ?, ?, ?) ON CONFLICT(message_id) DO NOTHING`, [claim.message_id, claim.instance_id, currentClaim.sequence, now], ) - if ( + const ownsActivation = instance.activation_owner_id === processId && instance.activation_token === currentClaim.activation_token && BigInt(instance.activation_generation) === BigInt(currentClaim.activation_generation) - ) { - await connection.run( - `UPDATE ${this.table("instances")} - SET activation_owner_id = NULL, activation_token = NULL, - activation_expires_at_ms = NULL, updated_at_ms = ? - WHERE id = ? AND activation_owner_id = ? AND activation_token = ? - AND activation_generation = ?`, - [ - now, - claim.instance_id, - processId, - currentClaim.activation_token, - currentClaim.activation_generation, - ], - ) - } + if (!ownsActivation) continue + await connection.run( + `UPDATE ${this.table("instances")} + SET activation_owner_id = NULL, activation_token = NULL, + activation_expires_at_ms = NULL, updated_at_ms = ? + WHERE id = ? AND activation_owner_id = ? AND activation_token = ? + AND activation_generation = ?`, + [ + now, + claim.instance_id, + processId, + currentClaim.activation_token, + currentClaim.activation_generation, + ], + ) } await connection.run( `UPDATE ${this.table("instances")} diff --git a/test/mysql.test.ts b/test/mysql.test.ts index 7904d40..c272127 100644 --- a/test/mysql.test.ts +++ b/test/mysql.test.ts @@ -194,6 +194,58 @@ describeMySQL("MySQL adapter", () => { } }, 15_000) + it("does not requeue a commit after process cleanup waits for it", async () => { + if (!connectionString) throw new Error("MySQL connection string is required") + database = mysql({ connectionString, maximumConnections: 5 }) + runtime = configure({ + database, + tableNamePrefix: "mysql_test_", + authorizeMessage: () => true, + authorizeQuery: () => true, + authorizeDestroy: () => true, + logger: quietLogger, + }) + runtime.register(MySQLFencedCommitActor) + const commitStarted = deferred() + const releaseCommit = deferred() + runtime.registerCommitAction("pause_fenced_commit", async () => { + commitStarted.resolve() + await releaseCommit.promise + }) + await runtime.install() + const message = await MySQLFencedCommitActor.ref( + `process-cleanup-${crypto.randomUUID()}`, + ).send.increment() + const firstWorker = runtime.worker() + const firstRun = firstWorker.runOnce() + await commitStarted.promise + try { + const processCleanup = runtime.repository.stopProcess(firstWorker.processId) + const cleanupBeforeRelease = await Promise.race([ + processCleanup.then(() => "settled" as const), + delay(50).then(() => "pending" as const), + ]) + releaseCommit.resolve() + + const [firstResult] = await Promise.all([firstRun, processCleanup]) + const ready = await database.connection((connection) => + connection.get<{ found: number | bigint }>( + `SELECT 1 AS found FROM ${runtime?.repository.table("ready_messages")} + WHERE message_id = ?`, + [message.id], + ), + ) + await firstWorker.stop() + + expect(cleanupBeforeRelease).toBe("pending") + expect(firstResult).toBe(1) + expect(ready).toBeUndefined() + await expect(message.result()).resolves.toBe(1) + } finally { + releaseCommit.resolve() + } + }, 15_000) + it("lets concurrent effect claimants skip locked work", async () => { if (!connectionString) throw new Error("MySQL connection string is required") database = mysql({ connectionString, maximumConnections: 5 }) From 57d437a02baed984716d558c1ac43e607b2c0fb8 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 9 Sep 2026 06:58:11 -0700 Subject: [PATCH 5/5] chore: bump version to 0.14.8 Prepare the atomic fencing fix for release by aligning the package metadata, exported runtime version, and dated changelog entry. --- CHANGELOG.md | 2 +- package.json | 2 +- src/version.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00ed401..8156acd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.14.8 - 2026-09-09 - Hold the actor instance and message claim locks through every fenced commit on PostgreSQL and MySQL. Lease recovery and stale-process cleanup revalidate diff --git a/package.json b/package.json index 8eb2916..63c737a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-objects", - "version": "0.14.7", + "version": "0.14.8", "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 adb2160..46c3fe7 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.14.7" +export const VERSION = "0.14.8"