From b4fe9f47128e1c5259a46a1ab5a5c4bca70ae85f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 19 Oct 2026 08:46:36 -0400 Subject: [PATCH 1/3] fix(studio): decode annotations nested inside Any-wrapped schemas Signed-off-by: Yordis Prieto --- .../studio/server/decode-annotations.test.mjs | 48 +++++++++++++++++++ tsworkspace/webapps/studio/server/index.mjs | 18 +++---- 2 files changed, 57 insertions(+), 9 deletions(-) create mode 100644 tsworkspace/webapps/studio/server/decode-annotations.test.mjs diff --git a/tsworkspace/webapps/studio/server/decode-annotations.test.mjs b/tsworkspace/webapps/studio/server/decode-annotations.test.mjs new file mode 100644 index 00000000..0a276471 --- /dev/null +++ b/tsworkspace/webapps/studio/server/decode-annotations.test.mjs @@ -0,0 +1,48 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import protobuf from 'protobufjs'; +import { beforeAll, describe, expect, it } from 'vitest'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const protoRoot = path.resolve(here, '..', '..', '..', '..', 'proto'); +const PKG = 'trogonatlas.eventmodel.v1alpha1'; + +let decodeAnnotations; +let root; + +beforeAll(async () => { + process.env.NODE_ENV = 'test'; + ({ decodeAnnotations } = await import('./index.mjs')); + root = new protobuf.Root(); + root.resolvePath = (_origin, target) => path.resolve(protoRoot, target); + await root.load(path.join(protoRoot, 'trogonatlas/api/eventmodel/v1alpha1/service.proto')); +}); + +function encode(name, payload) { + const type = root.lookupType(`${PKG}.${name}`); + return Buffer.from(type.encode(type.fromObject(payload)).finish()); +} + +describe('decodeAnnotations', () => { + it('decodes Any annotations nested inside an Any-wrapped schema', () => { + const annotation = encode('PersonalDataAnnotation', { doc: 'customer email' }); + const schema = encode('Schema', { + title: 'Customer', + fields: [ + { + name: 'customer_id', + metadata: [{ type_url: `type.googleapis.com/${PKG}.PersonalDataAnnotation`, value: annotation }], + }, + ], + }); + + const [decoded] = decodeAnnotations([ + { event: { schema: { typeUrl: `type.googleapis.com/${PKG}.Schema`, value: schema } } }, + ]); + + expect(decoded.event.schema.fields[0].metadata[0]).toMatchObject({ + '@type': `type.googleapis.com/${PKG}.PersonalDataAnnotation`, + doc: 'customer email', + }); + }); +}); diff --git a/tsworkspace/webapps/studio/server/index.mjs b/tsworkspace/webapps/studio/server/index.mjs index 2f0043fa..7d60d7e7 100644 --- a/tsworkspace/webapps/studio/server/index.mjs +++ b/tsworkspace/webapps/studio/server/index.mjs @@ -545,8 +545,8 @@ function valueToJs(v) { return null; } -/** @param {string} typeUrl @param {unknown} value */ -function decodeAnyValue(typeUrl, value) { +/** @param {string} typeUrl @param {unknown} value @param {number} [depth] */ +function decodeAnyValue(typeUrl, value, depth = 0) { try { const fqn = typeUrl.split("/").pop() ?? ""; const type = protoRoot.lookupType(fqn); @@ -558,7 +558,7 @@ function decodeAnyValue(typeUrl, value) { return { "@type": typeUrl, value: structToJs(type.toObject(msg)) }; } const obj = type.toObject(msg, { longs: String, enums: String }); - return { "@type": typeUrl, ...obj }; + return { "@type": typeUrl, .../** @type {Record} */ (decodeAnnotations(obj, depth + 1)) }; } catch (err) { logEvent("warn", "decode_any_value_failed", { typeUrl, @@ -575,7 +575,7 @@ function decodeAnnotations(node, depth = 0) { if (depth >= DECODE_ANNOTATIONS_MAX_DEPTH) return node; if (Array.isArray(node)) { return node.map((item) => { - const decoded = maybeDecodeAny(item); + const decoded = maybeDecodeAny(item, depth + 1); return decoded ?? decodeAnnotations(item, depth + 1); }); } @@ -583,18 +583,18 @@ function decodeAnnotations(node, depth = 0) { /** @type {Record} */ const out = {}; for (const [k, v] of Object.entries(/** @type {Record} */ (node))) { - const decoded = maybeDecodeAny(v); + const decoded = maybeDecodeAny(v, depth + 1); out[k] = decoded ?? decodeAnnotations(v, depth + 1); } return out; } -/** @param {unknown} item */ -function maybeDecodeAny(item) { +/** @param {unknown} item @param {number} [depth] */ +function maybeDecodeAny(item, depth = 0) { if (!item || typeof item !== "object" || Array.isArray(item)) return null; const url = /** @type {any} */ (item).typeUrl ?? /** @type {any} */ (item).type_url; if (typeof url !== "string" || !url.startsWith("type.googleapis.com/")) return null; - return decodeAnyValue(url, /** @type {any} */ (item).value); + return decodeAnyValue(url, /** @type {any} */ (item).value, depth); } /** @type {Record} */ @@ -2077,7 +2077,7 @@ app.use((/** @type {AppRequest} */ _req, /** @type {Res} */ res) => { markLive(); -export { app, beginShutdown, call, checkUpstream }; +export { app, beginShutdown, call, checkUpstream, decodeAnnotations }; if (process.env.NODE_ENV !== "test") { // Crash loudly rather than silently: an unhandled rejection that reaches From ed4e6c9aed0502d3253aeff1a0b3768ba3310039 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 19 Oct 2026 08:48:38 -0400 Subject: [PATCH 2/3] fix(studio): fall back to baseline when the URL branch no longer exists Signed-off-by: Yordis Prieto --- .../studio/src/components/ModelShell.test.tsx | 27 +++++++++++++++++ .../studio/src/components/ModelShell.tsx | 30 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/tsworkspace/webapps/studio/src/components/ModelShell.test.tsx b/tsworkspace/webapps/studio/src/components/ModelShell.test.tsx index 426e0df6..0760c6cc 100644 --- a/tsworkspace/webapps/studio/src/components/ModelShell.test.tsx +++ b/tsworkspace/webapps/studio/src/components/ModelShell.test.tsx @@ -289,4 +289,31 @@ describe('ModelShell', () => { }); vi.useRealTimers(); }); + + it('falls back to baseline with a banner when the URL branch no longer exists', async () => { + (api.branches as ReturnType).mockResolvedValue({ + branches: [{ name: 'other', doc: '', createdAt: '', deltaCount: 0 }], + }); + rtlRender(, { + wrapper: withNuqsTestingAdapter({ searchParams: '?branch=demo' }), + }); + expect(await screen.findByText(/no longer exists; showing baseline/)).toBeDefined(); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Branch: baseline' })).toBeDefined(); + }); + }); + + it('keeps the URL branch when it still exists', async () => { + (api.branches as ReturnType).mockResolvedValue({ + branches: [{ name: 'demo', doc: '', createdAt: '', deltaCount: 1 }], + }); + rtlRender(, { + wrapper: withNuqsTestingAdapter({ searchParams: '?branch=demo' }), + }); + await waitFor(() => { + expect(api.branches).toHaveBeenCalled(); + }); + expect(screen.queryByText(/no longer exists/)).toBeNull(); + expect(screen.getByRole('button', { name: /^Branch: demo/ })).toBeDefined(); + }); }); diff --git a/tsworkspace/webapps/studio/src/components/ModelShell.tsx b/tsworkspace/webapps/studio/src/components/ModelShell.tsx index 8649fe8c..149c2535 100644 --- a/tsworkspace/webapps/studio/src/components/ModelShell.tsx +++ b/tsworkspace/webapps/studio/src/components/ModelShell.tsx @@ -22,6 +22,7 @@ import { KIND_SHORT_TO_CAMEL } from '@/components/ModelShell.kindmap'; import { MultiInspector } from '@/components/MultiInspector'; import { Sidebar } from '@/components/Sidebar'; import { TopBar } from '@/components/TopBar'; +import { Button } from '@/components/ui/button'; import { Tabs, TabsList, TabsTab } from '@/components/ui/tabs'; import { ValidationInspector } from '@/components/ValidationInspector'; import { ValidationSummary } from '@/components/ValidationSummary'; @@ -115,6 +116,7 @@ export function ModelShell({ const [info, setInfo] = useState(); const scoped = Boolean(modelProp); const [branch, setBranch] = useBranchParam(); + const [missingBranch, setMissingBranch] = useState(); const [branchDiffEntries, setBranchDiffEntries] = useState([]); const branchLookup = useMemo(() => branchStatusLookup(branchDiffEntries), [branchDiffEntries]); // ?ns= is owned by this shell only in non-scoped mode; scoped mode is @@ -406,6 +408,23 @@ export function ModelShell({ return () => branchDiffAbortRef.current?.abort(); }, [branch, loadBranchDiff]); + // A merged or deleted branch can outlive its URL. Fall back to baseline so + // neither the board nor a copied fix prompt targets a branch that is gone. + useEffect(() => { + if (!branch) return; + const ctrl = new AbortController(); + api + .branches({ signal: ctrl.signal }) + .then((res) => { + if (ctrl.signal.aborted) return; + if ((res.branches ?? []).some((b) => b.name === branch)) return; + setMissingBranch(branch); + setBranch(undefined); + }) + .catch(() => {}); + return () => ctrl.abort(); + }, [branch, setBranch]); + // Scoped shells own the model via the caller. When ?branch= changes the // badges update above, but the caller's model must also reload so the // board shows the branch overlay rather than a stale baseline snapshot. @@ -564,6 +583,7 @@ export function ModelShell({ onExportHtml={onExportHtml} branch={branch} onSelectBranch={(next) => { + setMissingBranch(undefined); setBranch(next); setReviewOpen(false); setValidationOpen(false); @@ -587,6 +607,16 @@ export function ModelShell({ asOfIncomplete={asOfIncomplete} onExitAsOf={() => onAsOfChange?.(undefined)} /> + {missingBranch ? ( + + + Branch {missingBranch} no longer exists; showing baseline. + + + + ) : null}
{scoped ? null : ( Date: Mon, 19 Oct 2026 10:00:06 -0400 Subject: [PATCH 3/3] fix(studio): check the URL branch once per branch, not once per render Signed-off-by: Yordis Prieto --- .../studio/src/components/ModelShell.test.tsx | 15 +++++++++++++++ .../webapps/studio/src/components/ModelShell.tsx | 6 ++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/tsworkspace/webapps/studio/src/components/ModelShell.test.tsx b/tsworkspace/webapps/studio/src/components/ModelShell.test.tsx index 0760c6cc..1a01d481 100644 --- a/tsworkspace/webapps/studio/src/components/ModelShell.test.tsx +++ b/tsworkspace/webapps/studio/src/components/ModelShell.test.tsx @@ -316,4 +316,19 @@ describe('ModelShell', () => { expect(screen.queryByText(/no longer exists/)).toBeNull(); expect(screen.getByRole('button', { name: /^Branch: demo/ })).toBeDefined(); }); + + it('checks the URL branch once across re-renders', async () => { + (api.branches as ReturnType).mockResolvedValue({ + branches: [{ name: 'demo', doc: '', createdAt: '', deltaCount: 1 }], + }); + const wrapper = withNuqsTestingAdapter({ searchParams: '?branch=demo' }); + const view = rtlRender(, { wrapper }); + await waitFor(() => { + expect(api.branches).toHaveBeenCalledTimes(1); + }); + view.rerender(); + view.rerender(); + await act(async () => {}); + expect(api.branches).toHaveBeenCalledTimes(1); + }); }); diff --git a/tsworkspace/webapps/studio/src/components/ModelShell.tsx b/tsworkspace/webapps/studio/src/components/ModelShell.tsx index 149c2535..797ff28e 100644 --- a/tsworkspace/webapps/studio/src/components/ModelShell.tsx +++ b/tsworkspace/webapps/studio/src/components/ModelShell.tsx @@ -410,6 +410,8 @@ export function ModelShell({ // A merged or deleted branch can outlive its URL. Fall back to baseline so // neither the board nor a copied fix prompt targets a branch that is gone. + const setBranchRef = useRef(setBranch); + setBranchRef.current = setBranch; useEffect(() => { if (!branch) return; const ctrl = new AbortController(); @@ -419,11 +421,11 @@ export function ModelShell({ if (ctrl.signal.aborted) return; if ((res.branches ?? []).some((b) => b.name === branch)) return; setMissingBranch(branch); - setBranch(undefined); + setBranchRef.current(undefined); }) .catch(() => {}); return () => ctrl.abort(); - }, [branch, setBranch]); + }, [branch]); // Scoped shells own the model via the caller. When ?branch= changes the // badges update above, but the caller's model must also reload so the