GeezerVison is showing github.com — fix: accept transmit envelopes without arguments · cardmagic/solid-objects-js@acdcba5
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: docs/api.md +19-8Lines changed: 19 additions & 8 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -445,28 +445,39 @@ outbox with at-least-once delivery, per-actor order, and retry backoff. | 445 | 445 | browser runtime a generous `maxAttempts`; an effect that exhausts its | 446 | 446 | attempts during a long offline period lands in dead letters, and | 447 | 447 | `runtime.deadLetters.retry` re-queues it. | 448 | | -- `receiveTransmitEnvelope(options)`: idempotent server ingest. It enqueues an | | 448 | +- `receiveTransmitEnvelope(options)`: idempotent server ingest. `arguments` | | 449 | + is optional in the envelope and defaults to an empty object, matching the | | 450 | + staging side and the Ruby ingest. It enqueues an | 449 | 451 | internal message with `transmit:<effectId>` as the idempotency key, so a | 450 | 452 | replayed envelope applies once. The host must authenticate the sender | 451 | 453 | before this call; internal delivery skips `authorizeMessage`. The call | 452 | 454 | belongs inside whatever route the host application gives the transmit | 453 | 455 | callback to post to: | 454 | 456 |
| 455 | 457 | ```typescript | 456 | | - import { receiveTransmitEnvelope } from "solid-objects" | | 458 | + import { IdempotencyConflict, InvalidPayload, receiveTransmitEnvelope } from "solid-objects" | 457 | 459 |
| 458 | 460 | async function handleSyncRoute(request: Request): Promise<Response> { | 459 | 461 | const sender = await authenticate(request) | 460 | 462 | if (!sender) return new Response("Forbidden", { status: 403 }) | 461 | | - const envelope = await request.json() | 462 | | - await receiveTransmitEnvelope({ runtime, envelope }) | 463 | | - return Response.json({}) | | 463 | + try { | | 464 | + await receiveTransmitEnvelope({ runtime, envelope: await request.json() }) | | 465 | + return Response.json({}) | | 466 | + } catch (error) { | | 467 | + if (error instanceof InvalidPayload || error instanceof IdempotencyConflict) { | | 468 | + return new Response(null, { status: 422 }) | | 469 | + } | | 470 | + throw error | | 471 | + } | 464 | 472 | } | 465 | 473 | ``` | 466 | 474 |
| 467 | | - The example uses a Fetch-style handler; any HTTP framework works, and a | 468 | | - 422 response for a rejected envelope tells the browser side to stop | 469 | | - retrying that effect. | | 475 | + The example uses a Fetch-style handler; any HTTP framework works. The 422 | | 476 | + matters: it tells the sending outbox to dead-letter the effect instead of | | 477 | + retrying it. `InvalidPayload` marks a malformed envelope, and | | 478 | + `IdempotencyConflict` marks a replay whose arguments changed; both are | | 479 | + permanently unappliable, and a 500 would make the outbox retry them | | 480 | + forever. | 470 | 481 |
| 471 | 482 | - Per-actor order comes from an ordered drain: a claimed transmit effect | 472 | 483 | transmits every undelivered envelope for its actor up to its own mailbox | |
Collapse file Copy file name to clipboardExpand all lines: docs/browser-protocol.md +3-2Lines changed: 3 additions & 2 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -129,9 +129,10 @@ authentication, so the trust boundary is the origin. | 129 | 129 |
| 130 | 130 | ## Sync envelope | 131 | 131 |
| 132 | | -`solid-objects/transmit` transmits one JSON envelope per staged sync | | 132 | +`solid-objects/transmit` transmits one JSON envelope per staged transmit | 133 | 133 | effect: `effectId`, target `actorType` and `actorId`, `operation`, and an | 134 | | -`arguments` object. The transport belongs to the host application; the | | 134 | +optional `arguments` object that defaults to an empty object on both | | 135 | +ingests. The transport belongs to the host application; the | 135 | 136 | Playwright suite posts envelopes over `fetch`. The server calls | 136 | 137 | `receiveTransmitEnvelope`, which enqueues an internal message with | 137 | 138 | `transmit:<effectId>` as the idempotency key, so a replayed envelope applies once. | |
Collapse file Copy file name to clipboardExpand all lines: src/transmit.ts +5-4Lines changed: 5 additions & 4 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -47,17 +47,18 @@ export async function receiveTransmitEnvelope(options: { | 47 | 47 | const { envelope } = options | 48 | 48 | for (const field of ["effectId", "actorType", "actorId", "operation"] as const) { | 49 | 49 | if (typeof envelope[field] !== "string" || envelope[field].length === 0) { | 50 | | - throw new InvalidPayload(`sync envelope requires a non-empty ${field}`) | | 50 | + throw new InvalidPayload(`transmit envelope requires a non-empty ${field}`) | 51 | 51 | } | 52 | 52 | } | 53 | | - if (!isJsonObject(envelope.arguments)) { | 54 | | - throw new InvalidPayload("sync envelope arguments must be a JSON object") | | 53 | + const argumentsValue = envelope.arguments ?? {} | | 54 | + if (!isJsonObject(argumentsValue)) { | | 55 | + throw new InvalidPayload("transmit envelope arguments must be a JSON object") | 55 | 56 | } | 56 | 57 | const message = await options.runtime.enqueueInternalMessage({ | 57 | 58 | actorType: envelope.actorType, | 58 | 59 | actorId: envelope.actorId, | 59 | 60 | operation: envelope.operation, | 60 | | - argumentsValue: envelope.arguments, | | 61 | + argumentsValue, | 61 | 62 | idempotencyKey: `transmit:${envelope.effectId}`, | 62 | 63 | }) | 63 | 64 | return { messageId: message.id } | |
Collapse file Copy file name to clipboardExpand all lines: test/transmit.test.ts +50Lines changed: 50 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 1 | 1 | import { afterEach, describe, expect, it } from "vitest" | 2 | 2 | import "../src/platform/node.js" | 3 | 3 | import { Actor } from "../src/actor.js" | | 4 | +import { IdempotencyConflict } from "../src/errors.js" | 4 | 5 | import { createRuntime, type SolidObjectsRuntime } from "../src/runtime.js" | 5 | 6 | import type { SolidObjectsConfiguration } from "../src/configuration.js" | 6 | 7 | import { sqlite } from "../src/database/sqlite.js" | @@ -253,6 +254,55 @@ describe("sync bridge", () => { | 253 | 254 | }) | 254 | 255 | }) | 255 | 256 |
| | 257 | + it("accepts an envelope without arguments, matching the Ruby ingest", async () => { | | 258 | + const server = testRuntime() | | 259 | + await server.install() | | 260 | + server.register(ServerTransmitCounter) | | 261 | + | | 262 | + await receiveTransmitEnvelope({ | | 263 | + runtime: server, | | 264 | + envelope: { | | 265 | + effectId: "effect-no-arguments", | | 266 | + actorType: "TransmitCounter", | | 267 | + actorId: "defaulted", | | 268 | + operation: "increment", | | 269 | + } as never, | | 270 | + }) | | 271 | + await server.testing.drain({ roles: ["actors"] }) | | 272 | + | | 273 | + expect(await server.ref(ServerTransmitCounter, "defaulted").snapshot()).toEqual({ | | 274 | + count: 1, | | 275 | + applied: [1], | | 276 | + }) | | 277 | + }) | | 278 | + | | 279 | + it("raises IdempotencyConflict when a replay changes the arguments", async () => { | | 280 | + const server = testRuntime() | | 281 | + await server.install() | | 282 | + server.register(ServerTransmitCounter) | | 283 | + const envelope = { | | 284 | + effectId: "effect-conflict", | | 285 | + actorType: "TransmitCounter", | | 286 | + actorId: "conflicted", | | 287 | + operation: "increment", | | 288 | + arguments: { amount: 1 }, | | 289 | + } | | 290 | + | | 291 | + await receiveTransmitEnvelope({ runtime: server, envelope }) | | 292 | + await expect( | | 293 | + receiveTransmitEnvelope({ | | 294 | + runtime: server, | | 295 | + envelope: { ...envelope, arguments: { amount: 2 } }, | | 296 | + }), | | 297 | + ).rejects.toBeInstanceOf(IdempotencyConflict) | | 298 | + await server.testing.drain({ roles: ["actors"] }) | | 299 | + | | 300 | + expect(await server.ref(ServerTransmitCounter, "conflicted").snapshot()).toEqual({ | | 301 | + count: 1, | | 302 | + applied: [1], | | 303 | + }) | | 304 | + }) | | 305 | + | 256 | 306 | it("rejects a malformed envelope on the server", async () => { | 257 | 307 | const server = testRuntime() | 258 | 308 | await server.install() | |
|