Skip to content

Commit e533b0b

Browse files
os-zhuangclaude
andauthored
feat(spec)!: retire datasource.capabilities — eleven flags nothing read, one a safety claim (#4583) (#4601)
DatasourceCapabilities declared eleven booleans — transactions, seven query* flags, joins, fullTextSearch, readOnly, dynamicSchema — all strict-guarded, all read by nothing. Pushdown is decided by the runtime driver's own `supports.*`, a different mechanism, so declaring `queryAggregations: false` never once changed which engine path ran. Removed rather than bridged: there was nothing on the other side to connect it to. readOnly is why this is not tidy-up. It reads as a safety property and was authored as one — the shipped CRM example labelled a datasource "CRM Analytics Read Replica" on its strength while the datasource took writes like the primary. The key had already been MOVED twice toward somewhere it might be enforced (out of `config` in #4410, into `capabilities` in #4465) and was inert at every address. This removes it instead of moving it a third time. Removing it does NOT hand the author a replacement, and the rejection says so. `external.allowWrites: false` is the one enforced write gate and applies only to FEDERATED datasources — assertWriteAllowed returns early for a managed (or unset-schemaMode) one, so that key would be equally inert for a local database. A managed datasource has no read-only gate at all; that gap is #4584, deliberately not invented here. Also fixed: READ_ONLY_BELONGS_ON_DATASOURCE — the prescription every SQL driver shares for a `readOnly` written inside `config` — was still sending authors TO the removed key. A prescription that lands on an inert key manufactures exactly the belief it was meant to correct. DriverDefinition.capabilities goes with it: its call graph is closed too — MemoryDriverSpec / MongoDriverSpec are referenced only by their own tests, so the whole schema object is inert, not just the datasource-side use. Route is strict-removal (both shapes are .strict()): keys leave the walked shape, so the 11 ledger rows are DELETED rather than flipped, and the 13 baseline lines in authorable-surface.json are removed deliberately in this PR — the readReplicas (#4468) precedent. ADR-0087 conversion `datasource-capabilities-removed` is registered in step 17, so `os migrate meta --from 16` really does rewrite author sources rather than the tombstone merely claiming it. datasource ledger: 20 dead -> 9 (healthCheck x3, retryPolicy x4, external x2 — batches B/C/D of #4583). Strictness-ledger site count 9 -> 8. Claude-Session: https://claude.ai/code/session_01E5CYr5SDwe85gH2Jr5KSgu Co-authored-by: Claude <noreply@anthropic.com>
1 parent ddf7bdc commit e533b0b

23 files changed

Lines changed: 292 additions & 362 deletions
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/example-crm": patch
4+
---
5+
6+
feat(spec)!: retire `datasource.capabilities` — eleven flags nothing read, one of them a safety claim (#4583)
7+
8+
`DatasourceCapabilities` declared eleven booleans — `transactions`, seven `query*`
9+
flags, `joins`, `fullTextSearch`, `readOnly`, `dynamicSchema` — all strict-guarded,
10+
all read by nothing. Pushdown is decided by the runtime driver's own `supports.*`
11+
object, a different mechanism entirely, so a datasource declaring
12+
`queryAggregations: false` never once changed which engine path ran. The block is
13+
removed rather than bridged: there was nothing on the other side to connect it to.
14+
15+
**`readOnly` is why this is not tidy-up.** It reads as a safety property and was
16+
authored as one — the shipped CRM example labelled a datasource "CRM Analytics Read
17+
Replica" on the strength of it, while the datasource accepted writes exactly like the
18+
primary. The key had already been MOVED twice toward somewhere it might be enforced,
19+
out of `config` in #4410 and into `capabilities` in #4465, and was inert at every
20+
address. This removes it instead of moving it a third time.
21+
22+
**Removing it does not hand you a working replacement, and the rejection says so.**
23+
The one enforced datasource-wide write gate is `external.allowWrites: false`, and it
24+
applies only to a FEDERATED datasource — `assertWriteAllowed` returns early for a
25+
`managed` (or unset-`schemaMode`) datasource, so that key would be equally inert for a
26+
local database. **A managed datasource has no read-only gate at all**; that gap is
27+
#4584, deliberately not invented here. Until it is answered, enforce read-only where
28+
it is real: grant the connection SELECT-only at the database.
29+
30+
FROM → TO:
31+
32+
```ts
33+
// before — parsed cleanly, changed nothing
34+
defineDatasource({
35+
name: 'analytics', driver: 'sqlite', config: { filename: ':memory:' },
36+
capabilities: { readOnly: true, queryAggregations: true },
37+
})
38+
39+
// after — delete the block; for a FEDERATED datasource the enforced gate is:
40+
defineDatasource({
41+
name: 'warehouse', driver: 'postgres', config: { … },
42+
schemaMode: 'external',
43+
external: { allowWrites: false },
44+
})
45+
```
46+
47+
`os migrate meta --from 16` rewrites it automatically (ADR-0087 conversion
48+
`datasource-capabilities-removed`). Both `DatasourceSchema` and
49+
`DriverDefinitionSchema` are `.strict()`, so a leftover key is a loud rejection
50+
carrying the prescription — never a silent strip.
51+
52+
Also fixed: `READ_ONLY_BELONGS_ON_DATASOURCE`, the prescription every SQL driver
53+
shares for a `readOnly` written inside `config`, was still sending authors *to* the
54+
removed key. It now names the enforced gate and states plainly where that gate does
55+
not apply — a prescription that lands on an inert key manufactures exactly the belief
56+
it was meant to correct.
57+
58+
The `datasource` liveness ledger drops from 20 dead properties to 9 (remaining:
59+
`healthCheck` ×3, `retryPolicy` ×4, `external` ×2 — batches B/C/D of #4583).

content/docs/getting-started/quick-reference.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Core business logic and data modeling schemas.
2323
| **[Query](/docs/references/data/query)** | `query.zod.ts` | Query, QueryAST | Query AST with joins, aggregations |
2424
| **[Filter](/docs/references/data/filter)** | `filter.zod.ts` | QueryFilter, FilterCondition | Advanced filtering operators |
2525
| **[Validation](/docs/references/data/validation)** | `validation.zod.ts` | ValidationRule | Business validation rules |
26-
| **[Datasource](/docs/references/data/datasource)** | `datasource.zod.ts` | Datasource, DatasourceCapabilities | Database connection configs |
26+
| **[Datasource](/docs/references/data/datasource)** | `datasource.zod.ts` | Datasource, DriverDefinition | Database connection configs |
2727
| **[Analytics](/docs/references/data/analytics)** | `analytics.zod.ts` | Analytics | Data analytics and aggregation |
2828
| **[Mapping](/docs/references/data/mapping)** | `mapping.zod.ts` | FieldMapping | Field transformation mappings |
2929
| **[Hook](/docs/references/data/hook)** | `hook.zod.ts` | Hook, HookEvent | Lifecycle event hooks |

content/docs/references/data/datasource.mdx

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Can be a built-in driver or a plugin-contributed driver (e.g., "com.vendor.snowf
1616
## TypeScript Usage
1717

1818
```typescript
19-
import { DatasourceSchema, DatasourceCapabilities, DriverDefinitionSchema, DriverType, ExternalDatasourceSettingsSchema } from '@objectstack/spec/data';
19+
import { DatasourceSchema, DriverDefinitionSchema, DriverType, ExternalDatasourceSettingsSchema } from '@objectstack/spec/data';
2020
import type { Datasource, DriverDefinition, ExternalDatasourceSettings } from '@objectstack/spec/data';
2121

2222
// Validate data
@@ -36,7 +36,6 @@ const result = DatasourceSchema.parse(data);
3636
| **driver** | `string` || Underlying driver type |
3737
| **config** | `Record<string, any>` || Driver specific configuration |
3838
| **pool** | `{ min: number; max: number; idleTimeoutMillis: number; connectionTimeoutMillis: number }` | optional | Connection pool settings |
39-
| **capabilities** | `{ transactions: boolean; queryFilters: boolean; queryAggregations: boolean; querySorting: boolean; … }` | optional | Capability overrides |
4039
| **healthCheck** | `{ enabled: boolean; intervalMs: number; timeoutMs: number }` | optional | Datasource health check configuration |
4140
| **ssl** | `{ enabled: boolean; rejectUnauthorized: boolean; ca?: string; cert?: string; … }` | optional | SSL/TLS configuration for secure database connections |
4241
| **retryPolicy** | `{ maxRetries: number; baseDelayMs: number; maxDelayMs: number; backoffMultiplier: number }` | optional | Connection retry policy for transient failures |
@@ -55,27 +54,6 @@ const result = DatasourceSchema.parse(data);
5554
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |
5655

5756

58-
---
59-
60-
## DatasourceCapabilities
61-
62-
### Properties
63-
64-
| Property | Type | Required | Description |
65-
| :--- | :--- | :--- | :--- |
66-
| **transactions** | `boolean` || |
67-
| **queryFilters** | `boolean` || |
68-
| **queryAggregations** | `boolean` || |
69-
| **querySorting** | `boolean` || |
70-
| **queryPagination** | `boolean` || |
71-
| **queryWindowFunctions** | `boolean` || |
72-
| **querySubqueries** | `boolean` || |
73-
| **joins** | `boolean` || |
74-
| **fullTextSearch** | `boolean` || |
75-
| **readOnly** | `boolean` || |
76-
| **dynamicSchema** | `boolean` || |
77-
78-
7957
---
8058

8159
## DriverDefinition
@@ -89,7 +67,6 @@ const result = DatasourceSchema.parse(data);
8967
| **description** | `string` | optional | |
9068
| **icon** | `string` | optional | |
9169
| **configSchema** | `Record<string, any>` || JSON Schema for connection configuration |
92-
| **capabilities** | `{ transactions: boolean; queryFilters: boolean; queryAggregations: boolean; querySorting: boolean; … }` | optional | |
9370

9471

9572
---

docs/audits/2026-07-unknown-key-strictness-ledger.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
454454
| `notification.zod.ts` / `offline.zod.ts` / `report.zod.ts` | 3 ea | authorable (p) | |
455455
| `sharing.zod.ts` | 2 | authorable (p) | public-sharing config |
456456

457-
### `data/`165 sites
457+
### `data/`164 sites
458458

459459
| File | Sites | Class | Note |
460460
|---|---|---|---|
@@ -465,7 +465,7 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
465465
| `field.zod.ts` | 11 | authorable | partially strict |
466466
| `filter.zod.ts` / `query.zod.ts` | 11+5 | open | query dialect — user data flows through; validated semantically elsewhere. `query.zod.ts` dropped one site in #4196: `FieldNodeSchema`'s nested-select object form was declared-but-inert and narrowed to `z.string()`, so the union's second member is gone. Four more left in #4286 with the `joins`/`windowFunctions` removals: `JoinNodeBaseSchema`, `WindowFunctionNodeSchema`, and `WindowSpecSchema`'s two blocks (outer + `frame`) were deleted with their clusters. Class unchanged |
467467
| `driver-nosql.zod.ts` / `driver.zod.ts` / `driver-sql.zod.ts` | 10+9+2 | wire | driver capability contracts |
468-
| `datasource.zod.ts` | 9 | authorable | **strict as of #4001 data step** — all 9: `DatasourceSchema` (+ `pool` / `healthCheck` / `ssl` / `retryPolicy`), `ExternalDatasourceSettingsSchema` (+ `validation`), `DatasourceCapabilities`, `DriverDefinitionSchema`. `config` stays `z.record` **at this level** by construction (per-driver shapes), but is no longer unchecked: **#4410** made `DatasourceSchema`'s refinement parse it against the contract for the declared driver (`driver/config-registry.zod.ts`), so the openness here is a shape this level cannot express rather than the absence of one. This row used to add "the driver's own `configSchema` validates them", which was false until #4410 landed the parse site it names. #4410 extended the same parse to each `readReplicas` entry; **#4468 retired that key** — no driver ever opened a replica connection and no query path splits reads from writes, so the entries were being checked against a contract nothing would apply. Strictness makes a dropped key loud; it cannot make a slot live, and a *precisely validated* dead slot is the more convincing lie |
468+
| `datasource.zod.ts` | 8 | authorable | **strict as of #4001 data step** — all 8: `DatasourceSchema` (+ `pool` / `healthCheck` / `ssl` / `retryPolicy`), `ExternalDatasourceSettingsSchema` (+ `validation`), `DriverDefinitionSchema`. `config` stays `z.record` **at this level** by construction (per-driver shapes), but is no longer unchecked: **#4410** made `DatasourceSchema`'s refinement parse it against the contract for the declared driver (`driver/config-registry.zod.ts`), so the openness here is a shape this level cannot express rather than the absence of one. This row used to add "the driver's own `configSchema` validates them", which was false until #4410 landed the parse site it names. #4410 extended the same parse to each `readReplicas` entry; **#4468 retired that key** — no driver ever opened a replica connection and no query path splits reads from writes, so the entries were being checked against a contract nothing would apply. Strictness makes a dropped key loud; it cannot make a slot live, and a *precisely validated* dead slot is the more convincing lie | **#4583 dropped the ninth site**: `DatasourceCapabilities` is gone — eleven flags no code read, on a block whose strictness was the clearest case of this row's own closing sentence. `readOnly` in particular was *precisely validated* and completely inert, and had been relocated twice (#4410, #4465) toward somewhere it might be enforced; the shipped CRM example called a datasource a read replica on the strength of it while writes went through. Class unchanged
469469
| `driver/memory.zod.ts` / `driver/mongo.zod.ts` / `driver/postgres.zod.ts` | 6+1+1 | authorable | The per-driver shapes for the `config` slot — what an author actually writes under `datasource.config` (`host`, `port`, `filename`). **Undeclared here until the coverage walk went recursive** (see below): a subdirectory was invisible to the gate, so these sites sat outside the map while the map reported full coverage. **Strict as of #4410**, which is also what unblocked them: this row previously read "strictness here would enforce nothing" because nothing parsed `datasource.config` against these schemas and both `*DriverSpec.configSchema` literals were `{}`. Now `DatasourceSchema` parses `config` against them, and the same schemas project onto `configSchema` and onto the Studio connection form. (#4410 also ran the parse over each `readReplicas` entry; #4468 retired that key outright — see the row above.) `postgres.zod.ts` drops a site: its `ssl` was a `boolean | {ca, cert, key, …}` union, and the object arm is gone — certificates now live in the datasource-level `ssl` block (declared, strict, and until #4410 read by nobody), leaving `config.ssl` as the on/off shorthand. That narrowing is forced by the same projection: the Studio form renders anything that is not boolean/enum/number as a TEXT INPUT, so a union here would have produced a wizard whose every `ssl` value the new gate rejects. `memory.zod.ts` keeps 6 but loses two KEYS — `indexes` / `maxRecordsPerObject`, which `InMemoryDriverConfig` has no field for, removed under ADR-0049 rather than blessed by the new gate |
470470
| `driver/mysql.zod.ts` / `driver/sqlite.zod.ts` | 1+2 | authorable | The rest of the `config` contract, added by #4410. `mysql.zod.ts` and `sqlite.zod.ts` (sqlite + sqlite-wasm) are shapes that **never existed** — both driver ids were offered by the connection form and buildable by the shared factory, with no config contract anywhere, so `driver: 'sqlite'` + a misspelled `filename` was an ephemeral `:memory:` database reported as configured. All three sites strict, same error factory as the rest of the campaign. (Their sibling `driver/common.zod.ts` holds shared enums and prescription strings and has no `z.object(` site, so the coverage gate skips it) |
471471
| `analytics.zod.ts` | 8 | mixed (p) | |

docs/protocol-upgrade-guide.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ The `script` flow node converges on its one real path (#4343). It had four ways
188188
| `stack-api-require-auth-removed` | `stack.api.requireAuth` | stack key 'api.requireAuth' removed — anonymous access is always denied; publish public surfaces by declaration (#3963) | retired — `migrate meta` only |
189189
| `flow-node-wait-timeout-keys-removed` | `flow.node.waitEventConfig` | waitEventConfig keys 'timeoutMs' (→ 'timerDuration', stringified — its only reader used it as the duration) and 'onTimeout' (removed — zero readers, so no timeout ever fired) (#4158) | retired — `migrate meta` only |
190190
| `datasource-read-replicas-removed` | `datasource.readReplicas` | datasource key 'readReplicas' removed (#4468 — no driver opened a replica connection and no query path splits reads from writes; front replicas behind one endpoint and point `config` at it) | retired — `migrate meta` only |
191+
| `datasource-capabilities-removed` | `datasource.capabilities` | datasource key 'capabilities' removed (#4583 — eleven flags no code read; pushdown comes from the driver's own supports.*, and `readOnly` never made anything read-only) | retired — `migrate meta` only |
191192
| `flow-node-script-branch-keys-removed` | `flow.node.script.config.actionType / flow.node.script.config.template / flow.node.script.config.recipients / flow.node.script.config.variables / flow.node.script.config.script` | script flow-node config keys 'actionType' (→ 'function' when it was shorthand for one; otherwise removed — 'email'/'slack' were logger-backed stubs that delivered nothing), plus 'template' / 'recipients' / 'variables' (fed those stubs) and 'script' (inline JS the runtime never executed) (#4343) | retired — `migrate meta` only |
192193

193194
### Semantic (delegated to you, with acceptance criteria)

examples/app-crm/src/datasources/crm.datasource.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,26 @@ export const CrmDatasource = defineDatasource({
2121
});
2222

2323
/**
24-
* Read-replica for analytics queries — demonstrates datasource routing.
24+
* Second datasource for analytics queries — demonstrates datasource routing.
2525
*
26-
* `readOnly` is a datasource CAPABILITY, not sqlite config. It sat inside
27-
* `config` here until #4410 gave that slot a gate — a key no driver read, so
28-
* the "read replica" was writable while every signal said it was not.
26+
* This used to declare `capabilities: { readOnly: true }` and call itself a read
27+
* replica. It was neither: the key had no reader, so the "read replica" accepted
28+
* writes exactly like the primary — the third spelling of the same defect, after
29+
* the same claim sat inertly in `config` (#4410) and then in `capabilities`
30+
* (#4465). #4583 removed the key rather than move it a fourth time.
31+
*
32+
* The label no longer promises read-only, because nothing here can deliver it:
33+
* `external.allowWrites: false` is the one enforced write gate and it applies
34+
* only to FEDERATED datasources, while this one is local and managed. Whether a
35+
* managed datasource should have a read-only gate at all is #4584 — until that
36+
* is answered, the honest demo is routing, not a safety claim.
2937
*/
3038
export const CrmAnalyticsDatasource = defineDatasource({
3139
name: 'crm_analytics',
32-
label: 'CRM Analytics Read Replica',
40+
label: 'CRM Analytics',
3341
driver: 'sqlite',
3442
config: {
3543
filename: ':memory:',
3644
},
37-
capabilities: {
38-
readOnly: true,
39-
},
4045
active: true,
4146
});

packages/cli/src/utils/lint-liveness-properties.test.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -195,42 +195,47 @@ describe('lintLivenessProperties', () => {
195195
// the type that most needed it: 20 of its 43 props have no runtime consumer,
196196
// and until #4487 nothing told an author so.
197197

198-
it('warns on the dead datasource blocks — capabilities / healthCheck / retryPolicy (#4487)', () => {
198+
it('warns on the dead datasource blocks that remain — healthCheck / retryPolicy (#4487)', () => {
199+
// `capabilities` left this list in #4583: the block was REMOVED from the
200+
// schema, so an author who writes it now gets a hard parse rejection with a
201+
// prescription — a stronger signal than a lint warning, and the reason its
202+
// ledger rows are gone rather than flipped. healthCheck / retryPolicy are
203+
// still authorable and still dead (batches B and C of #4583).
199204
const findings = lintLivenessProperties({
200205
datasources: [{
201206
name: 'warehouse',
202207
driver: 'postgres',
203208
config: { host: 'db.internal', database: 'analytics' },
204-
capabilities: { transactions: true, queryAggregations: true },
205209
healthCheck: { enabled: true, intervalMs: 30000 },
206210
retryPolicy: { maxRetries: 5, baseDelayMs: 1000 },
207211
}],
208212
});
209213
const msgs = paths(findings);
210-
expect(msgs.some((m) => m.includes('capabilities.transactions'))).toBe(true);
211-
expect(msgs.some((m) => m.includes('capabilities.queryAggregations'))).toBe(true);
212214
expect(msgs.some((m) => m.includes('healthCheck.enabled'))).toBe(true);
213215
expect(msgs.some((m) => m.includes('healthCheck.intervalMs'))).toBe(true);
214216
expect(msgs.some((m) => m.includes('retryPolicy.maxRetries'))).toBe(true);
215217
expect(msgs.some((m) => m.includes('retryPolicy.baseDelayMs'))).toBe(true);
218+
// The removed block must no longer be reported by the lint at all.
219+
expect(msgs.some((m) => m.includes('capabilities'))).toBe(false);
216220
});
217221

218-
// The entry the whole audit was worth doing for. `capabilities.readOnly` reads
219-
// as a safety switch and gates nothing, and two shipped prescriptions pointed
220-
// authors AT it until #4487. The hint has to name the gate that IS enforced,
221-
// or the warning just relocates the author's confusion.
222-
it('warns on capabilities.readOnly and names the real write gate (#4487)', () => {
222+
// The entry the whole audit was worth doing for. `capabilities.readOnly` read
223+
// as a safety switch and gated nothing — a datasource labelled a read replica
224+
// took writes like any other. #4583 REMOVED it rather than warn about it for
225+
// another release, so the check moved up a level: the lint no longer has an
226+
// opinion because the schema refuses the key outright. The prescription that
227+
// replaces the hint is asserted in `packages/spec` (datasource.test.ts), where
228+
// it can also assert the part a hint could not carry — that the enforced gate
229+
// does NOT cover managed datasources (#4584).
230+
it('no longer warns on capabilities.readOnly — the key is gone, not merely flagged (#4583)', () => {
223231
const findings = lintLivenessProperties({
224232
datasources: [{
225233
name: 'reporting',
226234
driver: 'postgres',
227235
config: { host: 'ro.internal', database: 'reporting' },
228-
capabilities: { readOnly: true },
229236
}],
230237
});
231-
const hit = findings.find((f) => f.message.includes('capabilities.readOnly'));
232-
expect(hit).toBeDefined();
233-
expect(hit!.hint).toMatch(/allowWrites/);
238+
expect(findings.some((f) => f.message.includes('capabilities'))).toBe(false);
234239
});
235240

236241
it('stays silent on a datasource that only sets live properties (#4487)', () => {

packages/spec/api-surface.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,8 +290,6 @@
290290
"DataTypeMapping (type)",
291291
"DataTypeMappingSchema (const)",
292292
"Datasource (type)",
293-
"DatasourceCapabilities (const)",
294-
"DatasourceCapabilitiesType (type)",
295293
"DatasourceInput (type)",
296294
"DatasourceSchema (const)",
297295
"DateGranularity (const)",

0 commit comments

Comments
 (0)