|
14 | 14 | * shadows the stale one — which is exactly why this needs a pin: the invariant |
15 | 15 | * being protected is "a GET → PUT round-trip persists a byte-identical body", |
16 | 16 | * and only the stored bytes can show it. |
| 17 | + * |
| 18 | + * cloud#971 then showed the SECOND consumer of the same invariant, where it is |
| 19 | + * not cosmetic at all: since #4001 closed the metadata schemas, a served |
| 20 | + * document handed back to its own schema THROWS on our annotation. The last |
| 21 | + * describe block pins that — and, more importantly, pins the general rule, so a |
| 22 | + * future third decoration key fails here instead of in a production boot log. |
17 | 23 | */ |
18 | 24 | import { describe, expect, it } from 'vitest'; |
| 25 | +import { FlowSchema } from '@objectstack/spec/automation'; |
| 26 | +import { METADATA_READ_DECORATIONS } from '@objectstack/spec/kernel'; |
19 | 27 | import { ObjectStackProtocolImplementation, stripReadDecorations } from './index.js'; |
20 | 28 |
|
21 | 29 | interface Row { |
@@ -214,3 +222,95 @@ describe('saveMetaItem — the Studio round-trip persists a byte-identical body |
214 | 222 | expect(after).toBe(before); |
215 | 223 | }); |
216 | 224 | }); |
| 225 | + |
| 226 | +/** A minimal but complete record-change flow — what the automation service binds. */ |
| 227 | +const flowBody = (name: string) => ({ |
| 228 | + name, |
| 229 | + label: 'Pause project when hours are logged', |
| 230 | + type: 'record_change', |
| 231 | + status: 'active', |
| 232 | + nodes: [ |
| 233 | + { |
| 234 | + id: 'start', |
| 235 | + type: 'start', |
| 236 | + label: 'Start', |
| 237 | + config: { objectName: 'task', triggerType: 'record-after-update' }, |
| 238 | + }, |
| 239 | + { id: 'end', type: 'end', label: 'End' }, |
| 240 | + ], |
| 241 | + edges: [{ id: 'e1', source: 'start', target: 'end' }], |
| 242 | +}); |
| 243 | + |
| 244 | +/** |
| 245 | + * cloud#971 — the read path's annotations must not break a strict re-parse. |
| 246 | + * |
| 247 | + * This is the same invariant as the round-trip block above, seen from the other |
| 248 | + * side. `saveMetaItem` already strips on the WRITE path; the cold-boot flow bind |
| 249 | + * (`service-automation`: `getMetaItems('flow')` → `registerFlow` → |
| 250 | + * `FlowSchema.parse`) re-parses instead of persisting, and since #4001 closed |
| 251 | + * `FlowSchema` that parse THREW `unrecognized_keys: ["_diagnostics"]` for every |
| 252 | + * flow on every boot — an entire binding path dead behind a WARN, masked only |
| 253 | + * because the record-change plugin binds record flows a second way. |
| 254 | + * |
| 255 | + * These run against the REAL `getMetaItems`, so they fail if the read path ever |
| 256 | + * grows a decoration that consumers don't know to remove. |
| 257 | + */ |
| 258 | +describe('a served document survives its own (closed) schema — cloud#971', () => { |
| 259 | + /** Serve `flowBody(name)` back through the real read path. */ |
| 260 | + async function serveFlow(name: string): Promise<Record<string, unknown>> { |
| 261 | + const { engine } = makeStubEngine(); |
| 262 | + const protocol = new ObjectStackProtocolImplementation(engine); |
| 263 | + await protocol.saveMetaItem({ type: 'flow', name, item: flowBody(name) }); |
| 264 | + const res: any = await protocol.getMetaItems({ type: 'flow' }); |
| 265 | + const items: any[] = Array.isArray(res) ? res : (res?.items ?? []); |
| 266 | + const served = items.find((i) => i?.name === name); |
| 267 | + expect(served, `getMetaItems('flow') served ${name}`).toBeDefined(); |
| 268 | + return served as Record<string, unknown>; |
| 269 | + } |
| 270 | + |
| 271 | + it('the raw served flow does NOT parse — the strip is load-bearing', async () => { |
| 272 | + const served = await serveFlow('task_hours_pause_project'); |
| 273 | + expect(served._diagnostics).toBeDefined(); // precondition — the read decorates |
| 274 | + |
| 275 | + const raw = FlowSchema.safeParse(served); |
| 276 | + expect(raw.success, 'a decorated flow must still be rejected by the closed schema').toBe(false); |
| 277 | + // Exactly the production symptom, so a reader of this test can match it |
| 278 | + // against the WARN in the issue. |
| 279 | + expect(raw.error!.issues.some((i) => i.code === 'unrecognized_keys')).toBe(true); |
| 280 | + }); |
| 281 | + |
| 282 | + it('stripping the read decorations makes it parse — the cold-boot bind path', async () => { |
| 283 | + const served = await serveFlow('task_hours_pause_project'); |
| 284 | + const parsed = FlowSchema.safeParse(stripReadDecorations(served)); |
| 285 | + expect( |
| 286 | + parsed.success, |
| 287 | + `flow must bind after the strip; issues: ${JSON.stringify(parsed.error?.issues)}`, |
| 288 | + ).toBe(true); |
| 289 | + }); |
| 290 | + |
| 291 | + it('every key the read ADDS is either a known decoration or allowed by the schema', async () => { |
| 292 | + // The drift guard. `stripReadDecorations` only removes what |
| 293 | + // METADATA_READ_DECORATIONS lists, so a NEW annotation stamped by the |
| 294 | + // read path would sail past it and start throwing in `registerFlow` |
| 295 | + // again. Diff the served document against the authored one and hold |
| 296 | + // every added key to one of the two escapes. |
| 297 | + const name = 'task_hours_pause_project'; |
| 298 | + const served = await serveFlow(name); |
| 299 | + const authoredKeys = new Set(Object.keys(flowBody(name))); |
| 300 | + const added = Object.keys(served).filter((k) => !authoredKeys.has(k)); |
| 301 | + |
| 302 | + const unaccounted = added.filter((k) => { |
| 303 | + if ((METADATA_READ_DECORATIONS as readonly string[]).includes(k)) return false; |
| 304 | + // Not a decoration ⇒ it must be envelope state the closed schema |
| 305 | + // allowlists (the ADR-0010 `_lock`/`_packageId` family). |
| 306 | + return !FlowSchema.safeParse({ ...stripReadDecorations(served) as object, [k]: served[k] }).success; |
| 307 | + }); |
| 308 | + |
| 309 | + expect( |
| 310 | + unaccounted, |
| 311 | + 'the read path stamped a key that is neither stripped (add it to ' |
| 312 | + + 'METADATA_READ_DECORATIONS in @objectstack/spec) nor accepted by the closed schema — ' |
| 313 | + + 'every strict re-parse of a served flow, including the cold-boot bind, now throws', |
| 314 | + ).toEqual([]); |
| 315 | + }); |
| 316 | +}); |
0 commit comments