Skip to content

Commit 9ca2d85

Browse files
os-zhuangclaude
andauthored
feat(spec)!: 退休 datasource.readReplicas —— 声明了、strict 了、刚被加了校验,但没有任何东西打开过副本连接 (#4468) (#4481)
* docs(agents): claim the issue before writing any code Several agents work this repo at once and an unassigned issue reads as an open invitation. Two agents starting the same issue burn the same hours twice and then race to land conflicting shapes for one problem. Makes assignment the first action of a task — before the worktree, before the first read — and states the corollary for Prime Directive #10: file a finding unassigned, assign it at the moment you actually start, so the issue list works as a queue other agents can trust. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WsgTqRF58HsQYKLsrZ5pQY * feat(spec)!: retire datasource.readReplicas (#4468) BREAKING CHANGE: `datasource.readReplicas` is removed. It described replica connections nothing ever opened. `ConnectableDatasource` and `DatasourceConnectionSpec` carry no replicas field, the driver factory never reads the key, and no query path distinguishes a read from a write — the platform has no read/write splitting at all, so every statement always went to the primary regardless of what was declared. There is no target to move to, because there is no read-replica routing to move to. Front replicas behind one endpoint (pgpool, ProxySQL, an RDS reader endpoint) and point `config` there; `os migrate meta --from 16` strips the key. Worth recording why this one survived so long: #4410 closed the `datasource.config` gap and, reasonably, extended the new per-driver validation over each `readReplicas` entry. The result was a slot with every marker of a working feature — declared, `.strict()`-guarded, and validated field-by-field against the driver's contract, rejecting a misspelt replica host by index. None of that is evidence of a consumer and all of it reads like one. Rigor is cheap to add to a dead slot and expensive to tell apart from life, which is why ADR-0049 asks for a consumer rather than for rigor. The retirement kit: - DatasourceSchema: key deleted (strict route), `readReplicas`/`replicas` added to the guidance map so the rejection carries the prescription; the #4410 validation loop removed with it - `datasource-read-replicas-removed` D2 conversion + step-17 chain wiring, retired from the load path like the other keys retired for misdescribing themselves - authorable-surface baseline line deleted deliberately (gate (a)'s strict- removal trip wire); spec-changes, upgrade guide, reference docs regenerated - pin test flipped from "validates each entry" to "rejects the slot" - release notes, strictness ledger, and the two pending changesets that still described the key corrected Follow-ups filed: #4479 (read-replica routing as a real feature request, starting from the read/write decision point) and #4480 (the same feature declared a second time as `DatabaseConnector.readReplicaConfig`, also unread). Closes #4468 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WsgTqRF58HsQYKLsrZ5pQY --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2826d1e commit 9ca2d85

16 files changed

Lines changed: 256 additions & 50 deletions

.changeset/datasource-config-driver-contract.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ package. So `config: { hostname: 'db.internal' }` (the key is `host`) was
1313
accepted in silence and the datasource connected to `localhost` while the parse,
1414
the save and the connection probe all reported success.
1515

16-
`DatasourceSchema` now parses `config` — and each `readReplicas` entry — against
16+
`DatasourceSchema` now parses `config` against
1717
the contract for the declared driver, and `DatasourceAdminService`
1818
(create/update/test, the Setup wizard's path) applies the same check. Both read
1919
one registry in `@objectstack/spec/data`, which also projects each contract to
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
'@objectstack/spec': major
3+
---
4+
5+
`datasource.readReplicas` is removed (#4468, ADR-0049 enforce-or-remove)
6+
7+
It described replica connections nothing ever opened. `ConnectableDatasource`
8+
and `DatasourceConnectionSpec` carry no replicas field, the driver factory never
9+
reads the key, and no query path distinguishes a read from a write — the
10+
platform has no read/write splitting at all, so every statement always went to
11+
the primary no matter what was declared here.
12+
13+
**Migration.**
14+
15+
| Wrote | Write instead |
16+
| --- | --- |
17+
| `readReplicas: [{ host: 'replica-a', … }]` | delete the key |
18+
| `replicas: [ … ]` (the alias) | delete the key |
19+
20+
There is no target to move to, because there is no read-replica routing to move
21+
to. If you need replica reads today, front them behind a single endpoint —
22+
pgpool, ProxySQL, an RDS reader endpoint — and point `config` at that endpoint.
23+
That is the one read-scaling path that works, and it worked before this key was
24+
removed too.
25+
26+
Run `os migrate meta --from 16` to strip it from your sources; the
27+
`datasource-read-replicas-removed` conversion emits one notice per datasource.
28+
Authoring it now fails the parse with the same prescription.
29+
30+
**Why this one is worth reading about.** #4410 closed the `datasource.config`
31+
gap and, in passing, extended the new per-driver validation over each
32+
`readReplicas` entry — reasonably, since replicas carry the same shape. The
33+
result was a slot that had every marker of a working feature: declared with a
34+
doc comment, `.strict()`-guarded against typos at the top level, and
35+
field-by-field validated against the driver's contract underneath. A replica
36+
block with a misspelt `hostname` was rejected by index, naming the canonical
37+
key.
38+
39+
None of that is evidence of a consumer, and all of it reads like one. That is
40+
the specific trap ADR-0049 exists for: rigor is cheap to add to a dead slot and
41+
expensive to distinguish from life. Two independent surfaces had drawn the
42+
wrong conclusion — this validation, and objectui's datasource preview, which
43+
rendered a "2 read replicas" pill confirming the config to the author while
44+
nothing routed a single read. The preview goes with the key (objectui side,
45+
same change); `packages/spec/liveness/README.md` has the standing rule it
46+
violated ("an authoring/preview renderer is NOT a runtime consumer").
47+
48+
Read-replica routing remains unbuilt. It is tracked as a feature request rather
49+
than left as a schema key that looks like one.

.changeset/unknown-key-strictness-data-step.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,12 @@ Deliberately still tolerant:
2626
runtime shape the engine hands a handler. Strictness there would turn an
2727
engine-internal enrichment (as `provenance` was in #3712) into a breaking
2828
change for anyone parsing a context they were given.
29-
- `datasource.config` and `readReplicas` — per-driver by construction; the
30-
driver's own `configSchema` validates them.
29+
- `datasource.config` — per-driver by construction (a sqlite `filename` and a
30+
postgres `host`/`port` share no shape). Left open here and closed one level
31+
down instead: #4410 parses it against the contract for the declared driver.
32+
This bullet used to say "the driver's own `configSchema` validates them",
33+
which was not true when it was written — the field existed and nothing read
34+
it.
3135

3236
Errors are self-fixing: connection keys written one level too high (`host`,
3337
`port`, `filename`, `url`, …) are prescribed into `config`; a top-level

AGENTS.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,24 @@ this is mandatory, not a preference (Prime Directive #11), and a PreToolUse hook
106106
blocks edits made while on the shared `main` branch. Working in the shared `main`
107107
checkout is *not* a supported fallback: branches get switched and shared files —
108108
including ones you just wrote — get reset *under you* mid-task (a full session's
109-
work was silently reverted twice before this rule was enforced). Even inside your
110-
own worktree, operate defensively:
109+
work was silently reverted twice before this rule was enforced).
110+
111+
**Claim the issue BEFORE you write any code.** Assign it to yourself
112+
(`gh issue edit <n> --add-assignee @me`, or the `issue_write` MCP tool with
113+
`assignees`) as the *first* action of the task — before the worktree, before the
114+
first read. An unassigned issue reads as an open invitation, and several agents
115+
work this repo at once: two that both start on it burn the same hours twice and
116+
then race to land conflicting shapes for the same problem, which is worse than
117+
either one alone. If it is already assigned to someone else it is taken — pick
118+
another, or say so and ask; never reassign it to yourself.
119+
120+
The claim is also what makes the *finding* rule (Prime Directive #10) safe to
121+
follow. Once out-of-scope discoveries become issues, the issue list is a real
122+
queue other agents read, and a claim is the only thing separating "someone is on
123+
this" from "nobody has looked yet". File it unassigned when you are merely
124+
recording a finding; assign it at the moment you actually start.
125+
126+
Even inside your own worktree, operate defensively:
111127

112128
1. **Only touch the files your task needs.** Don't "fix" unrelated diffs,
113129
reverts, or other agents' in-flight edits, and don't try to manage the whole

CLAUDE.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
11
# CLAUDE.md
22

33
**[AGENTS.md](./AGENTS.md) is the source of truth for working in this repo — read it.**
4-
Its Prime Directives are binding. Do not rely on this file alone; the one rule that must
5-
never be missed is inlined here because missing it corrupts other agents' work.
4+
Its Prime Directives are binding. Do not rely on this file alone; the two rules that must
5+
never be missed are inlined here because missing either one wastes or corrupts other
6+
agents' work.
7+
8+
## ⛔ Claim the issue before you write any code
9+
10+
Assign the issue to yourself (`gh issue edit <n> --add-assignee @me`, or `issue_write`
11+
with `assignees`) as the **first action of the task** — before the worktree, before the
12+
first read. Several agents work this repo at once and an unassigned issue reads as an
13+
open invitation: two that both start on it burn the same hours twice, then race to land
14+
conflicting shapes for one problem. Already assigned to someone else? It is taken — pick
15+
another or ask; never reassign it to yourself. File findings unassigned when you are only
16+
recording them; assign at the moment you start.
617

718
## ⛔ Worktree-first — before your FIRST file edit (AGENTS.md Prime Directive #11)
819

content/docs/references/data/datasource.mdx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ const result = Datasource.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-
| **readReplicas** | `Record<string, any>[]` | optional | Read-only replica configurations |
4039
| **capabilities** | `{ transactions: boolean; queryFilters: boolean; queryAggregations: boolean; querySorting: boolean; … }` | optional | Capability overrides |
4140
| **healthCheck** | `{ enabled: boolean; intervalMs: number; timeoutMs: number }` | optional | Datasource health check configuration |
4241
| **ssl** | `{ enabled: boolean; rejectUnauthorized: boolean; ca?: string; cert?: string; … }` | optional | SSL/TLS configuration for secure database connections |

content/docs/releases/v17.mdx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -412,12 +412,13 @@ schema to the two highest-risk authorable surfaces, per the triage in
412412
- **Datasources**`DatasourceSchema` with its `pool` / `healthCheck` / `ssl` /
413413
`retryPolicy` blocks, the ADR-0015 `external` federation settings and their
414414
`validation` policy, `DatasourceCapabilities`, and `DriverDefinitionSchema`.
415-
`config` and `readReplicas` stay **open** records: their shape is per-driver.
416-
Nothing validates *inside* them — an earlier version of this note said the
417-
driver's own `configSchema` did, which was wrong; the per-driver schemas exist
418-
(`PostgresConfigSchema` and siblings) but nothing parses `config` against
419-
them, tracked as #4410. So a misspelling one level *down* is still silent
420-
today. That openness is why the
415+
`config` stays an **open** record: its shape is per-driver. What it no longer
416+
is, is unvalidated — #4410 wired the per-driver schemas
417+
(`PostgresConfigSchema` and siblings) into `DatasourceSchema`'s refinement, so
418+
a misspelling one level *down* is now rejected with the canonical key named.
419+
An earlier version of this note said the driver's own `configSchema` did that,
420+
which was wrong for two releases: the field existed, nothing read it. That
421+
openness is why the
421422
top level had to close — a connection key written one level too high (`host`
422423
next to `driver` instead of inside `config`) was stripped, and the datasource
423424
then connected on driver defaults rather than failing. Those keys now
@@ -1014,6 +1015,7 @@ import or the authored key.
10141015
| `DEFAULT_DISPATCHER_ROUTES` | dead route table |
10151016
| Aspirational config on Theme / Translation / Webhook | still-dead after #3494 |
10161017
| `ChartInteraction.zoom` / `.clickAction` | never implemented (#3752) |
1018+
| `datasource.readReplicas` | replica connections nothing ever opened — no driver reads the key and no query path splits reads from writes, so every statement went to the primary. #4410 had just taught the schema to validate each entry against the declared driver's contract, which made a dead slot look rigorously alive (#4468) |
10171019

10181020
The Console side follows: `@object-ui/types` drops its
10191021
`ObjectStack`/`ObjectOS`/`ObjectQL`/`ObjectUI` Capabilities re-exports, which
@@ -2034,7 +2036,13 @@ covers are folded into the list below rather than left to the changelog.)
20342036
exit code to assert on: nothing left to do exits `0`.
20352037
- **Datasources:** verify every declared datasource connects in every
20362038
environment — a bound datasource that cannot connect now fails the boot
2037-
instead of failing every later query.
2039+
instead of failing every later query. Delete `readReplicas` (`os migrate meta`
2040+
does it). Nothing ever opened those connections, so read throughput is
2041+
unchanged by removing them; if you need replica reads, front them behind one
2042+
endpoint (pgpool, ProxySQL, an RDS reader endpoint) and point `config` there.
2043+
Also re-check what you wrote under `config`: it is parsed against the driver's
2044+
contract now, so a key that used to be ignored — and left the datasource on
2045+
driver defaults — is rejected by name.
20382046
- **Sharing rules:** rewrite `sharedWith.type: 'group'` → `'team'`; drop `guest`
20392047
and owner-type rules; expect `accessLevel: 'full'` to convert to `'edit'`.
20402048
**A rule must state its criteria** — authoring one without is rejected, and a

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,8 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
184184
| `field.zod.ts` | 11 | authorable | partially strict |
185185
| `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 |
186186
| `driver-nosql.zod.ts` / `driver.zod.ts` / `driver-sql.zod.ts` | 10+9+2 | wire | driver capability contracts |
187-
| `datasource.zod.ts` | 9 | authorable | **strict as of #4001 data step** — all 9: `DatasourceSchema` (+ `pool` / `healthCheck` / `ssl` / `retryPolicy`), `ExternalDatasourceSettingsSchema` (+ `validation`), `DatasourceCapabilities`, `DriverDefinitionSchema`. `config` + `readReplicas` stay `z.record` **at this level** by construction (per-driver shapes), but are no longer unchecked: **#4410** made `DatasourceSchema`'s refinement parse both 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 |
188-
| `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` (and each `readReplicas` entry) against them, and the same schemas project onto `configSchema` and onto the Studio connection form. `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 |
187+
| `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 |
188+
| `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 |
189189
| `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) |
190190
| `analytics.zod.ts` | 8 | mixed (p) | |
191191
| `document.zod.ts` | 8 | wire (p) | |

0 commit comments

Comments
 (0)