refactor(client): open-repo attend le 1er STATE (barrière de sync), pas TabInfo
Avant : `ensureRepoOpen` résolvait son attente sur le 1er push d'abonnement (= TabInfo, ~1-3ms) — donc AVANT la vraie barrière de sync. Correct par chance sur ce broker (State suit TabInfo d'~1ms), faux si State tarde. Maintenant : - `subscribe.ts` : `docChangeType(resp)` extrait le variant (`State`/`Patch`/ `TabInfo`/…) sans cast `any` ; `subscribeDoc`/`subscribeDocs` le passent en 2e arg NON-cassant (les appelants 0-arg — discovery, inbox — inchangés). - `ensureRepoOpen` n'agit que sur `type === "State"` → attend la vraie barrière. - État de sync par-doc explicite : `getSyncState(nuri): "syncing"|"synced"| "timed-out"|"unknown"`. Le fallback 8s marque `timed-out`, JAMAIS `synced` — les deux ne sont plus confondus (base du futur signal app + du « trop long = signal »). - Fake ng (sans doc_subscribe) : résolution immédiate préservée (bun test intact). gate : tsc propre ; bun test 117 ; test:e2e 39 passed (CONTRAT 3 vert, events=["TabInfo","State"] ; reconnexion cold-read 176ms/10.8s — l'attente du 1er State se déclenche vite, pas de gonflement par timeout). Pas de régression app (le rouge du test reconnexion est pré-existant et broker-lenteur, vérifié sur lib vierge). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,35 @@ import type { Nuri } from "./types";
|
||||
*/
|
||||
export type DocChange = unknown;
|
||||
|
||||
/**
|
||||
* The discriminant of a {@link DocChange} — the single variant key of the raw
|
||||
* `AppResponse` payload (`{ V0: { State | Patch | TabInfo | … } }`). It is NOT a
|
||||
* closed enum: the platform may push other variants, so this is a bare `string`
|
||||
* (e.g. `"State"`, `"Patch"`, `"TabInfo"`), or `undefined` when the shape can't
|
||||
* be read. Verified against the CONTRACT-3 e2e probe (`e2e/sdk-entry.ts`): the
|
||||
* variant is `Object.keys(resp.V0)[0]`. Exposed so a caller that needs the SYNC
|
||||
* BARRIER (the first `State`, per CONTRACT 3) can distinguish it from the earlier
|
||||
* `TabInfo`/`Patch` pushes — see `open-repo.ts`. Most callers ignore it and use
|
||||
* any push as a plain change signal.
|
||||
*/
|
||||
export type DocChangeType = string | undefined;
|
||||
|
||||
/**
|
||||
* Extract the variant key from a raw {@link DocChange}. Reads `resp.V0` (case-
|
||||
* tolerant to `v0`) and returns its first key — the AppResponse variant name
|
||||
* (`"State"` / `"Patch"` / `"TabInfo"` / …). Returns `undefined` if the payload
|
||||
* is not a recognisable `{ V0: { <Variant>: … } }` object. Inspects the variant
|
||||
* proplerly (no `any`-cast to force it) so a `State` push is identifiable.
|
||||
*/
|
||||
export function docChangeType(resp: DocChange): DocChangeType {
|
||||
if (!resp || typeof resp !== "object") return undefined;
|
||||
const outer = resp as { V0?: unknown; v0?: unknown };
|
||||
const v0 = outer.V0 ?? outer.v0;
|
||||
if (!v0 || typeof v0 !== "object") return undefined;
|
||||
const keys = Object.keys(v0 as Record<string, unknown>);
|
||||
return keys.length > 0 ? keys[0] : undefined;
|
||||
}
|
||||
|
||||
/** An unsubscribe function — idempotent (calling it twice is a no-op). */
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
@@ -63,9 +92,18 @@ async function sessionId(): Promise<string> {
|
||||
* cancellation is honoured as soon as the real unsubscribe is available (and no
|
||||
* further `onChange` fires after unsubscribe).
|
||||
*
|
||||
* `onChange` receives the raw payload AND its variant type ({@link docChangeType},
|
||||
* e.g. `"State"`). The type is a NON-BREAKING second argument: existing callers
|
||||
* that ignore it (the change-signal pattern — `discovery.ts`, `inbox.ts`) are
|
||||
* unaffected; a caller that needs the sync barrier (`open-repo.ts`) reads it to
|
||||
* act only on the first `State`.
|
||||
*
|
||||
* Calls the REAL injected `ng.doc_subscribe` directly (never `makeNg`).
|
||||
*/
|
||||
export function subscribeDoc(nuri: Nuri, onChange: (r: DocChange) => void): Unsubscribe {
|
||||
export function subscribeDoc(
|
||||
nuri: Nuri,
|
||||
onChange: (r: DocChange, type: DocChangeType) => void,
|
||||
): Unsubscribe {
|
||||
const { ng } = getConfig();
|
||||
let stopped = false;
|
||||
let realUnsub: (() => void) | null = null;
|
||||
@@ -73,7 +111,7 @@ export function subscribeDoc(nuri: Nuri, onChange: (r: DocChange) => void): Unsu
|
||||
const cb = (resp: DocChange): void => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
onChange(resp);
|
||||
onChange(resp, docChangeType(resp));
|
||||
} catch (error) {
|
||||
console.error("[subscribe] onChange handler threw for", nuri, error);
|
||||
}
|
||||
@@ -123,7 +161,7 @@ export function subscribeDoc(nuri: Nuri, onChange: (r: DocChange) => void): Unsu
|
||||
*/
|
||||
export function subscribeDocs(
|
||||
nuris: Nuri[],
|
||||
onChange: (nuri: Nuri, r: DocChange) => void,
|
||||
onChange: (nuri: Nuri, r: DocChange, type: DocChangeType) => void,
|
||||
): Unsubscribe {
|
||||
const unique = [...new Set(nuris.filter(Boolean))];
|
||||
const unsubs = unique.map((nuri) => {
|
||||
@@ -131,7 +169,7 @@ export function subscribeDocs(
|
||||
// async setup failure (logged, never thrown), so one bad doc cannot abort the
|
||||
// construction of the others here.
|
||||
try {
|
||||
return subscribeDoc(nuri, (r) => onChange(nuri, r));
|
||||
return subscribeDoc(nuri, (r, type) => onChange(nuri, r, type));
|
||||
} catch (error) {
|
||||
console.error("[subscribe] subscribeDocs: failed to subscribe", nuri, error);
|
||||
return () => {};
|
||||
|
||||
Reference in New Issue
Block a user