Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions tsworkspace/webapps/studio/server/decode-annotations.test.mjs
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
18 changes: 9 additions & 9 deletions tsworkspace/webapps/studio/server/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<string, unknown>} */ (decodeAnnotations(obj, depth + 1)) };
} catch (err) {
logEvent("warn", "decode_any_value_failed", {
typeUrl,
Expand All @@ -575,26 +575,26 @@ 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);
});
}
if (!node || typeof node !== "object" || Buffer.isBuffer(node)) return node;
/** @type {Record<string, unknown>} */
const out = {};
for (const [k, v] of Object.entries(/** @type {Record<string, unknown>} */ (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<number, number>} */
Expand Down Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions tsworkspace/webapps/studio/src/components/ModelShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -289,4 +289,46 @@ describe('ModelShell', () => {
});
vi.useRealTimers();
});

it('falls back to baseline with a banner when the URL branch no longer exists', async () => {
(api.branches as ReturnType<typeof vi.fn>).mockResolvedValue({
branches: [{ name: 'other', doc: '', createdAt: '', deltaCount: 0 }],
});
rtlRender(<ModelShell model={buildModel([])} scopedTitle="Shop" />, {
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<typeof vi.fn>).mockResolvedValue({
branches: [{ name: 'demo', doc: '', createdAt: '', deltaCount: 1 }],
});
rtlRender(<ModelShell model={buildModel([])} scopedTitle="Shop" />, {
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();
});

it('checks the URL branch once across re-renders', async () => {
(api.branches as ReturnType<typeof vi.fn>).mockResolvedValue({
branches: [{ name: 'demo', doc: '', createdAt: '', deltaCount: 1 }],
});
const wrapper = withNuqsTestingAdapter({ searchParams: '?branch=demo' });
const view = rtlRender(<ModelShell model={buildModel([])} scopedTitle="Shop" />, { wrapper });
await waitFor(() => {
expect(api.branches).toHaveBeenCalledTimes(1);
});
view.rerender(<ModelShell model={buildModel([])} scopedTitle="Shop" />);
view.rerender(<ModelShell model={buildModel([])} scopedTitle="Shop" />);
await act(async () => {});
expect(api.branches).toHaveBeenCalledTimes(1);
});
});
32 changes: 32 additions & 0 deletions tsworkspace/webapps/studio/src/components/ModelShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -115,6 +116,7 @@ export function ModelShell({
const [info, setInfo] = useState<ServerInfo>();
const scoped = Boolean(modelProp);
const [branch, setBranch] = useBranchParam();
const [missingBranch, setMissingBranch] = useState<string>();
const [branchDiffEntries, setBranchDiffEntries] = useState<BranchDiffEntry[]>([]);
const branchLookup = useMemo(() => branchStatusLookup(branchDiffEntries), [branchDiffEntries]);
// ?ns= is owned by this shell only in non-scoped mode; scoped mode is
Expand Down Expand Up @@ -406,6 +408,25 @@ 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.
const setBranchRef = useRef(setBranch);
setBranchRef.current = setBranch;
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);
setBranchRef.current(undefined);
})
.catch(() => {});
return () => ctrl.abort();
}, [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
// board shows the branch overlay rather than a stale baseline snapshot.
Expand Down Expand Up @@ -564,6 +585,7 @@ export function ModelShell({
onExportHtml={onExportHtml}
branch={branch}
onSelectBranch={(next) => {
setMissingBranch(undefined);
setBranch(next);
setReviewOpen(false);
setValidationOpen(false);
Expand All @@ -587,6 +609,16 @@ export function ModelShell({
asOfIncomplete={asOfIncomplete}
onExitAsOf={() => onAsOfChange?.(undefined)}
/>
{missingBranch ? (
<output className="flex items-center gap-2 border-b border-amber-300 bg-amber-100 px-4 py-1.5 text-xs text-amber-900">
<span className="min-w-0 truncate">
Branch <span className="font-mono">{missingBranch}</span> no longer exists; showing baseline.
</span>
<Button variant="ghost" size="sm" onClick={() => setMissingBranch(undefined)}>
Dismiss
</Button>
</output>
) : null}
<div className="flex min-h-0 flex-1">
{scoped ? null : (
<Sidebar
Expand Down
Loading