Skip to content

Commit 29a47e4

Browse files
Claudeclaude
andcommitted
fix(objectql): hook retryPolicy 的默认值从 schema 派生,解析/未解析两条路径给同一个数 (#6832)
`hook.zod.ts` 声明 `maxRetries: .default(3)` / `backoffMs: .default(1000)`, 而 `hook-wrappers.ts` 用 `?? 0` 读它们 —— 「一个欠配置的 hook 重试几次」有 两个答案,取决于元数据有没有过 `HookSchema`:已解析(defineStack / PUT /meta / Studio)得 3 / 1000,未解析(公开导出 `wrapDeclarativeHook`,以及 hook-binder 自己的调用点)得 0 / 0。这是 #4247 从 flow `errorHandling` 上摘掉的同一形状 (「一份合同一个数字」),换了个面、数字对调,也是 ADR-0049 declared = enforced 要关的那一类。危害是静默且反向的:重试面不重试,不报错、不打日志、不红测试。 执行器现在把两个默认值**从 `HookSchema` 读出来**而不是重述,两条路径按构造 一致,日后给 `hook.retryPolicy` 加第三个键也不需要在执行器这边跟着改。 边界保持不变,并由测试钉死:`retryPolicy` 是 `.optional()` 且没有 `.default({})`,所以「整块缺失」与「块在但键省略」是两种不同的声明 —— 整块缺失仍然是 0 次重试(没声明策略就不该重试),只有 `retryPolicy: {}` / 半填块这一半是缺陷。照抄 `?? 3` 会让每一个从没写过 `retryPolicy` 的 hook 凭空多出三次重试,比原缺陷更严重。 形状选择的测量依据写在 PR 正文:`HookSchema` 拒绝四种 binder 今天照常绑定的 形状(未知键、非法 event、retryPolicy 值/键有误),所以「让 wrapDeclarativeHook 先解析」会把这些 hook 从「绑上并运行」变成「绑不上」,爆炸半径大于缺陷本身 —— 与 hook-binder.ts 上 #4001 注释的裁决一致:约束必须在执行器这边也成立。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S182oxejCaui6gg76z7Zq7
1 parent 73bff86 commit 29a47e4

3 files changed

Lines changed: 276 additions & 2 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): a hook's `retryPolicy` gets the same defaults whether or not its metadata was parsed (#6832)
6+
7+
`HookSchema` declares `retryPolicy.maxRetries` with `.default(3)` and
8+
`retryPolicy.backoffMs` with `.default(1000)`. The executor that actually
9+
performs the retries read both with `?? 0`. So "how many times does an
10+
under-specified hook retry?" had **two answers**, and which one you got depended
11+
on whether the metadata had been through `HookSchema`:
12+
13+
- parsed — `defineStack({ hooks })`, `PUT /meta`, the Studio form — got **3
14+
retries with a 1000ms backoff**, matching the schema, the generated reference
15+
page and the Studio form;
16+
- unparsed — the public `wrapDeclarativeHook` export, and `bindHooksToEngine`'s
17+
own call, which hands it `Hook` metadata verbatim — got **0 and 0**.
18+
19+
The failure was silent and pointed the wrong way: a retry surface that does not
20+
retry raises no error, logs nothing, and fails no test. It just loses the
21+
recovery the author believed they had configured. This is the divergence #4247
22+
removed from flow `errorHandling` ("one contract, one number"), one surface over
23+
and with the numbers swapped, and the `declared = enforced` case ADR-0049 exists
24+
to close.
25+
26+
`wrapDeclarativeHook` now reads both defaults **out of `HookSchema`** instead of
27+
restating them, so the two paths agree by construction and a future key added to
28+
`hook.retryPolicy` needs no matching edit in the executor.
29+
30+
**The boundary, which is deliberately unchanged — read this if you own hooks.**
31+
`retryPolicy` is `.optional()` with no `.default({})`, so an absent block and an
32+
empty one are different declarations, and they stay different:
33+
34+
- **`retryPolicy` omitted entirely → still zero retries.** No policy was
35+
declared, so none is applied. This is the behaviour every existing hook has
36+
today and it does not change. (Making the omitted case default to 3 would have
37+
"fixed" the divergence by silently giving every hook in every existing app
38+
three retries it never asked for — a larger behaviour change than the defect.)
39+
- **`retryPolicy: {}` or a half-filled block → the declared defaults now apply.**
40+
`retryPolicy: {}` means 3 retries / 1000ms; `retryPolicy: { backoffMs: 500 }`
41+
means 3 retries / 500ms. Previously both of these retried zero times on the
42+
unparsed path. If you wrote an empty or partial `retryPolicy` against a host
43+
that does not parse its hook metadata, that hook now retries as its schema,
44+
docs and Studio form have always said it would.
45+
46+
Any value you wrote explicitly still wins outright, including an explicit
47+
`maxRetries: 0`. The backoff remains linear (`backoffMs * attempt`), which is
48+
what the declared shape describes.
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#6832] `hook.retryPolicy` has ONE answer per key, whether or not the metadata
5+
* has been through `HookSchema`.
6+
*
7+
* `packages/spec/src/data/hook.zod.ts` declares `maxRetries: .default(3)` and
8+
* `backoffMs: .default(1000)`. `wrapDeclarativeHook` read both with `?? 0`. So
9+
* "how many times does an under-specified hook retry?" had two answers, and the
10+
* winner depended on the path: `defineStack` / `PUT /meta` / Studio parse through
11+
* `HookSchema` and got 3, while the public `wrapDeclarativeHook` export — and
12+
* `hook-binder.ts:221`, which calls it with unparsed `Hook` metadata — got 0.
13+
* The generated reference page, the Studio form and the schema all promised a
14+
* retry that the executor silently did not perform: no error, no log line, no
15+
* red test, just the recovery the author thought they had configured, gone.
16+
*
17+
* That is the shape #4247 removed from flow `errorHandling` (`flow.zod.ts:651`,
18+
* "one contract, one number") with the numbers swapped, and the ADR-0049
19+
* declared-=-enforced case for closing it.
20+
*
21+
* The boundary this file exists to pin — because the obvious fix breaks it:
22+
* `retryPolicy` is `.optional()` with no `.default({})`, so an ABSENT block and
23+
* an EMPTY one are different declarations. A bare `?? 3` would "fix" the
24+
* divergence by giving every hook that never wrote a `retryPolicy` three retries
25+
* it never asked for — a behaviour change for every existing author, worse than
26+
* the defect. Absent stays 0.
27+
*
28+
* 1. block absent → 1 attempt, no retry (unchanged, and correct)
29+
* 2. block present, empty → schema's 3 retries / 1000ms (was 1 attempt)
30+
* 3. block half-filled → written key wins, omitted key takes the default
31+
* 4. parsed ≡ unparsed → the two paths agree, per key
32+
*/
33+
34+
import { describe, it, expect, vi } from 'vitest';
35+
import { wrapDeclarativeHook } from './hook-wrappers.js';
36+
import { HookSchema } from '@objectstack/spec/data';
37+
import type { Hook, HookContext } from '@objectstack/spec/data';
38+
39+
const taskObject = { name: 'retry_task', label: 'Task', fields: {} };
40+
const qlStub = { getObject: (n: string) => (n === 'retry_task' ? taskObject : undefined) };
41+
42+
function makeCtx(): HookContext {
43+
return {
44+
object: 'retry_task',
45+
event: 'beforeInsert',
46+
input: { data: { title: 'x' } },
47+
ql: qlStub,
48+
} as unknown as HookContext;
49+
}
50+
51+
/**
52+
* Run an always-failing hook through the declarative wrapper and report how many
53+
* times the handler was entered. `attempts === 1 + maxRetries`.
54+
*
55+
* `retryPolicy` is spread in only when supplied, so "absent" really is an absent
56+
* key and not `retryPolicy: undefined`.
57+
*/
58+
async function attemptsFor(retryPolicy?: Hook['retryPolicy']): Promise<number> {
59+
let attempts = 0;
60+
const handler = async () => {
61+
attempts += 1;
62+
throw new Error('always fails');
63+
};
64+
const meta = {
65+
name: 'h_retry',
66+
object: 'retry_task',
67+
events: ['beforeInsert'],
68+
handler,
69+
...(retryPolicy === undefined ? {} : { retryPolicy }),
70+
} as unknown as Hook;
71+
72+
const wrapped = wrapDeclarativeHook(meta, handler, { logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} } });
73+
await expect(wrapped(makeCtx())).rejects.toThrow('always fails');
74+
return attempts;
75+
}
76+
77+
/** The declared defaults, read from the schema so this file restates nothing either. */
78+
function declared(): { maxRetries: number; backoffMs: number } {
79+
return HookSchema.shape.retryPolicy.unwrap().parse({});
80+
}
81+
82+
/**
83+
* Run `fn` with `setTimeout` firing synchronously, and return every delay it was
84+
* asked for. The retry loop's only use of the clock is the backoff sleep, so the
85+
* recorded delays ARE the backoff schedule — read without waiting for it.
86+
*/
87+
async function recordingBackoff(fn: () => Promise<void>): Promise<number[]> {
88+
const slept: number[] = [];
89+
const spy = vi.spyOn(globalThis, 'setTimeout').mockImplementation(((cb: () => void, ms?: number) => {
90+
slept.push(ms ?? 0);
91+
cb();
92+
return 0 as unknown as ReturnType<typeof setTimeout>;
93+
}) as unknown as typeof setTimeout);
94+
try {
95+
await fn();
96+
} finally {
97+
spy.mockRestore();
98+
}
99+
return slept;
100+
}
101+
102+
describe('#6832 — hook.retryPolicy defaults agree across the parsed and unparsed paths', () => {
103+
it('the schema still declares the numbers this file is about', () => {
104+
// If these ever change, the expectations below follow them rather than
105+
// drifting: every assertion reads `declared()`. This one exists so a
106+
// deliberate change to the published contract is visible in the diff.
107+
expect(declared()).toEqual({ maxRetries: 3, backoffMs: 1000 });
108+
});
109+
110+
describe('case 1 — the block is absent: no policy declared, so no retry', () => {
111+
it('does not retry, and does NOT pick up the per-key defaults', async () => {
112+
expect(await attemptsFor(undefined)).toBe(1);
113+
});
114+
115+
it('agrees with the parsed path, which leaves retryPolicy undefined', () => {
116+
const parsed = HookSchema.parse({
117+
name: 'h_retry', object: 'retry_task', events: ['beforeInsert'], handler: async () => {},
118+
});
119+
expect(parsed.retryPolicy).toBeUndefined();
120+
});
121+
});
122+
123+
describe('case 2 — the block is present but empty: both keys take the declared default', () => {
124+
it('retries `maxRetries` times', async () => {
125+
let attempts = 0;
126+
await recordingBackoff(async () => { attempts = await attemptsFor({}); });
127+
expect(attempts).toBe(1 + declared().maxRetries);
128+
});
129+
130+
it('waits the declared backoff between attempts (linear: backoffMs * attempt)', async () => {
131+
const slept = await recordingBackoff(async () => { await attemptsFor({}); });
132+
const base = declared().backoffMs;
133+
// Three retries after a failed first attempt; the backoff is linear
134+
// (`retryBackoffMs * attempt`), which matches the declared shape — there
135+
// is no multiplier or jitter key on `hook.retryPolicy`.
136+
expect(slept).toEqual([base * 1, base * 2, base * 3]);
137+
});
138+
});
139+
140+
describe('case 3 — the block is half-filled: written key wins, omitted key defaults', () => {
141+
it('an omitted `maxRetries` takes the declared count even when `backoffMs` is written', async () => {
142+
expect(await attemptsFor({ backoffMs: 0 })).toBe(1 + declared().maxRetries);
143+
});
144+
145+
it('an omitted `backoffMs` takes the declared delay even when `maxRetries` is written', async () => {
146+
let attempts = 0;
147+
const slept = await recordingBackoff(async () => { attempts = await attemptsFor({ maxRetries: 1 }); });
148+
expect(attempts).toBe(2);
149+
expect(slept).toEqual([declared().backoffMs]);
150+
});
151+
152+
it('a written value still wins outright, including an explicit 0', async () => {
153+
expect(await attemptsFor({ maxRetries: 0, backoffMs: 0 })).toBe(1);
154+
expect(await attemptsFor({ maxRetries: 2, backoffMs: 0 })).toBe(3);
155+
});
156+
});
157+
158+
describe('case 4 — the two paths answer identically, which is the whole point', () => {
159+
for (const [label, policy] of [
160+
['absent', undefined],
161+
['empty', {}],
162+
['half-filled (backoffMs only)', { backoffMs: 0 }],
163+
['half-filled (maxRetries only)', { maxRetries: 1 }],
164+
['fully written', { maxRetries: 2, backoffMs: 0 }],
165+
] as const) {
166+
it(`${label}: unparsed metadata retries as often as the same metadata parsed`, async () => {
167+
const parsed = HookSchema.parse({
168+
name: 'h_retry',
169+
object: 'retry_task',
170+
events: ['beforeInsert'],
171+
handler: async () => {},
172+
...(policy === undefined ? {} : { retryPolicy: policy }),
173+
});
174+
const parsedMax = parsed.retryPolicy?.maxRetries ?? 0;
175+
176+
let attempts = 0;
177+
await recordingBackoff(async () => { attempts = await attemptsFor(policy); });
178+
expect(attempts).toBe(1 + parsedMax);
179+
});
180+
}
181+
});
182+
});

packages/objectql/src/hook-wrappers.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* the metadata-driven behaviours.
1515
*/
1616
import type { Hook, HookContext } from '@objectstack/spec/data';
17+
import { HookSchema } from '@objectstack/spec/data';
1718
import type { Expression } from '@objectstack/spec';
1819
import type { Logger } from '@objectstack/spec/contracts';
1920
import type { HookHandler } from './engine.js';
@@ -67,6 +68,34 @@ const noopLogger: HookDiagnosticsLogger = {
6768
error: () => {},
6869
};
6970

71+
/**
72+
* The values `HookSchema` declares for an omitted `retryPolicy` key — READ from
73+
* the declaration, never restated here (#6832).
74+
*
75+
* `wrapDeclarativeHook` used to answer "how many times does an under-specified
76+
* hook retry?" with a hand-written `?? 0`, while `hook.zod.ts` answered `3` (and
77+
* `1000` for the backoff). Which number you got depended on whether the metadata
78+
* had been through `HookSchema` — `defineStack` / `PUT /meta` / Studio parse and
79+
* got 3, this public export and `hook-binder.ts`'s own call did not and got 0.
80+
* That is verbatim the divergence #4247 removed from flow `errorHandling`, whose
81+
* ruling `flow.zod.ts` still carries: **one contract, one number**. Reading the
82+
* numbers out of the schema is what makes there be only one — a third key added
83+
* to `hook.retryPolicy` needs no edit on this side.
84+
*
85+
* The value is resolved on first use, not at module load: `HookSchema` is a
86+
* `lazySchema` precisely so its closures are never allocated in a process that
87+
* binds no hooks.
88+
*/
89+
let declaredRetryPolicy: { maxRetries: number; backoffMs: number } | undefined;
90+
function retryPolicyDefaults(): { maxRetries: number; backoffMs: number } {
91+
// Parsing `{}` asks the schema what an empty-but-present block means, which is
92+
// exactly the question the executor has to answer. `.unwrap()` steps past the
93+
// `.optional()`; the ABSENCE of the block is a different question, answered at
94+
// the call site.
95+
declaredRetryPolicy ??= HookSchema.shape.retryPolicy.unwrap().parse({});
96+
return declaredRetryPolicy;
97+
}
98+
7099
/**
71100
* A hook declared a `condition` and the platform could not work out its value
72101
* (#4775). Thrown from the condition gate, which aborts the operation.
@@ -308,8 +337,23 @@ export function wrapDeclarativeHook(
308337
}
309338
}
310339

311-
const retryMax = Math.max(0, Number(meta.retryPolicy?.maxRetries ?? 0));
312-
const retryBackoffMs = Math.max(0, Number(meta.retryPolicy?.backoffMs ?? 0));
340+
// `retryPolicy` is `.optional()` with NO `.default({})`, so the block's ABSENCE
341+
// and its EMPTINESS are two different declarations and stay two different
342+
// answers (#6832):
343+
//
344+
// - no `retryPolicy` at all → no retry policy was declared → 0 / 0, and
345+
// the parsed path agrees: `HookSchema` leaves the key `undefined`.
346+
// - `retryPolicy: {}`, or a block with only one key set → the author DID ask
347+
// for retries; each omitted key takes the value the schema declares for it.
348+
//
349+
// Only the second case was broken. #4247's answer — delete the executor's
350+
// fallback and let the parsed default stand — does not transplant here, because
351+
// the whole point is a path that never parses; and its mirror image, a bare
352+
// `?? 3`, would be worse than the defect: every hook that never wrote a
353+
// `retryPolicy` would start retrying three times.
354+
const declaredRetry = meta.retryPolicy ? retryPolicyDefaults() : undefined;
355+
const retryMax = Math.max(0, Number(meta.retryPolicy?.maxRetries ?? declaredRetry?.maxRetries ?? 0));
356+
const retryBackoffMs = Math.max(0, Number(meta.retryPolicy?.backoffMs ?? declaredRetry?.backoffMs ?? 0));
313357
const timeoutMs = typeof meta.timeout === 'number' && meta.timeout > 0 ? meta.timeout : undefined;
314358
const onError = meta.onError ?? 'abort';
315359
// `async` is only meaningful for after* events; ignore on before* (we must

0 commit comments

Comments
 (0)