|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#10462] `publishPackageDrafts` return-site pins the objectql-side |
| 5 | + * conformance suite cannot stage against the real engine: |
| 6 | + * |
| 7 | + * - the Phase-1 UNWIND's `outcome` — staging a real mid-transaction promotion |
| 8 | + * failure through the real engine rides the undeclared `failed[].issues` |
| 9 | + * key on the causal element (#10524's surface, deliberately not this |
| 10 | + * card's), so the unwind is pinned here with the promotion seam mocked and |
| 11 | + * a plain Error (no `issues`); |
| 12 | + * - the no-op TRACE — the `console.info` line is the no-op exit's only |
| 13 | + * record: the audit ledger stays silent on purpose (`sys_metadata_audit` |
| 14 | + * rows are keyed on `(type, name)`, and a batch with zero items has no |
| 15 | + * honest identity to mint — the limiting case of the rule both refusal |
| 16 | + * sites already follow). This file goes red if the line is dropped, and |
| 17 | + * pins that it does NOT fire on the exits that leave their own records; |
| 18 | + * - the zero-draft machinery edge — the one return whose `outcome` is not |
| 19 | + * fixed by the site it sits on: a transaction-machinery failure over an |
| 20 | + * EMPTY batch answers `nothing_to_publish` (nothing was pending, nothing |
| 21 | + * was refused; the unwind's `console.warn` stays the record of the |
| 22 | + * failure), which is what keeps the producer invariant |
| 23 | + * `outcome === 'refused'` iff `failed.length > 0` true on every return. |
| 24 | + * |
| 25 | + * Harness copied from |
| 26 | + * `packages/objectql/src/protocol-publish-package-drafts.test.ts` (the #8896 |
| 27 | + * capture double) — copied, NOT imported: metadata-protocol cannot depend on |
| 28 | + * objectql, and each pin must be able to fail independently. |
| 29 | + */ |
| 30 | +import { describe, it, expect, vi, afterEach } from 'vitest'; |
| 31 | +import { ObjectStackProtocolImplementation } from './protocol.js'; |
| 32 | + |
| 33 | +/** |
| 34 | + * [#8896] A real double for the two engine calls `publishPackageDrafts` makes |
| 35 | + * on its own — the ADR-0067 pre-publish CAPTURE read (`findOne`, answering an |
| 36 | + * explicit `null` for "no active row"), and the commit write (`insert`). |
| 37 | + */ |
| 38 | +function makeCaptureEngine() { |
| 39 | + const engine = { |
| 40 | + findOne: async (table: string, opts?: { where?: Record<string, unknown> }) => { |
| 41 | + void table; void opts; |
| 42 | + // Every artifact is new here: `null` is the truthful capture answer. |
| 43 | + return null; |
| 44 | + }, |
| 45 | + insert: async (table: string) => ({ id: `${table}_1` }), |
| 46 | + }; |
| 47 | + return engine; |
| 48 | +} |
| 49 | + |
| 50 | +function makeProtocol(drafts: Array<{ type: string; name: string }>) { |
| 51 | + const protocol = new ObjectStackProtocolImplementation({} as never); |
| 52 | + (protocol as any).ensureOverlayIndex = async () => {}; |
| 53 | + (protocol as any).getOverlayRepo = () => ({ listDrafts: async () => drafts }); |
| 54 | + (protocol as any).engine = makeCaptureEngine(); |
| 55 | + const promote = vi.spyOn(protocol as any, 'promoteDraftForPublish'); |
| 56 | + const sideEffects = vi |
| 57 | + .spyOn(protocol as any, 'runPublishSideEffects') |
| 58 | + .mockResolvedValue({}); |
| 59 | + const promoteOk = (req: any) => ({ |
| 60 | + singularType: req.type, |
| 61 | + orgId: null, |
| 62 | + advisories: [], |
| 63 | + result: { version: 'h', seq: 1, item: { body: { name: req.name } }, packageId: null }, |
| 64 | + }); |
| 65 | + return { protocol, promote, sideEffects, promoteOk }; |
| 66 | +} |
| 67 | + |
| 68 | +const NOOP_LINE = /\[Protocol\] publishPackageDrafts: nothing to publish/; |
| 69 | + |
| 70 | +afterEach(() => vi.restoreAllMocks()); |
| 71 | + |
| 72 | +describe('[#10462] the Phase-1 unwind names its outcome', () => { |
| 73 | + it("a mid-batch promotion failure answers outcome 'refused' with the whole batch in failed[]", async () => { |
| 74 | + const { protocol, promote } = makeProtocol([ |
| 75 | + { type: 'view', name: 'cases' }, |
| 76 | + { type: 'view', name: 'leads' }, |
| 77 | + ]); |
| 78 | + // A plain Error, deliberately without `issues` — see the header. |
| 79 | + promote.mockImplementation(async (req: any) => { |
| 80 | + if (req.name === 'cases') throw new Error('promotion refused'); |
| 81 | + return { |
| 82 | + singularType: req.type, orgId: null, advisories: [], |
| 83 | + result: { version: 'h', seq: 1, item: { body: { name: req.name } }, packageId: null }, |
| 84 | + }; |
| 85 | + }); |
| 86 | + |
| 87 | + const res: any = await protocol.publishPackageDrafts({ packageId: 'app.edu' }); |
| 88 | + |
| 89 | + expect(res.success).toBe(false); |
| 90 | + expect(res.outcome).toBe('refused'); |
| 91 | + expect(res.publishedCount).toBe(0); |
| 92 | + expect(res.published).toEqual([]); |
| 93 | + // ADR-0067 D2: the causal item plus its BATCH_ABORTED sibling. |
| 94 | + expect(res.failedCount).toBe(2); |
| 95 | + expect(res.failed.map((f: any) => f.name).sort()).toEqual(['cases', 'leads']); |
| 96 | + // The invariants hold at this site too (both directions). |
| 97 | + expect(res.outcome === 'refused').toBe(res.failed.length > 0); |
| 98 | + expect(res.success).toBe(res.outcome === 'published'); |
| 99 | + }); |
| 100 | + |
| 101 | + it("a transaction-machinery failure over an EMPTY batch answers 'nothing_to_publish', keeping the invariant universal", async () => { |
| 102 | + const { protocol } = makeProtocol([]); |
| 103 | + (protocol as any).engine = { |
| 104 | + ...makeCaptureEngine(), |
| 105 | + transaction: async () => { throw new Error('connection lost'); }, |
| 106 | + }; |
| 107 | + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 108 | + |
| 109 | + const res: any = await protocol.publishPackageDrafts({ packageId: 'app.empty' }); |
| 110 | + |
| 111 | + // Truthful on both axes: nothing was pending, nothing was refused — |
| 112 | + // and `refused` with `failed: []` would break invariant (i). |
| 113 | + expect(res).toMatchObject({ |
| 114 | + success: false, outcome: 'nothing_to_publish', |
| 115 | + publishedCount: 0, failedCount: 0, published: [], failed: [], |
| 116 | + }); |
| 117 | + // The machinery failure is NOT silent: the unwind's warn is its record. |
| 118 | + expect(warn.mock.calls.some((c) => String(c[0]).includes('rolled back'))).toBe(true); |
| 119 | + }); |
| 120 | +}); |
| 121 | + |
| 122 | +describe('[#10462] the no-op exit leaves a trace', () => { |
| 123 | + it('TRACE CONTROL: the no-op logs one info line naming the package and BOTH facts', async () => { |
| 124 | + const { protocol } = makeProtocol([]); |
| 125 | + const info = vi.spyOn(console, 'info').mockImplementation(() => {}); |
| 126 | + |
| 127 | + const res: any = await protocol.publishPackageDrafts({ packageId: 'app.empty' }); |
| 128 | + |
| 129 | + expect(res.outcome).toBe('nothing_to_publish'); |
| 130 | + const line = info.mock.calls.map((c) => String(c[0])).find((m) => NOOP_LINE.test(m)); |
| 131 | + // Fails if the log line is dropped — the exit would be traceless again. |
| 132 | + expect(line).toBeDefined(); |
| 133 | + // Names the packageId… |
| 134 | + expect(line).toContain("'app.empty'"); |
| 135 | + // …and states the two facts `success: false` cannot carry alone: |
| 136 | + // nothing was pending, and nothing was refused. |
| 137 | + expect(line).toMatch(/no pending drafts/); |
| 138 | + expect(line).toMatch(/nothing was refused/); |
| 139 | + }); |
| 140 | + |
| 141 | + it('the trace is SPECIFIC to the no-op: a successful publish and a refusal do not emit it', async () => { |
| 142 | + const info = vi.spyOn(console, 'info').mockImplementation(() => {}); |
| 143 | + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 144 | + void warn; |
| 145 | + |
| 146 | + const ok = makeProtocol([{ type: 'view', name: 'cases' }]); |
| 147 | + ok.promote.mockImplementation(async (req: any) => ok.promoteOk(req)); |
| 148 | + const okRes: any = await ok.protocol.publishPackageDrafts({ packageId: 'app.edu' }); |
| 149 | + expect(okRes.outcome).toBe('published'); |
| 150 | + |
| 151 | + const refused = makeProtocol([{ type: 'view', name: 'cases' }]); |
| 152 | + refused.promote.mockImplementation(async () => { throw new Error('promotion refused'); }); |
| 153 | + const refusedRes: any = await refused.protocol.publishPackageDrafts({ packageId: 'app.edu' }); |
| 154 | + expect(refusedRes.outcome).toBe('refused'); |
| 155 | + |
| 156 | + // Those two exits leave their own records (audit rows / warn) — the |
| 157 | + // info line belongs to the no-op alone. |
| 158 | + expect(info.mock.calls.map((c) => String(c[0])).some((m) => NOOP_LINE.test(m))).toBe(false); |
| 159 | + }); |
| 160 | +}); |
0 commit comments