feat(#80): IMP-52 user_overrides.json persistence (u1~u10 backend + frontend + tests)
4-axis MDX-stem keyed persistence so layout / zone_geometries / zone_sections / frames
survive across `/api/run` sessions. Auto-restore on MDX reopen; CLI > file precedence
on backend pipeline entry; 300ms-debounced PUT flushed before Generate.
u1 src/user_overrides_io.py — load/save/validate_key (MDX-stem regex), 4-axis schema,
miss={}, corrupt warning+{}, atomic tmp+rename, foreign-key preserve.
u2 src/phase_z2_pipeline.py — post-argparse fallback fills only missing axes.
u3 Front/vite.config.ts — GET /api/user-overrides/:key (200 {} on miss, 400 traversal).
u4 Front/vite.config.ts — PUT /api/user-overrides/:key, 4-axis allowlist, partial merge.
u5 Front/client/src/services/userOverridesApi.ts — typed get/save + flushUserOverrides
with 300ms debounce and mutated-axis partial payloads.
u6 Front/client/src/pages/Home.tsx + slidePlanUtils.ts — restore on MDX upload (non-frame
axes immediately, frames remapped post-loadRun unit_id → region.id).
u7 Home.tsx — persist on 4 mutation handlers (section drop, layout select, zone resize,
frame select); zone_sizes and Generate excluded.
u8 tests/test_user_overrides_io.py — round-trip, unknown-key passthrough, missing/corrupt,
invalid keys (26 tests).
u9 tests/test_user_overrides_pipeline_fallback.py — per-axis fill, CLI-wins, no-file noop,
corrupt warning+skip (16 tests).
u10 Home.tsx + user_overrides_write.test.ts — await flushUserOverrides() before runPipeline
in handleGenerate try-block head; source-pattern regression assertions (20 → 22 tests).
Backend pytest 42/42 green. Frontend vitest 113/113 green (endpoint 42 / restore 21 /
service 28 / write 22). HEAD baseline ee97f4f; no spillover to phase_z2 templates /
families / frames / pipeline orchestration outside the IMP-52 surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
221
Front/client/src/services/userOverridesApi.ts
Normal file
221
Front/client/src/services/userOverridesApi.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
// IMP-52 u5 — typed frontend client for `/api/user-overrides/:key` (GET + PUT).
|
||||
//
|
||||
// The on-disk schema (KNOWN_AXES) and endpoint contract are owned by:
|
||||
// • src/user_overrides_io.py (Python — backend pipeline fallback, u1/u2)
|
||||
// • Front/vite.config.ts (handleGet/PutUserOverrides, u3/u4)
|
||||
// This module is the typed view used by Home.tsx restore-on-reopen (u6) and
|
||||
// the four mutation handlers (u7). It does NOT own the schema — any change
|
||||
// to KNOWN_AXES must land in u1/u4 first, then reflect here.
|
||||
//
|
||||
// Contract (Stage 2 unit u5 summary):
|
||||
// • Typed `getUserOverrides(key)` → returns `Partial<UserOverrides>` from
|
||||
// the GET endpoint. Missing / corrupt / non-object payloads degrade to
|
||||
// `{}` so the frontend reopen flow never crashes on a fresh MDX.
|
||||
// • Typed `saveUserOverrides(key, partial)` → schedules a 300ms-debounced
|
||||
// PUT carrying ONLY the axes the user has mutated since the last flush.
|
||||
// Per-axis coalescing: a later call overwrites the same axis in the
|
||||
// pending payload; axes the user did not mutate are NOT sent (the
|
||||
// server-side merge in u4 preserves them on disk).
|
||||
// • Per-key debounce buckets — rapid edits to MDX "03" do not delay the
|
||||
// flush for MDX "04".
|
||||
// • Explicit clear sentinel: `partial[axis] = null` forwards to the PUT
|
||||
// body verbatim so u4 `mergeUserOverrides` can `delete` the axis on disk.
|
||||
// • `flushUserOverrides()` / `flushUserOverrides(key)` force an immediate
|
||||
// PUT (used by tests + Home.tsx Generate flow to ensure outstanding
|
||||
// writes commit before pipeline run).
|
||||
|
||||
const ENDPOINT_BASE = "/api/user-overrides";
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
// ── Schema (mirror of backend KNOWN_AXES; see header comment) ───────────────
|
||||
|
||||
/** unit_id → template_id. unit_id = source_section_ids joined by "+". */
|
||||
export type FramesOverride = Record<string, string>;
|
||||
|
||||
/** zone_id → 0-1 normalized geometry inside slide-body. */
|
||||
export type ZoneGeometryOverride = {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
export type ZoneGeometriesOverride = Record<string, ZoneGeometryOverride>;
|
||||
|
||||
/** zone_id → ordered list of section_ids assigned to that zone. */
|
||||
export type ZoneSectionsOverride = Record<string, string[]>;
|
||||
|
||||
/** Full on-disk schema. All axes optional — file may carry any subset. */
|
||||
export interface UserOverrides {
|
||||
layout: string;
|
||||
frames: FramesOverride;
|
||||
zone_geometries: ZoneGeometriesOverride;
|
||||
zone_sections: ZoneSectionsOverride;
|
||||
}
|
||||
|
||||
/** Partial-mutation payload. `null` is the explicit clear sentinel (mirrors u4). */
|
||||
export type UserOverridesPartial = {
|
||||
[K in keyof UserOverrides]?: UserOverrides[K] | null;
|
||||
};
|
||||
|
||||
// ── Per-key debounce buckets ────────────────────────────────────────────────
|
||||
|
||||
type PendingBucket = {
|
||||
partial: UserOverridesPartial;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
waiters: Array<{
|
||||
resolve: (merged: Partial<UserOverrides>) => void;
|
||||
reject: (err: unknown) => void;
|
||||
}>;
|
||||
};
|
||||
|
||||
const buckets = new Map<string, PendingBucket>();
|
||||
|
||||
function getBucket(key: string): PendingBucket {
|
||||
let b = buckets.get(key);
|
||||
if (!b) {
|
||||
b = { partial: {}, timer: null, waiters: [] };
|
||||
buckets.set(key, b);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
// ── GET ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch the persisted user_overrides for `key` (MDX stem). Returns `{}` on
|
||||
* any failure mode (network error, 4xx/5xx, non-object body) so the caller
|
||||
* can use it unconditionally during MDX reopen without branching on
|
||||
* error paths.
|
||||
*/
|
||||
export async function getUserOverrides(
|
||||
key: string,
|
||||
): Promise<Partial<UserOverrides>> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${ENDPOINT_BASE}/${encodeURIComponent(key)}`, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (!res.ok) return {};
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await res.json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
return parsed as Partial<UserOverrides>;
|
||||
}
|
||||
|
||||
// ── PUT (debounced) ─────────────────────────────────────────────────────────
|
||||
|
||||
async function flushBucket(
|
||||
key: string,
|
||||
bucket: PendingBucket,
|
||||
): Promise<void> {
|
||||
const payload = bucket.partial;
|
||||
const waiters = bucket.waiters;
|
||||
bucket.partial = {};
|
||||
bucket.timer = null;
|
||||
bucket.waiters = [];
|
||||
|
||||
let merged: Partial<UserOverrides> = {};
|
||||
try {
|
||||
const res = await fetch(`${ENDPOINT_BASE}/${encodeURIComponent(key)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.ok) {
|
||||
try {
|
||||
const parsed = (await res.json()) as unknown;
|
||||
if (
|
||||
typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
!Array.isArray(parsed)
|
||||
) {
|
||||
merged = parsed as Partial<UserOverrides>;
|
||||
}
|
||||
} catch {
|
||||
// server returned 200 with non-JSON body → treat as empty merged
|
||||
}
|
||||
} else {
|
||||
const err = new Error(`PUT ${ENDPOINT_BASE}/${key} → ${res.status}`);
|
||||
waiters.forEach((w) => w.reject(err));
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
waiters.forEach((w) => w.reject(err));
|
||||
return;
|
||||
}
|
||||
waiters.forEach((w) => w.resolve(merged));
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a debounced PUT to persist the mutated axes. Resolves with the
|
||||
* server-side merged document when the debounced PUT eventually fires.
|
||||
* Multiple rapid calls for the same `key` coalesce into a single PUT;
|
||||
* a later call's value for a given axis overrides an earlier pending value.
|
||||
* Calls for different `key`s are isolated.
|
||||
*/
|
||||
export function saveUserOverrides(
|
||||
key: string,
|
||||
partial: UserOverridesPartial,
|
||||
): Promise<Partial<UserOverrides>> {
|
||||
const bucket = getBucket(key);
|
||||
// Per-axis coalescing — later mutations replace earlier pending values.
|
||||
for (const axis of Object.keys(partial) as Array<keyof UserOverridesPartial>) {
|
||||
bucket.partial[axis] = partial[axis] as never;
|
||||
}
|
||||
const p = new Promise<Partial<UserOverrides>>((resolve, reject) => {
|
||||
bucket.waiters.push({ resolve, reject });
|
||||
});
|
||||
if (bucket.timer !== null) clearTimeout(bucket.timer);
|
||||
bucket.timer = setTimeout(() => {
|
||||
void flushBucket(key, bucket);
|
||||
}, DEBOUNCE_MS);
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-flush pending debounced writes. With no arg, flushes ALL pending
|
||||
* keys (used before pipeline runs so the backend reads the latest file).
|
||||
* With a key, flushes only that key's bucket.
|
||||
*
|
||||
* Resolves after every flushed bucket's PUT completes. Per-bucket errors
|
||||
* are swallowed at the flush level — the original caller's
|
||||
* saveUserOverrides() promise still rejects to its owner via the waiter.
|
||||
*/
|
||||
export async function flushUserOverrides(key?: string): Promise<void> {
|
||||
const targets: Array<[string, PendingBucket]> = [];
|
||||
if (key !== undefined) {
|
||||
const b = buckets.get(key);
|
||||
if (b && b.timer !== null) targets.push([key, b]);
|
||||
} else {
|
||||
buckets.forEach((b, k) => {
|
||||
if (b.timer !== null) targets.push([k, b]);
|
||||
});
|
||||
}
|
||||
const flushPromises = targets.map(([k, b]) => {
|
||||
if (b.timer !== null) {
|
||||
clearTimeout(b.timer);
|
||||
b.timer = null;
|
||||
}
|
||||
return flushBucket(k, b);
|
||||
});
|
||||
await Promise.all(flushPromises);
|
||||
}
|
||||
|
||||
/** Test-only — clears all pending buckets without firing PUTs. */
|
||||
export function __resetUserOverridesBuckets_FOR_TEST(): void {
|
||||
buckets.forEach((b) => {
|
||||
if (b.timer !== null) clearTimeout(b.timer);
|
||||
});
|
||||
buckets.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user