Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/retire-batch-validate-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
"@objectstack/spec": major
---

feat(spec)!: retire `BatchOptions.validateOnly` — a dry-run flag that was never implemented (#4052)

`BatchOptions.validateOnly` promised a dry-run — "validate records without
persisting changes" — but no batch surface ever read it. `updateManyData`,
`deleteManyData` and `batchData` all persist regardless, so a caller sending
`options.validateOnly: true` to PREVIEW a mutation got it executed. That is the
dangerous direction of "declared ≠ enforced": a flag lying about a data-safety
guarantee, not merely an inert no-op.

There is no dry-run today. Rather than back-fill an implementation to match a
promise nothing kept — a real no-commit batch has its own design space (cascade
and constraint semantics under rollback, a response contract that reports each
row's would-succeed verdict) — the key is retired so it can be reintroduced
deliberately when there is a real need.

**Breaking change.**

- `BatchOptions.validateOnly` is a retired key. It is tombstoned (`retiredKey`)
in `BatchOptionsSchema`, so authoring it now fails with a fix-it prescription
rather than being silently stripped (the ADR-0104 / #3733 quiet-failure class).
The `BatchOptions` type's `validateOnly` becomes `never`.
- The retirement is HTTP-only (the key never appeared in stored stack metadata),
so it is recorded as a semantic migration on the protocol-18 chain step
(`batch-options-validate-only-retired`) — a TODO for API callers, not a stack
conversion.

**Migration.** Stop sending `options.validateOnly` on `/batch`, `/updateMany`
and `/deleteMany`. It never previewed anything; removing it changes no behaviour.
If you need to validate a batch without writing, follow #4052 so a real
no-commit preview can be designed.

Also fixes a dangling documentation reference: the `/createMany` route
registration named `requestSchema: 'CreateManyRequestSchema'`, a schema no
module ever exported — pointed at the real `CreateManyDataRequestSchema`.
2 changes: 0 additions & 2 deletions content/docs/api/client-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,6 @@ const batchResult = await client.data.batch('account', {
atomic: true,
returnRecords: true,
continueOnError: false,
validateOnly: false,
},
});

Expand Down Expand Up @@ -499,7 +498,6 @@ The `find` method accepts an options object with **canonical** (recommended) fie
| `atomic` | `boolean` | `true` | Rollback entire batch on any failure |
| `returnRecords` | `boolean` | `false` | Include full records in response |
| `continueOnError` | `boolean` | `false` | Continue after errors (when atomic=false) |
| `validateOnly` | `boolean` | `false` | Dry-run mode — validate without persisting |

---

Expand Down
3 changes: 1 addition & 2 deletions content/docs/api/data-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,7 @@ Execute a batch operation (create / update / upsert / delete) on multiple record
"options": {
"atomic": true,
"returnRecords": true,
"continueOnError": false,
"validateOnly": false
"continueOnError": false
}
}
```
Expand Down
3 changes: 1 addition & 2 deletions content/docs/api/wire-format.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -484,8 +484,7 @@ Process many records of a **single** operation type in one request. The body car
],
"options": {
"atomic": true,
"continueOnError": false,
"validateOnly": false
"continueOnError": false
}
}
```
Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/api/batch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const result = BatchConfig.parse(data);
| :--- | :--- | :--- | :--- |
| **enabled** | `boolean` | ✅ | Enable batch operations |
| **maxRecordsPerBatch** | `integer` | ✅ | Maximum records per batch |
| **defaultOptions** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly: boolean }` | optional | Default batch options |
| **defaultOptions** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly?: any }` | optional | Default batch options |


---
Expand Down Expand Up @@ -89,7 +89,7 @@ const result = BatchConfig.parse(data);
| **atomic** | `boolean` | ✅ | If true, rollback entire batch on any failure (transaction mode) |
| **returnRecords** | `boolean` | ✅ | If true, return full record data in response |
| **continueOnError** | `boolean` | ✅ | If true (and atomic=false), continue processing remaining records after errors |
| **validateOnly** | `boolean` | | If true, validate records without persisting changes (dry-run mode) |
| **validateOnly** | `any` | optional | [REMOVED] `options.validateOnly` was removed from BatchOptions in @objectstack/spec (#4052). It was never implemented: the batch surfaces persisted regardless, so a "dry-run" would have silently executed. There is no dry-run today — drop the key. If you need to preview a batch without writing, open an issue so it can be designed (no-commit cascade / constraint semantics) and reintroduced as a flag that actually holds. |


---
Expand All @@ -115,7 +115,7 @@ const result = BatchConfig.parse(data);
| :--- | :--- | :--- | :--- |
| **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | ✅ | Type of batch operation |
| **records** | `{ id?: string; data?: Record<string, any>; externalId?: string }[]` | ✅ | Array of records to process (server caps the count — see batch.maxBatchSize) |
| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly: boolean }` | optional | Batch operation options |
| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly?: any }` | optional | Batch operation options |


