diff --git a/SPEC.md b/SPEC.md index b0fdd49..52449e1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -181,7 +181,20 @@ HTTP status: `200`. - `results` (REQUIRED, array of single-statement result objects) — in the same order as the request `batch`. Each element has the shape of section 6.1. -If an atomic batch fails partway through, the response is the error envelope in section 7, not a partial `results` array. +A `results` array MUST have the same length as the request `batch`. A server MUST NOT return status `200` with a `results` array covering only some of the submitted statements. + +#### 6.2.1 Batch failure + +If a statement in a batch fails, the response is the error envelope in section 7, not a partial `results` array. This holds for both atomic and non-atomic batches. + +Servers MUST execute the statements of a non-atomic batch one at a time, in `batch` array order, and MUST stop at the first statement that fails. Without this, `error.statementIndex` does not partition the batch: a server that executed statements concurrently could commit a later statement while an earlier one failed, and the client could conclude nothing about what persisted. Atomic batches carry no ordering obligation, because the transaction makes execution order unobservable. + +Given that, the two cases differ in what persists, and the difference is normative: + +- **Atomic batch** (`atomic: true`) — the transaction rolls back. No statement in the batch has any effect. +- **Non-atomic batch** (`atomic` absent or `false`) — statements preceding the failing statement have been executed and their effects persist. The failing statement and all statements after it have not been executed. Clients MUST NOT treat a non-atomic batch error as no-statements-applied. + +For a non-atomic batch failure the server MUST include `error.statementIndex` (section 7), because it is the only means by which a client can determine how far the batch got. No such obligation applies to an atomic batch failure: nothing persisted, so there is nothing for the client to locate. ## 7. Error responses @@ -199,7 +212,7 @@ HTTP status: `4xx` or `5xx`. - `error.code` (REQUIRED, string) — one of the registered codes below, or a vendor-namespaced code (`vendor:`). - `error.message` (REQUIRED, string) — human-readable explanation. Servers SHOULD avoid leaking sensitive details. -- `error.statementIndex` (OPTIONAL, integer) — for batch requests, the zero-based index of the statement that failed. Omitted for single-statement requests. +- `error.statementIndex` (REQUIRED for non-atomic batch statement failures, otherwise OPTIONAL, integer) — the zero-based index of the statement that failed. For a non-atomic batch failure it is the client's only means of determining which statements persisted (section 6.2.1), so it MUST be present. MUST be omitted for single-statement requests. Registered error codes in v0.1: @@ -252,8 +265,10 @@ A v0.1 conforming server MUST: 3. Return the success envelopes defined in section 6 for successful execution. 4. Return the error envelope defined in section 7 for any failure, using the HTTP status codes in the table. 5. Honor `atomic: true` on batch requests. -6. Accept the registered parameter types in section 5 (`blob`, `bigint`). -7. Emit the `X-Http-Sql-Version` response header. +6. Execute a non-atomic batch sequentially in array order, stopping at the first failure (section 6.2.1). +7. On a batch statement failure, return the error envelope rather than a partial `results` array, and include `error.statementIndex` when the batch was non-atomic (section 6.2.1). +8. Accept the registered parameter types in section 5 (`blob`, `bigint`). +9. Emit the `X-Http-Sql-Version` response header. A v0.1 conforming server MAY: @@ -268,7 +283,8 @@ A v0.1 conforming client MUST: 2. Send exactly one of `sql` or `batch` in the request body. 3. Use the standard parameter encoding from section 5. 4. Handle the success and error envelopes from sections 6 and 7. -5. Not require any vendor-specific request or response fields beyond those defined here. +5. On a non-atomic batch error, treat the statements preceding `error.statementIndex` as applied (section 6.2.1). A client MUST NOT assume no statements were applied. +6. Not require any vendor-specific request or response fields beyond those defined here. A v0.1 conforming client SHOULD: diff --git a/conformance/README.md b/conformance/README.md index a1791c8..d854ed4 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -50,8 +50,9 @@ Conformance is self-asserted. The community can call out failures via issues. |-------|------------------------------------------------------------------|---------------------------------------------| | B-1 | Two INSERTs, no `atomic` | 200, `results` array length 2 | | B-2 | Two INSERTs with `atomic: true` | 200, `results` array length 2 | -| B-3 | Atomic batch where the second statement fails | 400, error envelope, no rows persisted | -| B-4 | Non-atomic batch where the second statement fails | 400, error envelope (servers MAY also return 200 with partial results -- the spec leaves this implementation-defined; recommended behavior is to fail closed) | +| B-3 | Atomic batch where the second statement fails | 400, error envelope. NO statement persisted -- the first INSERT is absent. | +| B-4 | Non-atomic batch where the second statement fails | 400, error envelope, `error.statementIndex` = 1. The FIRST statement PERSISTS -- its INSERT is present. | +| B-5 | Non-atomic three-statement batch where the second fails | 400, error envelope, `error.statementIndex` = 1. First statement persists, THIRD did not execute. | ### Parameter types diff --git a/examples/reference-client.ts b/examples/reference-client.ts index 4a71966..8430607 100644 --- a/examples/reference-client.ts +++ b/examples/reference-client.ts @@ -27,10 +27,11 @@ export class HttpSqlClient { } async batch(statements: Statement[], atomic = false): Promise { - return this.send({ batch: statements, atomic }) as Promise; + return this.send({ batch: statements, atomic }, atomic) as Promise; } - private async send(body: unknown): Promise { + // `atomic` is undefined for single-statement requests. + private async send(body: unknown, atomic?: boolean): Promise { const res = await fetch(this.endpoint, { method: "POST", headers: { @@ -48,7 +49,19 @@ export class HttpSqlClient { code: "internal_error", message: `HTTP ${res.status}`, }; - throw Object.assign(new Error(err.message), { code: err.code, statementIndex: err.statementIndex }); + // Section 6.2.1: a non-atomic batch error does NOT mean nothing applied -- + // statements before statementIndex persisted. Atomic batches rolled back, + // and a failed single statement applied nothing, so both are 0. + // `undefined` means exactly one thing: a non-atomic batch whose server + // omitted the REQUIRED statementIndex, so how far it got is UNKNOWN. + // Never treat that as zero -- a caller reading zero replays applied work. + const appliedCount = atomic === false ? err.statementIndex : 0; + + throw Object.assign(new Error(err.message), { + code: err.code, + statementIndex: err.statementIndex, + appliedCount, + }); } return json; diff --git a/examples/reference-server.ts b/examples/reference-server.ts index 26578f3..a4504f1 100644 --- a/examples/reference-server.ts +++ b/examples/reference-server.ts @@ -46,7 +46,8 @@ export async function handle(req: Request, auth: (req: Request) => boolean): Pro return ok({ results }); } catch (err) { const message = err instanceof Error ? err.message : String(err); - return errorResponse(400, "sql_error", message); + const statementIndex = (err as { statementIndex?: number }).statementIndex; + return errorResponse(400, "sql_error", message, statementIndex); } } @@ -61,8 +62,22 @@ async function execute(_stmt: Statement): Promise { return { columns: [], rows: [], rowsAffected: 0, lastInsertId: null }; } -async function executeBatch(batch: Statement[], _atomic: boolean): Promise { - return Promise.all(batch.map((s) => execute(s))); +async function executeBatch(batch: Statement[], atomic: boolean): Promise { + // SPEC.md 6.2.1: batches execute sequentially in array order, stopping at + // the first failure; a non-atomic failure carries error.statementIndex. + // In a real server the atomic branch wraps this loop in a transaction. + const out: Result[] = []; + for (let i = 0; i < batch.length; i++) { + try { + out.push(await execute(batch[i])); + } catch (err) { + if (!atomic && err !== null && typeof err === "object") { + (err as { statementIndex?: number }).statementIndex = i; + } + throw err; + } + } + return out; } function ok(body: unknown): Response {