Skip to content

Commit b6b9013

Browse files
authored
Actor identity mapping: resolve screen names in replay (#71)
* Actor identity mapping: resolve screen names in replay * Simplification review * Use auth.name as default * Screen name: add Reset and Randomize buttons, sync name changes to replay * Set color along with screen name * Cleanup review
1 parent 01c54f9 commit b6b9013

16 files changed

Lines changed: 630 additions & 36 deletions

File tree

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
# Actor Identity Mapping Plan
2+
3+
## Overview
4+
5+
Tie Automerge actor IDs (SHA-256 hex strings from Google OAuth `sub`) to user screen names by storing an `identities` mapping in the IndexDocument. Add schema versioning so the document format can evolve. In replay, resolve actor hashes to screen names and use a distinct "Me" bounding box colour.
6+
7+
## Current State
8+
9+
- **IndexDocument schema** (`ts-packages/quarto-automerge-schema/src/index.ts`):
10+
```typescript
11+
interface IndexDocument {
12+
files: Record<string, string>; // path -> docId (exposed as FileEntry[])
13+
}
14+
```
15+
- **Screen names** are stored locally in IndexedDB (`userSettings` store) and used only for presence (ephemeral messaging). They are **not** persisted in the Automerge document.
16+
- **Actor IDs** are SHA-256 hashes of Google OAuth `sub` claims, computed server-side and returned via `GET /auth/me`. They are set on the Automerge repo and embedded in change history.
17+
- **Replay** shows `state.actor.slice(0, 8)` (truncated hex) or "Me" if the actor matches `currentActorId`. The "Me" case uses the same CSS styling as other actors.
18+
- **Waveform** colours are derived from actor hash via `actorColor()` — deterministic hue from first 6 hex chars.
19+
- **`getUserIdentity()`** (`hub-client/src/services/userSettings.ts`) is async — returns `Promise<UserSettings>` with `.userName`.
20+
- **`SyncClientCallbacks`** is defined in `ts-packages/quarto-sync-client/src/types.ts`. The `onFilesChange` callback already fires on any IndexDocument change.
21+
22+
## Design
23+
24+
### Schema V1: IndexDocument with identities and version
25+
26+
```typescript
27+
interface IndexDocument {
28+
version: number; // schema version (1)
29+
files: Record<string, string>; // path -> docId (unchanged)
30+
identities: Record<string, string>; // actorId -> screenName
31+
}
32+
```
33+
34+
- `version` field: integer, currently `1`. Missing `version` means V0 (pre-versioning).
35+
- `identities` field: maps Automerge actor ID (64-char hex) to the user's current screen name.
36+
- Both fields are added to the Automerge document, so they sync to all peers and persist in history.
37+
38+
### Migration Strategy
39+
40+
When a client opens a project and the IndexDocument has no `version` field:
41+
1. Set `version = 1`
42+
2. Initialize `identities = {}`
43+
3. Write the current user's identity mapping
44+
45+
This is safe because Automerge merges concurrent changes — if two clients migrate simultaneously, both additions merge cleanly (they're setting non-conflicting keys).
46+
47+
**Automerge initialization note**: Inside a `handle.change()` callback, assigning `doc.identities = {}` creates an Automerge Map proxy. This is the correct pattern — Automerge intercepts the assignment. However, **do not** create a plain JS object outside the callback and assign it; always assign inside `change()`.
48+
49+
### Identity Sync on Connect
50+
51+
Every time a user opens a project (in `connect()` or `createNewProject()`):
52+
1. Read `doc.identities[actorId]`
53+
2. Compare to the user's current screen name
54+
3. If missing or different, update `doc.identities[actorId] = screenName`
55+
56+
This ensures name changes are propagated to the document.
57+
58+
### Screen Name Loading
59+
60+
Screen name is loaded from `getUserIdentity()` (async, IndexedDB) in `App.tsx`. It must be available **before** any connect/create call. The loading sequence:
61+
62+
1. Auth resolves (`useAuth()` — provides `actorId` and OIDC `name`)
63+
2. User identity resolves (`getUserIdentity()` — provides `userName`)
64+
3. **OIDC name defaulting**: If auth provides a display name and the stored name is still an auto-generated anonymous one (starts with "Anonymous "), the screen name is automatically upgraded to the OIDC display name and persisted to IndexedDB. This happens once on first login; subsequent visits use the persisted value.
65+
4. Both are available when project selector renders
66+
5. `screenName` is passed alongside `actorId` through `connect()` and `createNewProject()`
67+
68+
For non-auth instances (`AUTH_ENABLED` is false), screen name loads immediately from IndexedDB without waiting for auth, preserving the anonymous name default.
69+
70+
### Screen Name Reset
71+
72+
The ProjectSelector "Your Identity" section has a reset button:
73+
- **With auth**: "Reset Name" — resets screen name to the OIDC display name (`auth.name`)
74+
- **Without auth**: "Randomize Name" — generates a new anonymous name (original behavior)
75+
76+
### Identity Change Notification
77+
78+
A separate `onIdentitiesChange` callback keeps concerns cleanly separated — file changes and identity changes are logically independent events (e.g. a new user connecting updates identities but not files). The `onFilesChange` signature stays untouched.
79+
80+
```typescript
81+
onIdentitiesChange?: (identities: Record<string, string>) => void
82+
```
83+
84+
In the `indexHandle.on('change', ...)` handler, the sync client diffs both `files` and `identities` from the previous state, firing each callback only when its data actually changes. This means consumers subscribe only to what they care about.
85+
86+
### Stale Identities
87+
88+
Identities persist in the Automerge document forever. This is intentional — the document history is immutable, and stale identity mappings are harmless (they only map actor ID to screen name). Old contributors' names remain resolvable in replay, which is a feature.
89+
90+
### Replay Changes
91+
92+
- **Actor label**: Instead of `state.actor.slice(0, 8)`, look up `identities[state.actor]` from the IndexDocument. Fall back to truncated hex if not found.
93+
- **"Me" indicator**: Use a visually distinct bounding box (different border colour/background) instead of replacing the name with "Me". Display both the screen name AND the "Me" indicator so the user knows their own name as others see it.
94+
- **Waveform**: `actorColor()` stays hash-based (deterministic, no lookup needed). No change.
95+
96+
## Work Items
97+
98+
### Phase 1: Tests for Schema Changes
99+
100+
- [x] **1.1** Write unit tests for `IndexDocument` migration in `ts-packages/quarto-automerge-schema`:
101+
- V0 doc (no version, no identities) -> migrates to V1
102+
- V1 doc (already has version) -> no-op
103+
- `setIdentity` adds new, overwrites changed, leaves unchanged
104+
- [x] **1.2** Run tests — verify they pass (8 tests)
105+
106+
### Phase 2: Schema Implementation
107+
108+
- [x] **2.1** Update `IndexDocument` type in `ts-packages/quarto-automerge-schema/src/index.ts`:
109+
- Add `version?: number` and `identities?: Record<string, string>` fields
110+
- Make them optional since V0 docs won't have them
111+
- Export a `CURRENT_SCHEMA_VERSION = 1` constant
112+
- [x] **2.2** Add a migration helper in `ts-packages/quarto-automerge-schema/src/index.ts`:
113+
- `migrateIndexDocument(doc: IndexDocument): boolean` — returns true if changes were made
114+
- Checks for missing `version`, sets to 1, initializes `identities` if absent
115+
- [x] **2.3** Add an identity update helper:
116+
- `setIdentity(doc: IndexDocument, actorId: string, screenName: string): boolean` — returns true if identity was added/updated
117+
- [x] **2.4** Run Phase 1 tests — verify they pass
118+
119+
### Phase 3: Tests for Sync Client Integration
120+
121+
- [ ] **3.1** Write unit tests for sync client identity flow in `ts-packages/quarto-sync-client`:
122+
- `connect()` with actorId + screenName writes identity to doc
123+
- `createNewProject()` initializes with version and identity
124+
- Screen name update overwrites stale value
125+
- `onIdentitiesChange` fires when identities change but not when only files change
126+
- [ ] **3.2** Run tests — verify they fail
127+
128+
### Phase 4: Sync Client Implementation
129+
130+
- [x] **4.1** Add `onIdentitiesChange` callback to `SyncClientCallbacks` in `ts-packages/quarto-sync-client/src/types.ts`:
131+
- `onIdentitiesChange?: (identities: Record<string, string>) => void`
132+
- `onFilesChange` signature stays unchanged
133+
- [x] **4.2** Update `connect()` in `ts-packages/quarto-sync-client/src/client.ts`:
134+
- Add `screenName?: string` parameter (after `actorId`)
135+
- After loading the IndexDocument, call `indexHandle.change()` to migrate schema if needed
136+
- Write `identities[actorId] = screenName` if it differs from what's stored
137+
- In the `indexHandle.on('change', ...)` handler, diff identities from previous state and call `onIdentitiesChange` only when identities actually changed
138+
- Fire `onIdentitiesChange` on initial load with current identities
139+
- [x] **4.3** Update `createNewProject()` in the same file:
140+
- Add `screenName?: string` parameter (after `actorId`)
141+
- When initializing the IndexDocument, set `version: 1`, `identities: {}`, and write the creator's identity
142+
- Fire `onIdentitiesChange` with initial identities
143+
- [ ] **4.4** Run Phase 3 tests — verify they pass
144+
145+
### Phase 5: Tests for Hub-Client UI
146+
147+
- [x] **5.1** Write tests for `ReplayDrawer`:
148+
- Renders screen name instead of hex when identity available
149+
- Renders truncated hex when identity not available
150+
- Applies `--me` CSS class for current actor
151+
- [x] **5.2** Run tests — verify they pass (29 tests)
152+
153+
### Phase 6: Hub-Client Implementation
154+
155+
- [x] **6.1** Update `automergeSync.ts` (`hub-client/src/services/automergeSync.ts`):
156+
- Add `screenName` parameter to `connect()` and `createNewProject()`
157+
- Forward to sync client
158+
- [x] **6.2** Update `App.tsx` (`hub-client/src/App.tsx`):
159+
- Add `screenName` state, loaded via `getUserIdentity().userName` in a `useEffect` (runs after auth resolves, or unconditionally when auth is disabled)
160+
- Pass `screenName` to all `connectAndLoadContents()` and `createNewProject()` calls
161+
- Store `identities` map in React state via `onIdentitiesChange` callback
162+
- Pass `identities` to `Editor`
163+
- [x] **6.3** Update `Editor.tsx` (`hub-client/src/components/Editor.tsx`):
164+
- Accept `identities` prop (`Record<string, string>`)
165+
- Pass it through to `ReplayDrawer`
166+
- [x] **6.4** Update `ReplayDrawer` (`hub-client/src/components/ReplayDrawer.tsx`):
167+
- Add `identities` to `Props` interface
168+
- Update actor label rendering:
169+
- Resolve `state.actor` to screen name via `identities[state.actor]`
170+
- Fall back to `state.actor.slice(0, 8)` if no identity found
171+
- For "Me": show `screenName (Me)` alongside the resolved name
172+
- Add `replay-drawer__actor--me` CSS class with distinct border/background colour
173+
- [x] **6.5** Run Phase 5 tests — verify they pass
174+
175+
### Phase 7: Full Verification
176+
177+
- [x] **7.1** `cargo build --workspace` — passes
178+
- [x] **7.2** Run hub-client tests: 383 tests pass (including schema + ReplayDrawer)
179+
- [x] **7.3** Run hub-client build: passes
180+
- [x] **7.4** e2e fixtures create V0 IndexDocuments; migration handles them at connect time — no changes needed
181+
182+
## Files Modified
183+
184+
### `ts-packages/quarto-automerge-schema/src/index.ts`
185+
- `IndexDocument` type: add `version?` and `identities?`
186+
- New exports: `CURRENT_SCHEMA_VERSION`, `migrateIndexDocument()`, `setIdentity()`
187+
188+
### `ts-packages/quarto-sync-client/src/types.ts`
189+
- Add `onIdentitiesChange?: (identities: Record<string, string>) => void` callback
190+
- `onFilesChange` signature unchanged
191+
192+
### `ts-packages/quarto-sync-client/src/client.ts`
193+
- `connect()`: add `screenName` param, call migration + identity sync, fire `onIdentitiesChange`
194+
- `createNewProject()`: add `screenName` param, initialize V1 schema with identity, fire `onIdentitiesChange`
195+
- In change handler, diff identities and fire `onIdentitiesChange` only when identities changed
196+
197+
### `hub-client/src/services/automergeSync.ts`
198+
- `connect()`, `createNewProject()`: add `screenName` passthrough
199+
200+
### `hub-client/src/App.tsx`
201+
- New state: `screenName` (loaded from `getUserIdentity()`, auto-upgraded from OIDC name on first login)
202+
- Pass `screenName` to connect/create calls (with `screenName` in dependency arrays)
203+
- Store + update `identities` via `onIdentitiesChange` callback
204+
- Pass `identities` to `Editor`
205+
- Pass `authName` to `ProjectSelector` for screen name reset
206+
207+
### `hub-client/src/components/Editor.tsx`
208+
- Accept + forward `identities` prop to `ReplayDrawer`
209+
210+
### `hub-client/src/components/ProjectSelector.tsx`
211+
- Accept `authName` prop (OIDC display name)
212+
- "Reset Name" button: resets to OIDC name when available, randomizes when not
213+
214+
### `hub-client/src/components/ReplayDrawer.tsx`
215+
- Accept `identities` prop
216+
- Resolve actor to screen name
217+
- Add `--me` CSS class with distinct styling
218+
219+
### `hub-client/src/components/ReplayDrawer.css`
220+
- New `.replay-drawer__actor--me` class
221+
222+
### Test files (new or updated)
223+
- `ts-packages/quarto-automerge-schema/src/__tests__/migration.test.ts`
224+
- `ts-packages/quarto-sync-client/src/__tests__/identity.test.ts`
225+
- `hub-client/src/components/ReplayDrawer.test.tsx` (update existing)
226+
227+
### Possibly updated
228+
- `hub-client/e2e/scripts/regenerate-fixtures.ts` (if fixtures include IndexDocument)
229+
230+
## Key Decisions
231+
232+
1. **Optional fields**: `version` and `identities` are optional on the type to handle V0 docs. Migration happens at connect time.
233+
234+
2. **Screen name source**: The screen name stored in IndexedDB is the authoritative source, persisted in the Automerge document's `identities` map. On first login, if the stored name is still an auto-generated "Anonymous ..." name, it is automatically upgraded to the OIDC display name (`auth.name`) and persisted to IndexedDB. Users can further customize it; a "Reset Name" button restores it to the OIDC display name. For non-auth instances, anonymous names are used as the default.
235+
236+
3. **Screen name loading**: Loaded in `App.tsx` via `useEffect` after auth. Available before any project connection. Passed explicitly through the call chain — not fetched inside the sync client (which has no IndexedDB access).
237+
238+
4. **No colour in identities**: Cursor colours are per-session (presence) and per-hash (replay waveform). Storing them in the document would create conflicts. Keep colours derived locally.
239+
240+
5. **Concurrent migration safety**: Automerge's CRDT semantics mean two clients setting `version = 1` and `identities = {}` simultaneously will merge correctly. Identity writes to different keys merge without conflict.
241+
242+
6. **"Me" indication**: Use a different CSS class (`--me`) with a distinct border colour rather than replacing the name. This way the user sees their own name as others see it while still knowing which edits are theirs.
243+
244+
7. **Separate `onIdentitiesChange` callback**: Files and identities are logically independent events (a user connecting changes identities but not files). A dedicated callback keeps `onFilesChange` untouched, lets consumers subscribe only to what they need, and fires only when identities actually change.
245+
246+
8. **Stale identities are fine**: Identity mappings persist forever in the Automerge document. The history is immutable, and keeping old mappings means replay can always resolve actor names — even for contributors who have left.
247+
248+
9. **Scrubber tooltip**: The waveform scrubber tooltip only shows timestamps (not actor info), so no changes needed there.

0 commit comments

Comments
 (0)