mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-30 07:10:49 +02:00
* docs(claude): add API Response Compatibility section Narrows the existing "no backwards compat" rule to internal code only, and adds a new section that codifies the defensive boundary at API edges: parse-don't-cast, never pin UI to a single field, enum drift must downgrade not crash. Driven by #2143/#2147/#2192 — all three were the desktop client white- screening on backend response shape changes the client wasn't built against. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * feat(core): add zod-based API response validation layer Introduces a defensive boundary so a malformed backend response degrades into a safe fallback (empty page, [], etc.) instead of throwing inside React render. - Adds zod to the pnpm catalog and as a @multica/core dependency. - New parseWithFallback helper in core/api/schema.ts that runs safeParse, logs a warn with the endpoint + zod issues on failure, and returns the caller-supplied fallback. Never throws. - Schemas in core/api/schemas.ts are deliberately lenient (string enums kept as z.string() so unknown values still parse, optional fields default, nested records use .loose() for unknown keys). - Wires setSchemaLogger from CoreProvider so warnings flow through the same logger as the rest of the API client. This is the primitive — see the next commit for the call-site wiring. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * feat(api): guard top 5 high-risk endpoints with parseWithFallback Wraps the response of the five endpoints whose UIs white-screened in past incidents (#2143/#2147/#2192) so a contract drift returns a safe fallback instead of crashing the consumer: - listIssues → ListIssuesResponseSchema, fallback { issues: [], total: 0 } - listTimeline → TimelinePageSchema, fallback empty page - listComments → CommentsListSchema, fallback [] - listIssueSubscribers → SubscribersListSchema, fallback [] - listChildIssues → ChildIssuesResponseSchema, fallback { issues: [] } getIssue is intentionally NOT wrapped: there is no sensible "empty issue" — the entire detail page depends on real fields. The page-level ErrorBoundary (separate commit) catches that case. Adds schema.test.ts with 9 cases covering the five failure modes listed in MUL-1828: missing fields, wrong types, enum drift, null body, and null arrays. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * feat(ui): add ErrorBoundary and wrap high-risk pages Section-level error boundary (no third-party dep — class component + default fallback in @multica/ui). Supports a fallback render prop and resetKeys for auto-recovery on resource navigation. Wraps the surfaces that white-screened in past incidents: - IssueDetail (web + desktop + inbox split-pane) — keyed on issueId so navigating to a different issue clears the boundary automatically. - IssuesPage (web + desktop). Boundaries are placed at consumer call sites rather than inside IssueDetail itself so we don't have to refactor the 1100-line component, and so a crash inside one inbox split-pane doesn't take down the inbox list next to it. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(core): make all API schemas .loose() to preserve unknown fields zod 4 z.object() defaults to STRIP, which silently drops fields the schema didn't list. That makes the schema layer a sync point: a future PR adding a TS field but forgetting the schema would have the field disappear at runtime while TS still claims it exists — the exact bug- class this PR is meant to prevent, just inverted. Apply .loose() to every object schema (TimelineEntry, TimelinePage, Comment, Issue, ListIssuesResponse, Subscriber, ChildIssuesResponse) so unknown server-side fields pass through unchanged. Add a regression test that feeds a payload with extra fields at both entry and page level, and a direct unit test for parseWithFallback decoupled from any endpoint. Update the listIssues fallback test to use a wrong-type payload — under .loose() the previous "{ unexpected: true }" payload parses successfully (every declared field has a default) instead of triggering the fallback path it was meant to exercise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(claude): strip field-specific examples from API Compatibility section The original wording embedded current schema field names (entries, has_more_before, has_more_after, cursor, status, type) directly in the rules. CLAUDE.md should state the rule, not the implementation — once a field is renamed the doc drifts out of sync with the code, and the specific names don't add anything the abstract rule doesn't. Keep the rule, drop the field-level archaeology. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
148 lines
5.1 KiB
TypeScript
148 lines
5.1 KiB
TypeScript
import { z } from "zod";
|
|
import type { ListIssuesResponse, TimelinePage } from "../types";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Schemas for the highest-risk API endpoints — those whose responses drive
|
|
// the issue detail page (timeline, comments, subscribers) and the issues
|
|
// list. These are the surfaces that white-screened in #2143 / #2147 / #2192.
|
|
//
|
|
// These schemas are intentionally LENIENT:
|
|
// - String enums are stored as `z.string()` rather than `z.enum([...])`.
|
|
// A new server-side enum value should render as a generic fallback in
|
|
// the UI, never crash a `safeParse`.
|
|
// - Optional fields are unioned with `null` and given fallbacks where
|
|
// existing UI code already coerces them.
|
|
// - Arrays default to `[]` so a missing `reactions` / `attachments` /
|
|
// `entries` field doesn't take the page down.
|
|
// - Every object schema ends with `.loose()` so unknown server-side
|
|
// fields pass through unchanged. zod 4's `.object()` defaults to STRIP,
|
|
// which would silently delete fields the schema didn't explicitly list
|
|
// — fine while the TS type doesn't claim them, but the moment a future
|
|
// PR adds a TS field without updating the schema, the cast `as T` lies
|
|
// and the field shows up as `undefined` at runtime. `.loose()` removes
|
|
// that synchronisation hazard.
|
|
//
|
|
// These schemas are deliberately not typed as `z.ZodType<TimelineEntry>` /
|
|
// `z.ZodType<Issue>` etc. — the strict TS types narrow string fields to
|
|
// literal unions, which would defeat the leniency above. `parseWithFallback`
|
|
// returns the parsed value cast to the caller-supplied `T`, so the strict
|
|
// type still flows out at the call site; the schema only guards shape.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ReactionSchema = z.object({
|
|
id: z.string(),
|
|
comment_id: z.string(),
|
|
actor_type: z.string(),
|
|
actor_id: z.string(),
|
|
emoji: z.string(),
|
|
created_at: z.string(),
|
|
});
|
|
|
|
const AttachmentSchema = z.object({
|
|
id: z.string(),
|
|
}).loose();
|
|
|
|
// All object schemas use `.loose()` so unknown server-side fields pass
|
|
// through unchanged. zod 4's `.object()` defaults to STRIP, which would
|
|
// silently drop new fields and surface as a "field neither showed up in
|
|
// the UI" mystery the next time the TS type adopted them but the schema
|
|
// wasn't updated in lock-step. `.loose()` removes that synchronisation
|
|
// hazard — the schema validates the shape it knows about and leaves the
|
|
// rest alone.
|
|
const TimelineEntrySchema = z.object({
|
|
type: z.string(),
|
|
id: z.string(),
|
|
actor_type: z.string(),
|
|
actor_id: z.string(),
|
|
created_at: z.string(),
|
|
action: z.string().optional(),
|
|
details: z.record(z.string(), z.unknown()).optional(),
|
|
content: z.string().optional(),
|
|
parent_id: z.string().nullable().optional(),
|
|
updated_at: z.string().optional(),
|
|
comment_type: z.string().optional(),
|
|
reactions: z.array(ReactionSchema).optional(),
|
|
attachments: z.array(AttachmentSchema).optional(),
|
|
coalesced_count: z.number().optional(),
|
|
}).loose();
|
|
|
|
export const TimelinePageSchema = z.object({
|
|
entries: z.array(TimelineEntrySchema).default([]),
|
|
next_cursor: z.string().nullable().default(null),
|
|
prev_cursor: z.string().nullable().default(null),
|
|
has_more_before: z.boolean().default(false),
|
|
has_more_after: z.boolean().default(false),
|
|
target_index: z.number().optional(),
|
|
}).loose();
|
|
|
|
export const EMPTY_TIMELINE_PAGE: TimelinePage = {
|
|
entries: [],
|
|
next_cursor: null,
|
|
prev_cursor: null,
|
|
has_more_before: false,
|
|
has_more_after: false,
|
|
};
|
|
|
|
export const CommentSchema = z.object({
|
|
id: z.string(),
|
|
issue_id: z.string(),
|
|
author_type: z.string(),
|
|
author_id: z.string(),
|
|
content: z.string(),
|
|
type: z.string(),
|
|
parent_id: z.string().nullable(),
|
|
reactions: z.array(ReactionSchema).default([]),
|
|
attachments: z.array(AttachmentSchema).default([]),
|
|
created_at: z.string(),
|
|
updated_at: z.string(),
|
|
}).loose();
|
|
|
|
export const CommentsListSchema = z.array(CommentSchema);
|
|
|
|
const IssueSchema = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string(),
|
|
number: z.number(),
|
|
identifier: z.string(),
|
|
title: z.string(),
|
|
description: z.string().nullable(),
|
|
status: z.string(),
|
|
priority: z.string(),
|
|
assignee_type: z.string().nullable(),
|
|
assignee_id: z.string().nullable(),
|
|
creator_type: z.string(),
|
|
creator_id: z.string(),
|
|
parent_issue_id: z.string().nullable(),
|
|
project_id: z.string().nullable(),
|
|
position: z.number(),
|
|
due_date: z.string().nullable(),
|
|
reactions: z.array(z.unknown()).optional(),
|
|
labels: z.array(z.unknown()).optional(),
|
|
created_at: z.string(),
|
|
updated_at: z.string(),
|
|
}).loose();
|
|
|
|
export const ListIssuesResponseSchema = z.object({
|
|
issues: z.array(IssueSchema).default([]),
|
|
total: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const EMPTY_LIST_ISSUES_RESPONSE: ListIssuesResponse = {
|
|
issues: [],
|
|
total: 0,
|
|
};
|
|
|
|
const SubscriberSchema = z.object({
|
|
issue_id: z.string(),
|
|
user_type: z.string(),
|
|
user_id: z.string(),
|
|
reason: z.string(),
|
|
created_at: z.string(),
|
|
}).loose();
|
|
|
|
export const SubscribersListSchema = z.array(SubscriberSchema);
|
|
|
|
export const ChildIssuesResponseSchema = z.object({
|
|
issues: z.array(IssueSchema).default([]),
|
|
}).loose();
|