Skip to content

Commit 4dfd002

Browse files
os-zhuangclaude
andauthored
fix(spec): automation/etl.zod.ts 的九个别名回到 X / XParsed house convention (#4963) (#5514)
* fix(spec): `automation/etl.zod.ts` 的九个别名回到 `X` / `XParsed` house convention (#4963) 裸名翻转为 `z.input`(作者写的形状),新增九个 `*Parsed` = `z.infer`(parse 之后的 形状),与 `shared/retry-policy.zod.ts` 记下的 house convention 一致。 翻转之前九个别名全是 `z.infer`,而本文件有六个带 `.default()` 的键,外加 `schedule` 是 `CronExpressionInputSchema` transform —— 于是 `const p: ETLPipeline = { … }`(三仓零 parse site,这就是本文件唯一的授权门) 根本编译不过。SYNC_ARCHITECTURE.md 的三段示例就是证据,同 PR 修到可编译并加 compiler-API 测试逐字编译它们。 两个 ETL factory 去掉为满足自身返回类型而写的 `enabled: true` 与 cron 预包装。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D * docs(spec): 精确化 etl 别名注释与 changeset 里带默认值的键计数 原文写「六个键」却列了五个具名键 + 一整个 retry 块,内部不自洽;改为逐类点名 (五个具名键 + retry 的五个 + stats 的四个),不再给一个含混的总数。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D * docs(spec): 说清 L3 块被跳过的真实理由,并指向 #5515 原注释说三段 L3 块「两段用 ... 省略」,读起来像第三段没问题;实测第三段 (sapConnector)是完整字面量、报四条诊断,其中三条写的是 schema 会拒收的 键名/取值。已 file 为 #5515,注释直接点名,免得下一个读者以为那是已审阅的豁免。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a9f32df commit 4dfd002

6 files changed

Lines changed: 585 additions & 18 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
**`automation/etl.zod.ts`'s nine type aliases now follow the house `X` / `XParsed`
6+
convention** (#4963): the bare name is `z.input` — what an author writes — and a new
7+
`XParsed` is `z.infer` — what a parse returns.
8+
9+
Until 17 all nine were `z.infer` under the bare name with no `*Parsed` counterpart at
10+
all. On this file that was not a style detail. Five named keys carry `.default()`
11+
(`ETLDestination.writeMode`, `ETLTransformation.continueOnError`, `ETLPipeline.syncMode`
12+
/ `.enabled`, `ETLSource.incremental.enabled`), as does every key of
13+
`ETLPipeline.retry` and of `ETLPipelineRun.stats`, and `schedule` is a
14+
`CronExpressionInputSchema` transform whose *output* is the `{ dialect, source }`
15+
envelope. Under `z.infer` all of them were REQUIRED and a bare cron string was rejected — so the single use this file has, `const p: ETLPipeline = { … }`
16+
written by hand, did not compile. That is the whole authoring door: `etl.zod.ts` has no
17+
parse site in objectstack / objectui / cloud, so the exported schema and the exported
18+
type are the only surface an author touches.
19+
20+
The evidence was checked in. `packages/spec/docs/SYNC_ARCHITECTURE.md` carried three
21+
`ETLPipeline` examples, none of which compiled, and both `ETL` factories were spelling
22+
out defaults and pre-wrapping their cron purely to satisfy their own return type.
23+
24+
### Migration
25+
26+
**Zero importers across objectstack, objectui and cloud** (re-measured against each
27+
repo's `origin/main` for this change), so the migration surface is empty. If you have a
28+
local consumer:
29+
30+
| You wrote | Keep it if | Change it to |
31+
|:---|:---|:---|
32+
| `const p: ETLPipeline = { … }` | you are AUTHORING a pipeline literal | nothing — this is the case that now compiles |
33+
| `const p: ETLPipeline = ETLPipelineSchema.parse(raw)` || `const p: ETLPipelineParsed = …` |
34+
| `function run(p: ETLPipeline)` reading `p.syncMode` as always-present || `ETLPipelineParsed` |
35+
36+
The same rename applies to each of the nine: `ETLEndpointType`, `ETLSource`,
37+
`ETLDestination`, `ETLTransformationType`, `ETLTransformation`, `ETLSyncMode`,
38+
`ETLPipeline`, `ETLRunStatus`, `ETLPipelineRun` — append `Parsed` wherever the annotated
39+
value came out of a `.parse()`. The four enum aliases are unaffected in practice
40+
(`z.input` and `z.infer` coincide for an enum); their pair exists so a reader never has
41+
to know which of the nine has defaults before choosing an annotation.
42+
43+
Nothing at runtime moves: no schema, default, bound or key changed, and both `ETL`
44+
factories still produce documents that parse to the same result. `ETL.databaseSync` /
45+
`ETL.apiToDatabase` no longer restate `enabled: true` and no longer pre-wrap a bare cron
46+
string into `{ dialect: 'cron', source }` — the schema does that at parse, which is where
47+
it always belonged. Each helper still states what it DECIDES (`incremental` + `upsert`
48+
vs `full` + `append`), because that contrast is the reason the pair exists.
49+
50+
`SYNC_ARCHITECTURE.md`'s three examples now compile, and a compiler-API test
51+
(`etl-author-shape.test.ts`) compiles them verbatim on every run — import line included —
52+
so they cannot rot again silently.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -691,7 +691,7 @@ column does not move and the `strip` column falls by the count of what left.
691691
| File | Class | Note |
692692
|---|---|---|
693693
| `flow.zod.ts` | authorable | **strict as of #4001** — the four outer authoring shapes at step 1, and **the six nested blocks at batch 11** (`FlowNode.connectorConfig` / `.position` / `.inputSchema` / `.waitEventConfig` / `.boundaryConfig`, `Flow.errorHandling`). The gap between those two dates is this campaign's own finding 17 inside its own file: closing the shells left the gate rejecting `nodee:` at node level while `connectorConfig: { connectorId, actionId, params: {…} }` parsed clean and the executor dispatched `input ?? {}` — a successful connector call carrying nothing. Worth recording precisely, because the obvious example is the wrong one: a slip on a REQUIRED key was always loud (it then reads as missing). What `.strip` swallowed here is the OPTIONAL half — the input map, the retry budget, `interrupting: false`, `required: true` — i.e. exactly the keys an author adds to CONSTRAIN behaviour, replaced by a permissive default without a word. `Flow.errorHandling` gained a second chapter at **#4964**: closing it in 批 11 revealed (rather than caused) that its retry keys were a THIRD encoding of the policy #4661 had converged — it spelled the base delay `retryDelayMs` where the shared declaration spells it `backoffMs` and tombstones the old word, so the strictness this row records was, for one release, rejecting an author for having read the newer file. The block now builds from `retryPolicyShape()`. Site count unchanged; only the vocabulary. Two things stay open and are now pinned in code with the reason, so a later sweep stops rather than "finishes" the file: the node `config` slot (ADR-0018 plugin namespace) and `FlowVersionHistorySchema` (the file's only WIRE shape — emitted on publish, never authored; its `definition` is `FlowSchema`, so the authored half inside a history record is gated anyway) |
694-
| `etl.zod.ts` | mixed | **7 strict as of #4001 批 12** — the authoring half (`ETLSource` + `.incremental`, `ETLDestination`, `ETLTransformation`, `ETLPipeline` + `.retry` + `.notifications`). The other 3 — `ETLPipelineRun` + `.stats` + `.error` — are **deliberately left open**: engine-emitted run state (an id it minted, a status it reached, counters it accumulated), same disposition and same reason as `FlowVersionHistorySchema` above and all of `execution.zod.ts`. The exemption is recorded on the schema itself, not only here, because a note only this file carries is a note the next sweep does not read. The old blanket `authorable (p)` was too wide; verification split it. ⚠️ **Read the classification caveat before reusing this verdict**: `etl.zod.ts` has NO parse site in objectstack / objectui / cloud, so neither half could be settled by pointing at a live call. The 7 are authorable because the exported schema and type ARE the door (`SYNC_ARCHITECTURE.md` and the module's `@example` both hand-write `const p: ETLPipeline = { … }`) — the `webhook.zod.ts` posture. The 3 are wire on the shape's semantics plus settled precedent, NOT on an emit site anyone can point at today; if an ETL engine ever lands and a run result turns out to be operator-authored, that verdict is the one to revisit. Two out-of-scope findings were filed rather than fixed here; **the first is now closed**: the `retry` block was a third retry-policy vocabulary #4661's convergence never reached (#4962 — converged onto `shared/RetryPolicySchema` in the v17 window, together with `flow.errorHandling` (#4964), the fourth. Both were anonymous inline blocks, so the dual-source instrument that drove #4661 could not see them: it asks how many declarations share an exported NAME, and neither has one. 批 12's five curated `retry` entries described that divergence and dissolved with it — the block's site count is unchanged, only its vocabulary). Still open: all nine type aliases export the parsed shape under the bare name, which is why the SYNC_ARCHITECTURE.md pipeline examples do not compile (#4963). **−12 at #4738**: `sync.zod.ts` (the L1 "Simple Sync" file — `DataSyncConfig`, its `ConflictResolution` enum and satellites, formerly this row's co-candidate) was deleted whole rather than hardened: three-repo zero importers, no parse site, defs unreachable from the metadata-type roots (#4650 gate), so there was no author for strictness to protect (#4535 C13+C15). The integration-side `ConflictResolution` → `ConnectorConflictResolution` rename in the same change is name-only and moves no sites |
694+
| `etl.zod.ts` | mixed | **7 strict as of #4001 批 12** — the authoring half (`ETLSource` + `.incremental`, `ETLDestination`, `ETLTransformation`, `ETLPipeline` + `.retry` + `.notifications`). The other 3 — `ETLPipelineRun` + `.stats` + `.error` — are **deliberately left open**: engine-emitted run state (an id it minted, a status it reached, counters it accumulated), same disposition and same reason as `FlowVersionHistorySchema` above and all of `execution.zod.ts`. The exemption is recorded on the schema itself, not only here, because a note only this file carries is a note the next sweep does not read. The old blanket `authorable (p)` was too wide; verification split it. ⚠️ **Read the classification caveat before reusing this verdict**: `etl.zod.ts` has NO parse site in objectstack / objectui / cloud, so neither half could be settled by pointing at a live call. The 7 are authorable because the exported schema and type ARE the door (`SYNC_ARCHITECTURE.md` and the module's `@example` both hand-write `const p: ETLPipeline = { … }`) — the `webhook.zod.ts` posture. The 3 are wire on the shape's semantics plus settled precedent, NOT on an emit site anyone can point at today; if an ETL engine ever lands and a run result turns out to be operator-authored, that verdict is the one to revisit. Two out-of-scope findings were filed rather than fixed here; **the first is now closed**: the `retry` block was a third retry-policy vocabulary #4661's convergence never reached (#4962 — converged onto `shared/RetryPolicySchema` in the v17 window, together with `flow.errorHandling` (#4964), the fourth. Both were anonymous inline blocks, so the dual-source instrument that drove #4661 could not see them: it asks how many declarations share an exported NAME, and neither has one. 批 12's five curated `retry` entries described that divergence and dissolved with it — the block's site count is unchanged, only its vocabulary). **The second is now closed too**: all nine type aliases exported the PARSED shape under the bare name with no `*Parsed` counterpart, so the authoring door this row's whole classification rests on — `const p: ETLPipeline = { … }` — did not actually compile, and the SYNC_ARCHITECTURE.md examples proving it were the evidence (#4963 — bare names flipped to `z.input`, nine `*Parsed` added, house convention per `shared/retry-policy.zod.ts`; three-repo zero importers made the migration surface empty). Worth carrying forward for the next classification: **"authorable because the exported type IS the door" is a claim about a type that must be checked, not assumed** — 批 12 read the door correctly and nobody compiled it. **−12 at #4738**: `sync.zod.ts` (the L1 "Simple Sync" file — `DataSyncConfig`, its `ConflictResolution` enum and satellites, formerly this row's co-candidate) was deleted whole rather than hardened: three-repo zero importers, no parse site, defs unreachable from the metadata-type roots (#4650 gate), so there was no author for strictness to protect (#4535 C13+C15). The integration-side `ConflictResolution` → `ConnectorConflictResolution` rename in the same change is name-only and moves no sites |
695695
| `execution.zod.ts` | wire | run-state envelopes — never strict. +5 at #4354 (the run-summary family: step metrics / skip reason / per-node / per-gate / the summary itself) — engine-emitted telemetry read by the Console and by operator queries, nobody authors them, so the `wire` verdict covers them unchanged |
696696
| `state-machine.zod.ts` | authorable | **strict as of #4001 批 10** — all six sites (`ActionRef` / `GuardRef` / `Transition` / `StateNode` + `.meta` / `StateMachine`). **The `(p)` was NOT a formality here.** ADR-0020 retired this XState shape as a *record-lifecycle* declaration — the top-level `workflow` metadata type and `object.stateMachines` are both gone, and a record's transitions live on the `state_machine` VALIDATION RULE instead — so had those been the only doors this file would be DEAD surface, and the correct action would have been to fix its class, not close it. One authoring door survives: `ai/agent.zod.ts`'s `lifecycle` is `StateMachineSchema`, and `agent` is a registered type, so `defineStack({ agents })` / meta REST / the Studio agent form all reach here through `AgentSchema.parse()`. Verified by parse: an agent whose lifecycle carried `stats`, a state with `onn` (one keystroke from `on`) and a `meta` with two unknown keys **parsed clean**, returning a machine with NO transitions at all — the declaration whose whole job is to deny undeclared transitions, silently emptied and reported valid. `.meta` was checked for the #4909 open-slot case and is CLOSED: the hand-written `StateNodeConfig` type declares exactly its four keys (passthrough would open the Zod while `tsc` stayed shut), nothing in the repo reads any `meta` key, and the prior behaviour was strip — an author's `meta` arrived as `{}` — so there was no openness to preserve. ⚠️ `ActionRef` / `GuardRef` are UNIONS: a strict branch's message does not reach the top (zod raises one `invalid_union` whose message is the literal `"Invalid input"`, with the real prescription nested in `issue.errors[]`), which `formatZodError` then flattens away — filed, not fixed here. **−1 at #4658**: the orphan `EventSchema` (`{ type, schema }`, an XState-style signal declaration nothing referenced — `StateMachineSchema` names event types as `on:` record keys) was deleted rather than converged with `kernel/events/core.zod.ts`'s envelope `EventSchema`, whose key set it did not intersect (#4535 C6). The remaining 6 sites and their verdict are unchanged |
697697
| `control-flow.zod.ts` | authorable | **strict as of #4001 批 10** — all five sites (`FlowRegion` / `Loop` / `ParallelBranch` / `Parallel` / `TryCatch`). The `(p)` resolves to authorable on the executors' own parse seam (`parseNodeConfig`, #4277) plus `validateControlFlow`'s region parse. **`validateControlFlow` is a sibling guard, not a key gate, and the two do not fight**: it answers single-entry / single-exit / acyclic, which no key check can decide, and the schema answers key membership, which no structural check can decide. They meet at exactly one seam — the guard `safeParse`s each region slot before analyzing it, so an undeclared region key now surfaces there as `<where>: invalid region — <the strictObject message>`, the guard's framing wrapping the schema's prescription. Nothing was duplicated and nothing removed; the guard simply stopped silently repairing its own input before judging it. Two curation entries had to be MEASURED rather than reasoned: the bare edit-distance fallback answers `itemVariable` with **`indexVariable`** — binding the loop INDEX where the author wanted the ITEM — so the alias exists to overrule a confidently wrong suggestion from this campaign's own helper (the `pii` → `min` shape, third instance); and `join`/`joinGateway` needed two DISTINCT prescriptions because `guidance` emits one bullet per key verbatim, so a shared string printed the same paragraph twice. Its test instrument also had to be rebuilt: `region-slots.test.ts` probed every construct with every candidate key at once and depended on `.strip` to discard the mismatches, so it returned "no schema accepts any region" the moment the shapes closed — it failed loudly, which is the only reason this is a footnote and not a fourth finding-3. Structural validation by `validateControlFlow` remains. **−1 at #4661**: `RetryPolicySchema` moved out to `shared/retry-policy.zod.ts` — `./automation` and `./system` published the same name for two different declarations (#4411), so the retry policy converged onto one. The site still exists and is still non-strict and authorable; it is simply no longer in a directory this ledger sections. ⚠️ That is a coverage gap worth knowing about: this audit sections `ui/` / `data/` / `automation/` / `security/` / `studio/` only, so a `shared/` shape is unaudited by construction. The tolerance is deliberate here — the `retryDelayMs` → `backoffMs` rename is tombstoned via `retiredKey()` precisely because a non-strict parent would otherwise swallow the old spelling. **#4964 widened that rename to `flow.errorHandling`**, which spelled the base delay the pre-17 way while the shared policy tombstoned it — so the two automation retry surfaces now teach the same word, and the tombstone's prescription names all four surfaces instead of the two #4661 could see |

packages/spec/api-surface.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2106,22 +2106,31 @@
21062106
"DeleteRecordConfigSchema (const)",
21072107
"ETL (const)",
21082108
"ETLDestination (type)",
2109+
"ETLDestinationParsed (type)",
21092110
"ETLDestinationSchema (const)",
21102111
"ETLEndpointType (type)",
2112+
"ETLEndpointTypeParsed (type)",
21112113
"ETLEndpointTypeSchema (const)",
21122114
"ETLPipeline (type)",
2115+
"ETLPipelineParsed (type)",
21132116
"ETLPipelineRun (type)",
2117+
"ETLPipelineRunParsed (type)",
21142118
"ETLPipelineRunSchema (const)",
21152119
"ETLPipelineSchema (const)",
21162120
"ETLRunStatus (type)",
2121+
"ETLRunStatusParsed (type)",
21172122
"ETLRunStatusSchema (const)",
21182123
"ETLSource (type)",
2124+
"ETLSourceParsed (type)",
21192125
"ETLSourceSchema (const)",
21202126
"ETLSyncMode (type)",
2127+
"ETLSyncModeParsed (type)",
21212128
"ETLSyncModeSchema (const)",
21222129
"ETLTransformation (type)",
2130+
"ETLTransformationParsed (type)",
21232131
"ETLTransformationSchema (const)",
21242132
"ETLTransformationType (type)",
2133+
"ETLTransformationTypeParsed (type)",
21252134
"ETLTransformationTypeSchema (const)",
21262135
"ExecutionError (type)",
21272136
"ExecutionErrorParsed (type)",

packages/spec/docs/SYNC_ARCHITECTURE.md

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,19 @@ Advanced data pipelines for complex transformations, multi-source aggregation, a
7777

7878
### Example
7979

80+
> **`ETLPipeline` is the AUTHOR shape.** It is `z.input` of `ETLPipelineSchema`
81+
> (#4963, the house `X` / `XParsed` convention), so every key carrying a
82+
> `.default()``syncMode`, `enabled`, `destination.writeMode`, a
83+
> transformation's `continueOnError`, `source.incremental.enabled` — is optional
84+
> when you write a pipeline, and `schedule` takes the bare cron string the
85+
> schema wraps for you. Annotate the **result** of
86+
> `ETLPipelineSchema.parse(…)` with **`ETLPipelineParsed`**, where those same
87+
> keys are all present. The example below states them anyway, because it is a
88+
> tour of the surface; the Migration Guide's examples omit them, because that is
89+
> what ordinary authoring looks like.
90+
8091
```typescript
81-
import { ETLPipeline } from '@objectstack/spec/automation';
92+
import type { ETLPipeline } from '@objectstack/spec/automation';
8293

8394
const dataWarehousePipeline: ETLPipeline = {
8495
name: 'customer_360_pipeline',
@@ -377,24 +388,36 @@ const connector: Connector = {
377388

378389
**After (L2):**
379390
```typescript
391+
import type { ETLPipeline } from '@objectstack/spec/automation';
392+
380393
const pipeline: ETLPipeline = {
381394
name: 'order_analytics_pipeline',
382-
source: { type: 'api', connector: 'orders' },
395+
source: { type: 'api', connector: 'orders', config: { endpoint: '/orders' } },
383396
transformations: [
384397
{ type: 'aggregate', config: { groupBy: ['customer_id'] } }
385398
],
386399
destination: { type: 'database', config: { table: 'analytics_order' } }
387400
};
388401
```
389402

403+
Every endpoint carries a `config` bag — it is the one required key besides
404+
`type`, and it is where endpoint-specific settings (`table`, `endpoint`, `path`,
405+
`format`) live. `syncMode`, `enabled`, `destination.writeMode` and the
406+
transformation's `continueOnError` are omitted on purpose: they have defaults,
407+
and `ETLPipeline` is the author shape.
408+
390409
### From L2 to L3
391410

392411
When your ETL pipeline needs webhooks, advanced auth, or rate limiting:
393412

394413
**Before (L2):**
395414
```typescript
415+
import type { ETLPipeline } from '@objectstack/spec/automation';
416+
396417
const pipeline: ETLPipeline = {
397-
source: { type: 'api', connector: 'external_api' }
418+
name: 'external_api_ingest',
419+
source: { type: 'api', connector: 'external_api', config: { endpoint: '/events' } },
420+
destination: { type: 'database', config: { table: 'external_events' } }
398421
};
399422
```
400423

0 commit comments

Comments
 (0)