From 1742e2d5b107a9eb9f4fceb458a088a08f80c2a7 Mon Sep 17 00:00:00 2001 From: Carl Kritzinger Date: Thu, 3 Sep 2026 12:38:09 +0200 Subject: [PATCH] Adds API semantics to handle large transactions --- README.md | 44 +++++++++++ openapi.yaml | 213 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 255 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 597db39..a61435e 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,50 @@ cd frontend && pnpm install && pnpm dev - PowerSync: http://localhost:8080 - Postgres: localhost:5432 +## Huge Transaction Flow + +`openapi.yaml` defines a stage/commit/poll flow for transactions too +large to fit in a single `postCrudTransaction` call. The client decides +which option to use by comparing the transaction's op count against a +configured `MAX_TRANSACTION_OPS`. If too long, the transation's operations are split into chunks and staged in batches before the transaction is committed. + +```mermaid +sequenceDiagram + participant Client + participant Server + + Client->>Client: ops.length > MAX_TRANSACTION_OPS? + + alt normal transaction + Client->>Server: POST /api/data
{ crud: [...] } + Server-->>Client: 200 OK
{ status: success | retryable_error | fatal_error } + else huge transaction + Client->>Client: split ops into chunks of size <= MAX_TRANSACTION_OPS + + Client->>Server: PUT /api/data/transactions/{id} + Note right of Server: begin staging + Server-->>Client: 201 Created (or 200 if already staging)
{ staged_count } + + loop each chunk + Client->>Server: POST /api/data/transactions/{id}/operations
[CrudEntry, ...] + Server-->>Client: 202 Accepted
{ staged_count } + end + + Client->>Server: POST /api/data/transactions/{id}/commit + Note right of Server: idempotent, can be retried, returns instantly + Server-->>Client: 202 Accepted
{ status: pending | processing } + + par background batch job + Server->>Server: run staged operations as a background transaction + and client polls + loop until terminal status + Client->>Server: GET /api/data/transactions/{id} + Server-->>Client: 200 OK
{ status: processing | success | retryable_error | fatal_error } + end + end + end +``` + ## Generating Types from OpenAPI Spec Both the backend and frontend generate TypeScript types from the shared `openapi.yaml` spec. diff --git a/openapi.yaml b/openapi.yaml index bb41227..1d16386 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -22,7 +22,7 @@ paths: responses: "200": description: > - Transaction result. Always returns 200 — the outcome + Transaction result. Always returns 200 — the outcome is determined by the status field in the response body. content: application/json: @@ -76,7 +76,200 @@ paths: schema: $ref: "#/components/schemas/MessageResponse" + /api/data/transactions/{transactionId}/operations: + post: + operationId: postTransactionOperations + summary: Stage operations as part of a large transaction + description: > + For transactions too large for a single postCrudTransaction call. + Saves the given operations to the transactionId staging area without processing. + beginTransaction must have been called first. + Can be called repeatedly with the same transactionId to accumulate more operations before committing. + Staged operations that are never committed are discarded after a server-defined TTL. + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/TransactionId" + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/CrudEntry" + responses: + "202": + description: Operations accepted into the staging area for the specified transaction. + content: + application/json: + schema: + $ref: "#/components/schemas/StagedOperationsResponse" + "401": + description: Missing or invalid token + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + "404": + description: > + Unknown transaction. Either beginTransaction was never called for this id, or it has been garbage collected. + The client must not assume its earlier operations calls for this id were preserved and should restart with beginTransaction under a fresh id. + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + "409": + description: Transaction has already been committed; staging is closed. + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + "500": + description: Unexpected server error + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + + /api/data/transactions/{transactionId}/commit: + post: + operationId: commitTransaction + summary: Commit a staged transaction + description: > + Idempotent, calling commit again on an already-committed transaction returns its current status without reprocessing. + Committing runs the staged operations as a background batch, so this always returns immediately with a pending/processing status. + Poll getTransactionStatus for the outcome. + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/TransactionId" + responses: + "202": + description: Commit accepted. Processing started. + content: + application/json: + schema: + $ref: "#/components/schemas/TransactionResponse" + "401": + description: Missing or invalid token + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + "404": + description: Unknown transaction, either nothing was begun/staged or this transaction has been garbage collected. + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + "500": + description: Unexpected server error + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + + /api/data/transactions/{transactionId}: + put: + operationId: beginTransaction + summary: Begin staging a large transaction + description: > + Creates the staging area for transactionId, identified by the client-generated UUID in the path. + Operations can only be staged against a transaction that has been explicitly begun, so this must be called before postTransactionOperations can be invoked against a transactionId. + Idempotent while the transaction is still staging, meaning it is safe to retry after a dropped connection and will not create a second transaction or duplicate staged operations. + Returns the current staged_count, so a reconnecting client can tell where it left off, or if a transaction has already been committed, or has been garbage collected. + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/TransactionId" + responses: + "201": + description: Transaction begun. + content: + application/json: + schema: + $ref: "#/components/schemas/StagedOperationsResponse" + "200": + description: Transaction was already staging; returned its current state without changing anything. + content: + application/json: + schema: + $ref: "#/components/schemas/StagedOperationsResponse" + "401": + description: Missing or invalid token + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + "409": + description: Transaction has already been committed, so it can no longer be begun again. + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + "500": + description: Unexpected server error + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + + get: + operationId: getTransactionStatus + summary: Query the status/result of a committed transaction + description: > + Results are retained for a server-defined TTL after commit completes, then garbage collected. + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/TransactionId" + responses: + "200": + description: Current transaction status. + content: + application/json: + schema: + $ref: "#/components/schemas/TransactionResponse" + "401": + description: Missing or invalid token + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + "404": + description: Unknown transaction, either nothing was begun/staged or this transaction has been garbage collected. + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + "500": + description: Unexpected server error + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + components: + parameters: + TransactionId: + name: transactionId + in: path + required: true + description: Client-generated UUID identifying a staged/committed transaction. + This must be unique for each transaction. + DO NOT reuse CrudTransaction.transaction_id values across transactions. + schema: + type: string + format: uuid + + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: Bearer JWT authenticating the writer to the backend. + schemas: CrudTransaction: type: object @@ -177,8 +370,13 @@ components: properties: status: type: string - enum: [success, retryable_error, fatal_error, not_attempted] + enum: [pending, processing, success, retryable_error, fatal_error, not_attempted] description: > + pending: transaction committed, batch job not yet started. + Only returned by the staged-transaction commit/status + endpoints, never by postCrudTransaction. + processing: batch job running. Only returned by the + staged-transaction commit/status endpoints. success: entire transaction persisted, safe to complete. retryable_error: transient failure, transaction rolled back, client should retry. @@ -219,6 +417,17 @@ components: type: string description: Human-readable error detail. + StagedOperationsResponse: + type: object + required: [transaction_id, staged_count] + properties: + transaction_id: + type: string + format: uuid + staged_count: + type: integer + description: Total number of operations staged for this transaction so far, across all operations calls. + MessageResponse: type: object required: