@@ -6,6 +6,9 @@ import type { NodeExecutor } from '../engine.js';
66import { InMemorySuspendedRunStore } from '../suspended-run-store.js' ;
77import { registerWaitNode , parseIsoDuration , rearmSuspendedWaitTimers } from './wait-node.js' ;
88import type { IJobService , JobHandler , JobSchedule } from '@objectstack/spec/contracts' ;
9+ // #6758 — the wait tombstone's prescription is checked against BOTH gates an
10+ // author's value must clear: the spec schema and `parseIsoDuration` above.
11+ import { FlowNodeSchema } from '@objectstack/spec/automation' ;
912
1013function silentLogger ( ) {
1114 return { info ( ) { } , warn ( ) { } , error ( ) { } , debug ( ) { } , child ( ) { return silentLogger ( ) ; } } as any ;
@@ -74,6 +77,90 @@ describe('parseIsoDuration', () => {
7477 } ) ;
7578} ) ;
7679
80+ /**
81+ * #6758 — the spec's own upgrade prescription, EXECUTED rather than read.
82+ *
83+ * `waitEventConfig.timeoutMs` is a #4158 tombstone whose message tells an
84+ * upgrading author which `timerDuration` value to write instead. That value has
85+ * to survive TWO gates, and each gate is blind to the other's failure:
86+ *
87+ * 1. **The schema.** `timerDuration` is `z.string()`, so an unquoted number is
88+ * TS2322 at the authoring site and `expected string, received number` at
89+ * the parse. This is what the message printed until #6758 — `parseIsoDuration`
90+ * below does accept a bare JS number, which is where the wrong wording came
91+ * from, but no number can REACH it through `timerDuration`.
92+ * 2. **This reader.** `z.string()` takes any string at all, so schema-green
93+ * cannot tell `'60000'` from `'about a minute'` (the latter parses to
94+ * `undefined` here and silently waits on nothing).
95+ *
96+ * The spec package can only check gate 1 — it does not depend on this one — so
97+ * the round trip is pinned here, where both halves are importable. Every value
98+ * is EXTRACTED from the live message, never compared to a copy: a hard-coded
99+ * `'60000'` would go green the moment someone reworded the prose, which is
100+ * exactly when this needs to be checked.
101+ */
102+ describe ( "the spec's `timeoutMs` → `timerDuration` prescription round-trips (#6758)" , ( ) => {
103+ const waitNode = ( waitEventConfig : Record < string , unknown > ) => ( {
104+ id : 'w' , type : 'wait' , label : 'Wait' , waitEventConfig,
105+ } ) ;
106+ const issueMessage = ( waitEventConfig : Record < string , unknown > , code : string ) => {
107+ const result = FlowNodeSchema . safeParse ( waitNode ( waitEventConfig ) ) ;
108+ expect ( result . success ) . toBe ( false ) ;
109+ return result . error ! . issues . find ( ( i ) => i . code === code ) ?. message ;
110+ } ;
111+
112+ it ( 'every `timerDuration` it prints parses AND yields the wait it promises' , ( ) => {
113+ // Channel 1 — the tombstone itself. Channel 2 — the `timeout` misspelling's
114+ // `guidance` entry, which repeats the same advice to an author who has had
115+ // no first failure to learn from.
116+ const tombstone = issueMessage ( { eventType : 'timer' , timeoutMs : 60_000 } , 'invalid_type' ) ;
117+ const guidance = issueMessage ( { eventType : 'timer' , timeout : 60_000 } , 'unrecognized_keys' ) ;
118+
119+ for ( const [ channel , message ] of Object . entries ( { tombstone, guidance } ) ) {
120+ // Anti-vacuity: gut either channel and this fails, rather than the loop
121+ // below passing because it found nothing to check.
122+ expect ( message , `${ channel } : raised no message at all` ) . toBeDefined ( ) ;
123+ const printed = [ ...message ! . matchAll ( / ` t i m e r D u r a t i o n : \s * ( [ ^ ` ] + ) ` / g) ] . map ( ( m ) => m [ 1 ] ) ;
124+ expect ( printed . length , `${ channel } must PRINT a \`timerDuration\` value to copy` )
125+ . toBeGreaterThan ( 0 ) ;
126+
127+ for ( const literal of printed ) {
128+ // Read the literal exactly as an author retypes it: `'60000'` is a
129+ // string, a bare `60000` is a number — and that gap IS the defect.
130+ const authored : unknown = JSON . parse ( literal . replace ( / ^ ' ( .* ) ' $ / , '"$1"' ) ) ;
131+
132+ // Gate 1 — the schema.
133+ const parsed = FlowNodeSchema . safeParse ( waitNode ( { eventType : 'timer' , timerDuration : authored } ) ) ;
134+ expect (
135+ parsed . success ,
136+ `${ channel } prints \`timerDuration: ${ literal } \`, but the spec REJECTS it: `
137+ + JSON . stringify ( parsed . error ?. issues ) ,
138+ ) . toBe ( true ) ;
139+
140+ // Gate 2 — this reader. A wait it cannot parse is a wait on nothing.
141+ const ms = parseIsoDuration ( parsed . data ! . waitEventConfig ?. timerDuration ) ;
142+ expect ( ms , `${ channel } prints \`timerDuration: ${ literal } \`, which this reader cannot parse` )
143+ . toBeGreaterThan ( 0 ) ;
144+ }
145+ }
146+
147+ // The tombstone does not merely prescribe a value, it claims an EQUALITY:
148+ // "`timeoutMs: N` and `timerDuration: X` the same wait". Read both sides out
149+ // of the message and hold it to that claim.
150+ const claimedMs = Number ( / ` t i m e o u t M s : \s * ( \d + ) ` / . exec ( tombstone ! ) ?. [ 1 ] ) ;
151+ expect ( claimedMs , 'the tombstone must still name the `timeoutMs` wait it is equating' )
152+ . toBeGreaterThan ( 0 ) ;
153+ for ( const literal of [ ...tombstone ! . matchAll ( / ` t i m e r D u r a t i o n : \s * ( [ ^ ` ] + ) ` / g) ] . map ( ( m ) => m [ 1 ] ) ) {
154+ const authored = JSON . parse ( literal . replace ( / ^ ' ( .* ) ' $ / , '"$1"' ) ) as string ;
155+ expect (
156+ parseIsoDuration ( authored ) ,
157+ `the tombstone equates \`timeoutMs: ${ claimedMs } \` with \`timerDuration: ${ literal } \`, `
158+ + 'but they are not the same wait' ,
159+ ) . toBe ( claimedMs ) ;
160+ }
161+ } ) ;
162+ } ) ;
163+
77164describe ( 'wait node executor' , ( ) => {
78165 let engine : AutomationEngine ;
79166 let ran : string [ ] ;
0 commit comments