---
Expand Down Expand Up @@ -199,7 +199,7 @@ A cross-object batch strip event: dropped fields plus the operation index
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **ids** | `string[]` | ✅ | Array of record IDs to delete (server caps the count — see batch.maxBatchSize) |
| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly: boolean }` | optional | Delete options |
| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly?: any }` | optional | Delete options |


---
Expand All @@ -223,7 +223,7 @@ A cross-object batch strip event: dropped fields plus the operation index
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **records** | `{ id: string; data: Record<string, any> }[]` | ✅ | Array of records to update (server caps the count — see batch.maxBatchSize) |
| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly: boolean }` | optional | Update options |
| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly?: any }` | optional | Update options |


---
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ const result = AiAgentCapabilities.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **ids** | `string[]` | ✅ | Array of record IDs to delete (server caps the count — see batch.maxBatchSize) |
| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly: boolean }` | optional | Delete options |
| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly?: any }` | optional | Delete options |
| **object** | `string` | ✅ | Object name |


Expand Down Expand Up @@ -1404,7 +1404,7 @@ const result = AiAgentCapabilities.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **records** | `{ id: string; data: Record<string, any> }[]` | ✅ | Array of records to update (server caps the count — see batch.maxBatchSize) |
| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly: boolean }` | optional | Update options |
| **options** | `{ atomic: boolean; returnRecords: boolean; continueOnError: boolean; validateOnly?: any }` | optional | Update options |
| **object** | `string` | ✅ | Object name |


Expand Down
2 changes: 1 addition & 1 deletion packages/spec/authorable-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -682,7 +682,7 @@
"api/BatchOptions:atomic",
"api/BatchOptions:continueOnError",
"api/BatchOptions:returnRecords",
"api/BatchOptions:validateOnly",
"api/BatchOptions:validateOnly [RETIRED]",
"api/BatchRecord:data",
"api/BatchRecord:externalId",
"api/BatchRecord:id",
Expand Down
13 changes: 10 additions & 3 deletions packages/spec/src/api/batch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,21 +57,28 @@ describe('BatchOptionsSchema', () => {
expect(options.atomic).toBe(true);
expect(options.returnRecords).toBe(false);
expect(options.continueOnError).toBe(false);
expect(options.validateOnly).toBe(false);
});

it('should accept custom options', () => {
const options = BatchOptionsSchema.parse({
atomic: false,
returnRecords: true,
continueOnError: true,
validateOnly: true,
});

expect(options.atomic).toBe(false);
expect(options.returnRecords).toBe(true);
expect(options.continueOnError).toBe(true);
expect(options.validateOnly).toBe(true);
});

it('rejects the retired `validateOnly` key with its prescription (#3963 follow-up)', () => {
// Never implemented — a "dry-run" that silently persisted. Tombstoned so
// writing it is audible rather than silently stripped (ADR-0104 / PD #10).
const result = BatchOptionsSchema.safeParse({ validateOnly: true });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toMatch(/validateOnly.*removed|never implemented/i);
}
});
});

Expand Down
18 changes: 17 additions & 1 deletion packages/spec/src/api/batch.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { z } from 'zod';
import { ApiErrorSchema, BaseResponseSchema, RecordDataSchema } from './contract.zod';
import { DroppedFieldsEventSchema } from '../data/data-engine.zod';
import { retiredKey } from '../shared/retired-key';

/**
* Batch Operations API
Expand Down Expand Up @@ -61,7 +62,22 @@ export const BatchOptionsSchema = lazySchema(() => z.object({
atomic: z.boolean().optional().default(true).describe('If true, rollback entire batch on any failure (transaction mode)'),
returnRecords: z.boolean().optional().default(false).describe('If true, return full record data in response'),
continueOnError: z.boolean().optional().default(false).describe('If true (and atomic=false), continue processing remaining records after errors'),
validateOnly: z.boolean().optional().default(false).describe('If true, validate records without persisting changes (dry-run mode)'),
// `validateOnly` promised a dry-run — "validate records without persisting" —
// but no batch surface ever read it (`updateManyData` / `deleteManyData` /
// `batchData` all persist regardless). A caller sending `validateOnly: true`
// to preview a mutation got it EXECUTED: a declared flag actively lying about
// a data-safety guarantee, the worst shape of "declared ≠ enforced" (PD #10).
// Retired rather than half-implemented: a real dry-run has its own design
// space (cascade / constraint semantics under no-commit, response contract)
// and should be reintroduced deliberately, not back-filled to match a promise
// nothing kept. Tombstoned so writing it is audible, not silently stripped.
validateOnly: retiredKey(
'`options.validateOnly` was removed from BatchOptions in @objectstack/spec (#4052). '
+ 'It was never implemented: the batch surfaces persisted regardless, so a "dry-run" would have '
+ 'silently executed. There is no dry-run today — drop the key. If you need to preview a batch '
+ 'without writing, open an issue so it can be designed (no-commit cascade / constraint semantics) '
+ 'and reintroduced as a flag that actually holds.',
),
}));

export type BatchOptions = z.infer<typeof BatchOptionsSchema>;
Expand Down
5 changes: 4 additions & 1 deletion packages/spec/src/api/plugin-rest-api.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -915,7 +915,10 @@ export const DEFAULT_BATCH_ROUTES: RestApiRouteRegistration = {
summary: 'Batch create',
description: 'Create multiple records in a single operation',
tags: ['Batch'],
requestSchema: 'CreateManyRequestSchema',
// Was 'CreateManyRequestSchema' — a name no schema ever exported (the real
// request contract is CreateManyDataRequestSchema in protocol.zod.ts). A
// dangling documentation reference; point it at the schema that exists.
requestSchema: 'CreateManyDataRequestSchema',
responseSchema: 'BatchUpdateResponseSchema',
permissions: ['data.create', 'data.batch'],
timeout: 60000,
Expand Down
25 changes: 23 additions & 2 deletions packages/spec/src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,9 +467,30 @@ const step18: MigrationStep = {
+ "(read as SYSTEM), and `book.audience: 'public'` (ADR-0046 §6.7). The key is dropped "
+ 'with a notice rather than mapped — there is no replacement value, only a different '
+ 'way to publish (by declaration). A stack that mounts no auth at all now fails at boot '
+ 'when it would serve a data API, instead of receiving an implicit fail-open.',
+ 'when it would serve a data API, instead of receiving an implicit fail-open.\n\n'
+ 'The same major retires `BatchOptions.validateOnly` (#4052): a batch "dry-run" flag that '
+ 'was declared but never implemented — every batch surface (`updateManyData` / '
+ '`deleteManyData` / `batchData`) persisted regardless, so a caller sending it to PREVIEW a '
+ 'mutation got it executed. That is the dangerous direction of declared ≠ enforced: a flag '
+ 'lying about a data-safety guarantee. No dry-run exists today; the schema tombstones the '
+ 'key with the prescription. It is HTTP-only (never stored in stack metadata), so the '
+ 'change is one semantic TODO for API callers rather than a stack conversion.',
conversionIds: ['stack-api-require-auth-removed'],
semantic: [],
semantic: [
{
id: 'batch-options-validate-only-retired',
surface: 'api.batchOptions.validateOnly',
replacement: '(removed — no dry-run today; open an issue to design a no-commit batch preview)',
reason:
'The `validateOnly` key promised a dry-run ("validate records without persisting") but no '
+ 'batch surface ever read it — updateManyData / deleteManyData / batchData persist '
+ 'regardless. There is no behaviour to preserve and nothing stored to rewrite (it only '
+ 'ever appeared in an HTTP request body). Callers must stop sending it.',
acceptanceCriteria:
'No /batch, /updateMany or /deleteMany call sends `options.validateOnly`; a request that '
+ 'includes it answers 400 VALIDATION_FAILED with the retirement prescription.',
},
],
};

export const MIGRATIONS_BY_MAJOR: Readonly<Record<number, MigrationStep>> = {
Expand Down
Loading