diff --git a/backend/migrations/dead_letter/README.md b/backend/migrations/dead_letter/README.md new file mode 100644 index 0000000..b602082 --- /dev/null +++ b/backend/migrations/dead_letter/README.md @@ -0,0 +1,44 @@ +# Dead-letter queue migrations + +These scripts create the `powersync_dead_letter` table/collection that the backend writes to when a transaction fails with a non-recoverable error (constraint violations, schema mismatches, validation failures). The scripts are templates — copy them into your own migration workflow and adapt as needed. + +## Applying + +| Source DB | Command | +| --- | --- | +| Postgres | `psql "$DATABASE_URI" -f postgres.sql` | +| MySQL | `mysql --defaults-extra-file=... < mysql.sql` | +| MSSQL | `sqlcmd -S ... -i mssql.sql` | +| MongoDB | `mongosh "$DATABASE_URI" mongo.js` | + +## What's in a dead-letter row + +Each row carries the full uploaded transaction plus a pointer to the operation that caused the rollback: + +- `id` — DLQ row id (UUID). +- `transaction_id` — client-side transaction id (may be null per the wire spec). +- `crud` — JSON payload of the full `CrudEntry[]` as uploaded. +- `failed_client_id` — `CrudEntry.client_id` of the offending op. +- `failed_table`, `failed_op` — table and op type (`PUT|PATCH|DELETE`) of the offender. +- `error_code` — machine classification (`UNIQUE_VIOLATION`, `FOREIGN_KEY_VIOLATION`, `SCHEMA_MISMATCH`, ...). +- `error_message` — driver-supplied human-readable detail. +- `created_at` — when the DLQ row was written. + +## What's covered + +Errors the server classifies as non-transient. These come back to the client as `status: 'dead_lettered'`; the client completes the transaction and the row lands here. + +## What's _not_ covered + +- **Stuck transient errors.** A "retryable" error (network blip, deadlock, DB unavailable) that never resolves keeps the client upload queue stuck — it does not land in the DLQ. Fix the underlying issue or build a higher-level escape hatch. +- **Mutator failures.** `/api/mutators/invoke` is a separate write channel with its own failure semantics. Not handled by this DLQ. + +## MongoDB caveat + +The MongoDB persister currently runs ops in a loop without wrapping them in a multi-document transaction. If op #3 of 5 fails, ops #1 and #2 are already committed. The DLQ entry still records the full transaction and the failing-op pointer, but **the entry represents intent that was partially applied** — naive replay against the same `_id` set will collide. When designing replay or resolution for Mongo, account for this. + +The other persisters (Postgres, MySQL, MSSQL) wrap the batch in a real transaction, so their DLQ entries always represent a clean rollback. + +## Resolution hook + +`backend/src/dead-letter-hook.ts` exports an `onDeadLetter` function that fires after each DLQ row is committed. It's fire-and-forget — errors in the hook are logged and dropped; they never block the upload response. Wire it to Slack, an admin dashboard refresh, metrics, or anything else. diff --git a/backend/migrations/dead_letter/mongo.js b/backend/migrations/dead_letter/mongo.js new file mode 100644 index 0000000..406085a --- /dev/null +++ b/backend/migrations/dead_letter/mongo.js @@ -0,0 +1,4 @@ +// Run with: mongosh "$DATABASE_URI" mongo.js +db.createCollection('powersync_dead_letter'); +db.powersync_dead_letter.createIndex({ created_at: -1 }); +db.powersync_dead_letter.createIndex({ failed_table: 1 }); diff --git a/backend/migrations/dead_letter/mssql.sql b/backend/migrations/dead_letter/mssql.sql new file mode 100644 index 0000000..0d08195 --- /dev/null +++ b/backend/migrations/dead_letter/mssql.sql @@ -0,0 +1,17 @@ +CREATE TABLE powersync_dead_letter ( + id UNIQUEIDENTIFIER NOT NULL PRIMARY KEY, + transaction_id BIGINT NULL, + crud NVARCHAR(MAX) NOT NULL, + failed_client_id BIGINT NOT NULL, + failed_table NVARCHAR(255) NOT NULL, + failed_op NVARCHAR(16) NOT NULL, + error_code NVARCHAR(64) NOT NULL, + error_message NVARCHAR(MAX) NULL, + created_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME() +); + +CREATE INDEX powersync_dead_letter_created_at_idx + ON powersync_dead_letter (created_at DESC); + +CREATE INDEX powersync_dead_letter_table_idx + ON powersync_dead_letter (failed_table); diff --git a/backend/migrations/dead_letter/mysql.sql b/backend/migrations/dead_letter/mysql.sql new file mode 100644 index 0000000..20b77ae --- /dev/null +++ b/backend/migrations/dead_letter/mysql.sql @@ -0,0 +1,13 @@ +CREATE TABLE powersync_dead_letter ( + id CHAR(36) NOT NULL PRIMARY KEY, + transaction_id BIGINT NULL, + crud JSON NOT NULL, + failed_client_id BIGINT NOT NULL, + failed_table VARCHAR(255) NOT NULL, + failed_op VARCHAR(16) NOT NULL, + error_code VARCHAR(64) NOT NULL, + error_message TEXT NULL, + created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + INDEX powersync_dead_letter_created_at_idx (created_at), + INDEX powersync_dead_letter_table_idx (failed_table) +); diff --git a/backend/migrations/dead_letter/postgres.sql b/backend/migrations/dead_letter/postgres.sql new file mode 100644 index 0000000..291faae --- /dev/null +++ b/backend/migrations/dead_letter/postgres.sql @@ -0,0 +1,17 @@ +CREATE TABLE powersync_dead_letter ( + id UUID PRIMARY KEY, + transaction_id BIGINT, + crud JSONB NOT NULL, + failed_client_id BIGINT NOT NULL, + failed_table TEXT NOT NULL, + failed_op TEXT NOT NULL, + error_code TEXT NOT NULL, + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX powersync_dead_letter_created_at_idx + ON powersync_dead_letter (created_at DESC); + +CREATE INDEX powersync_dead_letter_table_idx + ON powersync_dead_letter (failed_table); diff --git a/backend/src/api/data.ts b/backend/src/api/data.ts index cc0bc1e..099673e 100644 --- a/backend/src/api/data.ts +++ b/backend/src/api/data.ts @@ -1,8 +1,10 @@ import express, { type Request, type Response } from 'express'; +import { randomUUID } from 'crypto'; import config from '../../config.js'; import { factories } from '../persistance/persister-factories.js'; import { FatalOperationError, RetryableError } from '../errors.js'; -import type { OpBody, OpResponse } from '../types.js'; +import { onDeadLetter } from '../dead-letter-hook.js'; +import type { DeadLetterEntry, OpBody, OpResponse } from '../types.js'; const router = express.Router(); @@ -14,7 +16,7 @@ if (!config.database.uri) { throw new Error('DATABASE_URI environment variable is required'); } -const { updateBatch } = await persistenceFactory(config.database.uri); +const persister = await persistenceFactory(config.database.uri, { onDeadLetter }); /** * Handle a CrudTransaction. @@ -26,12 +28,24 @@ router.post( res: Response> ) => { try { - await updateBatch(req.body.crud); + await persister.updateBatch(req.body.crud); res.status(200).send({ status: 'success', message: 'Transaction completed' }); } catch (e) { if (e instanceof FatalOperationError) { + const entry: DeadLetterEntry = { + id: randomUUID(), + transaction_id: req.body.transaction_id ?? null, + crud: req.body.crud, + failed_client_id: e.failedOp.client_id, + failed_table: e.failedOp.table, + failed_op: e.failedOp.op, + error_code: e.errorCode, + error_message: e.message, + created_at: new Date().toISOString() + }; + await persister.writeDeadLetter(entry); res.status(200).send({ - status: 'fatal_error', + status: 'dead_lettered', message: e.message, failed_operation: { error_code: e.errorCode, message: e.message } }); diff --git a/backend/src/dead-letter-hook.ts b/backend/src/dead-letter-hook.ts new file mode 100644 index 0000000..034b531 --- /dev/null +++ b/backend/src/dead-letter-hook.ts @@ -0,0 +1,11 @@ +import type { DeadLetterEntry } from './types.js'; + +/** + * Fires after a transaction has been persisted to the dead-letter queue. + * Fire-and-forget: errors here are logged and dropped; this never blocks + * the upload response. Wire to Slack, an admin dashboard refresh, metrics, + * etc. + */ +export const onDeadLetter = async (entry: DeadLetterEntry): Promise => { + console.warn('Dead-letter entry:', entry.id, entry.error_code, entry.failed_table); +}; diff --git a/backend/src/errors.ts b/backend/src/errors.ts index 0eecfd7..b4c09a7 100644 --- a/backend/src/errors.ts +++ b/backend/src/errors.ts @@ -1,3 +1,5 @@ +import type { CrudEntry } from './types.js'; + /** Transient failure (deadlock, timeout, connection error). Client should retry. */ export class RetryableError extends Error { constructor(message: string) { @@ -9,7 +11,8 @@ export class RetryableError extends Error { export class FatalOperationError extends Error { constructor( public readonly errorCode: string, - message: string + message: string, + public readonly failedOp: CrudEntry ) { super(message); } diff --git a/backend/src/generated/api.ts b/backend/src/generated/api.ts index c502995..bad2213 100644 --- a/backend/src/generated/api.ts +++ b/backend/src/generated/api.ts @@ -70,9 +70,14 @@ export interface components { * client should retry. * fatal_error: transaction rolled back due to a non-recoverable * issue — see failed_operation for details. + * dead_lettered: a non-recoverable error occurred and the + * transaction was persisted to the dead-letter queue for + * later resolution. The client should complete the transaction + * (the server has taken responsibility for it) and may surface + * this to the user. * @enum {string} */ - status: "success" | "retryable_error" | "fatal_error"; + status: "success" | "retryable_error" | "fatal_error" | "dead_lettered"; /** @description Suggested retry delay in ms. Only meaningful for retryable_error. */ retry_after_ms?: number; /** @description Present when status is fatal_error. Identifies what caused the rollback. */ diff --git a/backend/src/persistance/mongo/mongo-persistance.ts b/backend/src/persistance/mongo/mongo-persistance.ts index a0079ce..3c4929a 100644 --- a/backend/src/persistance/mongo/mongo-persistance.ts +++ b/backend/src/persistance/mongo/mongo-persistance.ts @@ -1,12 +1,16 @@ import * as mongo from 'mongodb'; -import type { Persister, CrudEntry } from '../../types.js'; +import type { Persister, PersisterConfig, CrudEntry, DeadLetterEntry } from '../../types.js'; import { RetryableError, FatalOperationError } from '../../errors.js'; -import type { EntryMapper } from '../../mapping/types.js'; import { mongoMapper } from '../../mapping/mongo.js'; -export const createMongoPersister = async (uri: string, mapper: EntryMapper = mongoMapper): Promise => { +const DEAD_LETTER_COLLECTION = 'powersync_dead_letter'; + +export const createMongoPersister = async (uri: string, config: PersisterConfig = {}): Promise => { console.debug('Using MongoDB Persister'); + const mapper = config.mapper ?? mongoMapper; + const onDeadLetter = config.onDeadLetter; + const client = new mongo.MongoClient(uri); const db = client.db(); await client.connect(); @@ -14,8 +18,10 @@ export const createMongoPersister = async (uri: string, mapper: EntryMapper = mo const persister: Persister = { updateBatch: async (batch: CrudEntry[]) => { // TODO: Use batches & transactions. + let currentOp: CrudEntry | null = null; try { for (const op of batch) { + currentOp = op; const mapped = mapper(op); if (mapped === null) continue; @@ -33,12 +39,32 @@ export const createMongoPersister = async (uri: string, mapper: EntryMapper = mo } catch (e) { const err = e as Error & { code?: number; hasErrorLabel?: (label: string) => boolean }; if (err.code === 11000) { - throw new FatalOperationError('UNIQUE_VIOLATION', err.message); + throw new FatalOperationError('UNIQUE_VIOLATION', err.message, currentOp!); } else if (err.hasErrorLabel?.('TransientTransactionError')) { throw new RetryableError(err.message); } throw new RetryableError(err.message); } + }, + + writeDeadLetter: async (entry: DeadLetterEntry) => { + const collection = db.collection(DEAD_LETTER_COLLECTION); + await collection.insertOne({ + _id: entry.id as unknown as mongo.ObjectId, + transaction_id: entry.transaction_id, + crud: entry.crud, + failed_client_id: entry.failed_client_id, + failed_table: entry.failed_table, + failed_op: entry.failed_op, + error_code: entry.error_code, + error_message: entry.error_message, + created_at: new Date(entry.created_at) + }); + if (onDeadLetter) { + void Promise.resolve(onDeadLetter(entry)).catch((err) => + console.error('onDeadLetter hook failed:', err) + ); + } } }; diff --git a/backend/src/persistance/mssql/mssql-persistance.ts b/backend/src/persistance/mssql/mssql-persistance.ts index 9f113e7..5790867 100644 --- a/backend/src/persistance/mssql/mssql-persistance.ts +++ b/backend/src/persistance/mssql/mssql-persistance.ts @@ -1,17 +1,19 @@ import { URL } from 'url'; import sql from 'mssql'; -import type { Persister, CrudEntry } from '../../types.js'; +import type { Persister, PersisterConfig, CrudEntry, DeadLetterEntry } from '../../types.js'; import { RetryableError, FatalOperationError } from '../../errors.js'; -import type { EntryMapper } from '../../mapping/types.js'; import { defaultMapper } from '../../mapping/default.js'; function escapeIdentifier(identifier: string): string { return `[${identifier}]`; } -export const createMSSQLPersister = async (uri: string, mapper: EntryMapper = defaultMapper): Promise => { +export const createMSSQLPersister = async (uri: string, config: PersisterConfig = {}): Promise => { console.debug('Using MSSQL Persister'); + const mapper = config.mapper ?? defaultMapper; + const onDeadLetter = config.onDeadLetter; + const url = new URL(uri); const pool = new sql.ConnectionPool({ @@ -35,10 +37,12 @@ export const createMSSQLPersister = async (uri: string, mapper: EntryMapper = de const persister: Persister = { updateBatch: async (batch: CrudEntry[]) => { const transaction = pool.transaction(); + let currentOp: CrudEntry | null = null; try { await transaction.begin(); for (const op of batch) { + currentOp = op; const mapped = mapper(op); if (mapped === null) continue; @@ -114,12 +118,37 @@ export const createMSSQLPersister = async (uri: string, mapper: EntryMapper = de const err = e as Error & { number?: number }; const num = err.number ?? 0; if (num === 2627 || num === 2601) { - throw new FatalOperationError('UNIQUE_VIOLATION', err.message); + throw new FatalOperationError('UNIQUE_VIOLATION', err.message, currentOp!); } else if (num === 547) { - throw new FatalOperationError('FOREIGN_KEY_VIOLATION', err.message); + throw new FatalOperationError('FOREIGN_KEY_VIOLATION', err.message, currentOp!); } throw new RetryableError(err.message); } + }, + + writeDeadLetter: async (entry: DeadLetterEntry) => { + const request = pool.request(); + request.input('id', sql.UniqueIdentifier, entry.id); + request.input('transaction_id', sql.BigInt, entry.transaction_id); + request.input('crud', sql.NVarChar(sql.MAX), JSON.stringify(entry.crud)); + request.input('failed_client_id', sql.BigInt, entry.failed_client_id); + request.input('failed_table', sql.NVarChar, entry.failed_table); + request.input('failed_op', sql.NVarChar, entry.failed_op); + request.input('error_code', sql.NVarChar, entry.error_code); + request.input('error_message', sql.NVarChar(sql.MAX), entry.error_message); + request.input('created_at', sql.DateTime2, entry.created_at); + await request.query( + `INSERT INTO powersync_dead_letter + (id, transaction_id, crud, failed_client_id, failed_table, + failed_op, error_code, error_message, created_at) + VALUES (@id, @transaction_id, @crud, @failed_client_id, @failed_table, + @failed_op, @error_code, @error_message, @created_at)` + ); + if (onDeadLetter) { + void Promise.resolve(onDeadLetter(entry)).catch((err) => + console.error('onDeadLetter hook failed:', err) + ); + } } }; return persister; diff --git a/backend/src/persistance/mysql/mysql-persistance.ts b/backend/src/persistance/mysql/mysql-persistance.ts index cbf3e20..2b541c9 100644 --- a/backend/src/persistance/mysql/mysql-persistance.ts +++ b/backend/src/persistance/mysql/mysql-persistance.ts @@ -1,26 +1,29 @@ import mysql from 'mysql2/promise'; -import type { Persister, CrudEntry } from '../../types.js'; +import type { Persister, PersisterConfig, CrudEntry, DeadLetterEntry } from '../../types.js'; import { RetryableError, FatalOperationError } from '../../errors.js'; -import type { RowDataPacket } from 'mysql2/promise'; -import type { EntryMapper } from '../../mapping/types.js'; import { defaultMapper } from '../../mapping/default.js'; function escapeIdentifier(identifier: string): string { return `\`${identifier.replace(/`/g, '``').replace(/\./g, '`.`')}\``; } -export const createMySQLPersister = (uri: string, mapper: EntryMapper = defaultMapper): Persister => { +export const createMySQLPersister = (uri: string, config: PersisterConfig = {}): Persister => { console.debug('Using MySQL Persister'); + const mapper = config.mapper ?? defaultMapper; + const onDeadLetter = config.onDeadLetter; + const pool = mysql.createPool(uri); const persister: Persister = { updateBatch: async (batch: CrudEntry[]) => { const connection = await pool.getConnection(); + let currentOp: CrudEntry | null = null; try { await connection.beginTransaction(); for (const op of batch) { + currentOp = op; const mapped = mapper(op); if (mapped === null) continue; @@ -75,14 +78,44 @@ export const createMySQLPersister = (uri: string, mapper: EntryMapper = defaultM const err = e as Error & { errno?: number }; const errno = err.errno ?? 0; if (errno === 1062) { - throw new FatalOperationError('UNIQUE_VIOLATION', err.message); + throw new FatalOperationError('UNIQUE_VIOLATION', err.message, currentOp!); } else if (errno === 1452) { - throw new FatalOperationError('FOREIGN_KEY_VIOLATION', err.message); + throw new FatalOperationError('FOREIGN_KEY_VIOLATION', err.message, currentOp!); } throw new RetryableError(err.message); } finally { connection.release(); } + }, + + writeDeadLetter: async (entry: DeadLetterEntry) => { + const connection = await pool.getConnection(); + try { + await connection.execute( + `INSERT INTO powersync_dead_letter + (id, transaction_id, crud, failed_client_id, failed_table, + failed_op, error_code, error_message, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + entry.id, + entry.transaction_id, + JSON.stringify(entry.crud), + entry.failed_client_id, + entry.failed_table, + entry.failed_op, + entry.error_code, + entry.error_message, + entry.created_at + ] + ); + } finally { + connection.release(); + } + if (onDeadLetter) { + void Promise.resolve(onDeadLetter(entry)).catch((err) => + console.error('onDeadLetter hook failed:', err) + ); + } } }; return persister; diff --git a/backend/src/persistance/postgres/postgres-persistance.ts b/backend/src/persistance/postgres/postgres-persistance.ts index 3b27fd1..e7a6c9b 100644 --- a/backend/src/persistance/postgres/postgres-persistance.ts +++ b/backend/src/persistance/postgres/postgres-persistance.ts @@ -1,8 +1,7 @@ import { URL } from 'url'; import PG from 'pg'; -import type { Persister, CrudEntry } from '../../types.js'; +import type { Persister, PersisterConfig, CrudEntry, DeadLetterEntry } from '../../types.js'; import { RetryableError, FatalOperationError } from '../../errors.js'; -import type { EntryMapper } from '../../mapping/types.js'; import { defaultMapper } from '../../mapping/default.js'; const { Pool } = PG; @@ -11,9 +10,12 @@ function escapeIdentifier(identifier: string): string { return `"${identifier.replace(/"/g, '""').replace(/\./g, '"."')}"`; } -export const createPostgresPersister = (uri: string, mapper: EntryMapper = defaultMapper): Persister => { +export const createPostgresPersister = (uri: string, config: PersisterConfig = {}): Persister => { console.debug('Using Postgres Persister'); + const mapper = config.mapper ?? defaultMapper; + const onDeadLetter = config.onDeadLetter; + const url = new URL(uri); const pool = new Pool({ @@ -31,10 +33,12 @@ export const createPostgresPersister = (uri: string, mapper: EntryMapper = defau const persister: Persister = { updateBatch: async (batch: CrudEntry[]) => { const client = await pool.connect(); + let currentOp: CrudEntry | null = null; try { await client.query('BEGIN'); for (const op of batch) { + currentOp = op; const mapped = mapper(op); if (mapped === null) continue; @@ -104,16 +108,44 @@ export const createPostgresPersister = (uri: string, mapper: EntryMapper = defau const err = e as Error & { code?: string }; const code = err.code ?? ''; if (code === '23505') { - throw new FatalOperationError('UNIQUE_VIOLATION', err.message); + throw new FatalOperationError('UNIQUE_VIOLATION', err.message, currentOp!); } else if (code === '23503') { - throw new FatalOperationError('FOREIGN_KEY_VIOLATION', err.message); + throw new FatalOperationError('FOREIGN_KEY_VIOLATION', err.message, currentOp!); } else if (code.startsWith('42')) { - throw new FatalOperationError('SCHEMA_MISMATCH', err.message); + throw new FatalOperationError('SCHEMA_MISMATCH', err.message, currentOp!); } throw new RetryableError(err.message); } finally { client.release(); } + }, + + writeDeadLetter: async (entry: DeadLetterEntry) => { + const client = await pool.connect(); + try { + await client.query( + `INSERT INTO powersync_dead_letter + (id, transaction_id, crud, failed_client_id, failed_table, + failed_op, error_code, error_message, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + entry.id, + entry.transaction_id, + JSON.stringify(entry.crud), + entry.failed_client_id, + entry.failed_table, + entry.failed_op, + entry.error_code, + entry.error_message, + entry.created_at + ] + ); + } finally { + client.release(); + } + if (onDeadLetter) { + await Promise.resolve(onDeadLetter(entry)).catch((err) => console.error('onDeadLetter hook failed:', err)); + } } }; return persister; diff --git a/backend/src/types.ts b/backend/src/types.ts index e6fef2a..6c0d170 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -23,8 +23,26 @@ export type OpQuery = operations[Op] extends { para ? Q : never; +export interface DeadLetterEntry { + id: string; + transaction_id: number | null; + crud: CrudEntry[]; + failed_client_id: number; + failed_table: string; + failed_op: 'PUT' | 'PATCH' | 'DELETE'; + error_code: string; + error_message: string; + created_at: string; +} + export interface Persister { updateBatch: (batch: CrudEntry[]) => Promise; + writeDeadLetter: (entry: DeadLetterEntry) => Promise; +} + +export interface PersisterConfig { + mapper?: EntryMapper; + onDeadLetter?: (entry: DeadLetterEntry) => void | Promise; } -export type PersisterFactory = (uri: string, mapper?: EntryMapper) => Persister | Promise; +export type PersisterFactory = (uri: string, config?: PersisterConfig) => Persister | Promise; diff --git a/frontend/src/generated/api.d.ts b/frontend/src/generated/api.d.ts index c502995..bad2213 100644 --- a/frontend/src/generated/api.d.ts +++ b/frontend/src/generated/api.d.ts @@ -70,9 +70,14 @@ export interface components { * client should retry. * fatal_error: transaction rolled back due to a non-recoverable * issue — see failed_operation for details. + * dead_lettered: a non-recoverable error occurred and the + * transaction was persisted to the dead-letter queue for + * later resolution. The client should complete the transaction + * (the server has taken responsibility for it) and may surface + * this to the user. * @enum {string} */ - status: "success" | "retryable_error" | "fatal_error"; + status: "success" | "retryable_error" | "fatal_error" | "dead_lettered"; /** @description Suggested retry delay in ms. Only meaningful for retryable_error. */ retry_after_ms?: number; /** @description Present when status is fatal_error. Identifies what caused the rollback. */ diff --git a/frontend/src/library/powersync/DemoConnector.ts b/frontend/src/library/powersync/DemoConnector.ts index 41b88ba..3e67fe5 100644 --- a/frontend/src/library/powersync/DemoConnector.ts +++ b/frontend/src/library/powersync/DemoConnector.ts @@ -88,6 +88,13 @@ export class DemoConnector implements PowerSyncBackendConnector { console.error('Fatal error:', result.failedOperation?.error_code, result.message); await transaction.complete(); break; + case 'dead_lettered': + // Server persisted the transaction to its dead-letter queue. + // Local optimistic state will revert on the next sync. Surface + // to the user if appropriate. + console.warn('Dead-lettered:', result.failedOperation?.error_code, result.message); + await transaction.complete(); + break; case 'retryable_error': // Error is retryable - e.g. network error or temporary server error. // Throwing an error here causes this call to be retried after a delay. diff --git a/frontend/src/library/powersync/WriteAPIClient.ts b/frontend/src/library/powersync/WriteAPIClient.ts index d3c0671..8f62e73 100644 --- a/frontend/src/library/powersync/WriteAPIClient.ts +++ b/frontend/src/library/powersync/WriteAPIClient.ts @@ -15,7 +15,7 @@ export interface CrudEntry_API { } export interface TransactionResponse { - status: 'success' | 'retryable_error' | 'fatal_error'; + status: 'success' | 'retryable_error' | 'fatal_error' | 'dead_lettered'; retry_after_ms?: number; failed_operation?: { error_code: string; message?: string }; message?: string; @@ -26,7 +26,7 @@ export interface WriteAPITransport { } export interface TransactionResult { - status: 'success' | 'retryable_error' | 'fatal_error'; + status: 'success' | 'retryable_error' | 'fatal_error' | 'dead_lettered'; message?: string; failedOperation?: { error_code: string; message?: string }; } diff --git a/openapi.yaml b/openapi.yaml index 4d38406..19406f8 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -98,13 +98,18 @@ components: properties: status: type: string - enum: [success, retryable_error, fatal_error] + enum: [success, retryable_error, fatal_error, dead_lettered] description: > success: entire transaction persisted, safe to complete. retryable_error: transient failure, transaction rolled back, client should retry. fatal_error: transaction rolled back due to a non-recoverable issue — see failed_operation for details. + dead_lettered: a non-recoverable error occurred and the + transaction was persisted to the dead-letter queue for + later resolution. The client should complete the transaction + (the server has taken responsibility for it) and may surface + this to the user. retry_after_ms: type: integer description: >