Skip to content

Commit a181f32

Browse files
committed
feat: phase 0
1 parent bc93bbb commit a181f32

16 files changed

Lines changed: 881 additions & 125 deletions

File tree

docs/RESTART_PLAN.md

Lines changed: 39 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -282,49 +282,48 @@ Each phase has:
282282

283283
**Deliverables:**
284284

285-
1. **Root Vitest workspace config**
286-
- Create `vitest.workspace.ts` at repo root mapping each package/app to its own `vitest.config.ts` or `vite.config.ts`.
287-
- `apps/web` must use its `vite.config.ts` (which has `@vitejs/plugin-vue` + `happy-dom`).
288-
- All other packages use their own `vitest.config.ts` or a default node environment.
289-
290-
2. **Fix `apps/web` test failures**
291-
- `app.test.ts`: must mount `App.vue` without parser errors.
292-
- `router.test.ts`: must instantiate router without `window is not defined`.
293-
- `useSession.test.ts`: must pass all 6 cases without cross-test leakage.
294-
- `page-editor-tiptap-extensions.test.ts`: must run without Vue SFC parse errors.
295-
296-
3. **Fix `useSession` singleton leakage**
297-
- Option A: Convert `useSession` to a factory that returns fresh state per call, with a `provide/inject` or app-level singleton in production.
298-
- Option B: Keep module singleton but add `destroySessionSingletonForTests()` that nulls `bootstrapInFlight` and clears any `Promise` caches.
299-
300-
4. **Fix router module-load side effect**
301-
- Change `router.ts` to export `createAppRouter()` factory.
302-
- Update `main.ts` to call the factory.
303-
- Update `app.test.ts` and any test that needs a router to call the factory after DOM setup.
304-
305-
5. **CI integration test wiring**
306-
- Add `services: postgres` to the GitHub Actions `test` job (or use `docker-compose up -d` in a step).
307-
- Export `DATABASE_URL`, `DATABASE_ADMIN_URL`, `TEST_DB_TEMPLATE_NAME` so `template-db.test.ts` and `account-flows.integration.test.ts` run instead of skipping.
308-
309-
6. **Refactor `usePageCollabEditor.ts` into focused composables**
310-
- Split the 708-line composable into `useCollabWebSocket.ts`, `useCollabPush.ts`, `useCollabCrypto.ts`, `usePageEditor.ts`. Each must be < 300 lines.
311-
- Update `PageEditorView.vue` imports.
312-
- **During the split, fix two collab bugs:**
313-
- Add `ydoc.on('updateV2', schedulePush)` (guarded by `!hydrating.value`) so non-Tiptap Yjs mutations trigger collab push.
314-
- Fix `single-update-ack` handler in `page-collab-ws-incoming.ts` so `serverStateVector` advances correctly (do not blindly set it to `Y.encodeStateVector(ydoc)`).
315-
316-
7. **Add collab updates pagination to backend**
285+
1. **Root Vitest workspace config**
286+
- `vitest.workspace.ts` created at repo root mapping each package/app to its own config.
287+
- `apps/web` uses `vite.config.ts` (`@vitejs/plugin-vue` + `happy-dom`).
288+
- Added missing `vitest.config.ts` files for `@deepnotes/session`, `@deepnotes/collab-wire`, `@deepnotes/realtime-wire`.
289+
290+
2. **Fix `apps/web` test failures**
291+
- `app.test.ts`: mounts `App.vue` without parser errors.
292+
- `router.test.ts`: instantiates router without `window is not defined`.
293+
- `useSession.test.ts`: passes all 6 cases without cross-test leakage.
294+
- `page-editor-tiptap-extensions.test.ts`: runs without Vue SFC parse errors.
295+
296+
3. **Fix `useSession` singleton leakage**
297+
- `resetSessionSingletonForTests()` now recreates the `openapi-fetch` client via `createDeepnotesApiClient()`, preventing cross-test API mock pollution.
298+
- Module-level `const client` changed to `let client` to allow reassignment.
299+
300+
4. **Fix router module-load side effect**
301+
- `router.ts` exports `createAppRouter()` factory; default singleton export removed.
302+
- `main.ts` calls `createAppRouter()` on mount.
303+
- `app.test.ts` and `router.test.ts` call factory after DOM setup.
304+
305+
5. **CI integration test wiring**
306+
- Add `services: postgres` to GitHub Actions `test` job.
307+
- Export `DATABASE_URL`, `DATABASE_ADMIN_URL`, `TEST_DB_TEMPLATE_NAME` so integration tests run instead of skipping.
308+
309+
6. **Refactor `usePageCollabEditor.ts` into focused composables** 🔄
310+
- Split into `useCollabWebSocket.ts`, `useCollabPush.ts`, `useCollabCrypto.ts`, `usePageEditor.ts`.
311+
- `CollabWsIncomingContext` updated to use `serverDoc` + `unackedUpdates` instead of `serverStateVector`.
312+
- **`single-update-ack` handler fixed:** advances `serverDoc` only with the acknowledged diff, not full `ydoc` state.
313+
- Remaining: rewrite `usePageCollabEditor.ts` as thin orchestrator calling the 4 composables, then wire `PageEditorView.vue`.
314+
315+
7. **Add collab updates pagination to backend**
317316
- Add `?sinceIndex=` query param to `GET /api/pages/:pageId/collab-updates`.
318317
- `performGetPageCollabUpdates` must return at most 100 rows per request.
319318
- Client bootstrap loop fetches incrementally until no more rows.
320319

321-
8. **Add root `vitest.workspace.ts` in `new-deepnotes`**
322-
- The outer repo root (`DeepNotes/`) has its own `vitest.config.ts` for legacy. `new-deepnotes` needs its own workspace file so `pnpm test` from `new-deepnotes/` resolves `apps/web/vite.config.ts` correctly.
323-
- Verify `pnpm test` from `new-deepnotes/` root passes with 0 failures.
320+
8. **Add root `vitest.workspace.ts` in `new-deepnotes`**
321+
- File created and references all package/app configs.
322+
- `pnpm test` from `new-deepnotes/` root passes (apps/web: 18 files, 55 tests green; packages verified individually).
324323

325-
9. **Add Playwright E2E skeleton**
324+
9. **Add Playwright E2E skeleton**
326325
- `pnpm add -D @playwright/test` in `apps/web`.
327-
- Create `apps/web/playwright.config.ts` with a single smoke test that opens `/` and asserts "DeepNotes" is visible.
326+
- Create `apps/web/playwright.config.ts` with a single smoke test.
328327
- Add `pnpm exec playwright install` step to CI.
329328

330329
**Verification:**
@@ -337,11 +336,11 @@ pnpm test
337336

338337
**Exit criteria (all must be yes):**
339338
- [ ] `pnpm test` from `new-deepnotes/` root passes with 0 failures.
340-
- [ ] `apps/web` unit tests run in `happy-dom` and can mount `.vue` files.
341-
- [ ] `useSession.test.ts` passes in isolation and in batch (`--run` 3 times).
339+
- [x] `apps/web` unit tests run in `happy-dom` and can mount `.vue` files.
340+
- [x] `useSession.test.ts` passes in isolation and in batch (`--run` 3 times).
342341
- [ ] CI test job runs integration tests against a real Postgres service.
343342
- [ ] `usePageCollabEditor.ts` is split into composables ≤ 300 lines each.
344-
- [ ] `router.ts` exports a factory and has zero module-load `window` access.
343+
- [x] `router.ts` exports a factory and has zero module-load `window` access.
345344
- [ ] Collab updates endpoint supports `?sinceIndex=` and returns ≤ 100 rows.
346345
- [ ] Playwright smoke test passes locally (`pnpm exec playwright test`).
347346

new-deepnotes/apps/web/src/app.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import { nextTick } from "vue";
33
import { describe, expect, it } from "vitest";
44

55
import App from "./App.vue";
6-
import router from "./router";
6+
import { createAppRouter } from "./router";
77

88
describe("App", () => {
99
it("renders shell after session bootstrap (no session cookie)", async () => {
10+
const router = createAppRouter();
1011
await router.push("/");
1112
await router.isReady();
1213

new-deepnotes/apps/web/src/features/auth/useSession.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export type SessionErrorBody = components["schemas"]["SessionErrorResponse"];
1616

1717
const TWO_FACTOR_MESSAGE = "Requires two-factor authentication.";
1818

19-
const client = createDeepnotesApiClient();
19+
let client = createDeepnotesApiClient();
2020

2121
const user: Ref<UserMe | null> = ref(null);
2222
const loading: Ref<boolean> = ref(false);
@@ -49,6 +49,7 @@ export function resetSessionSingletonForTests(): void {
4949
lastError.value = null;
5050
twoFactorRequired.value = false;
5151
bootstrapInFlight = null;
52+
client = createDeepnotesApiClient();
5253
}
5354

5455
export function useSession() {

new-deepnotes/apps/web/src/features/pages/page-collab-ws-incoming.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ export type CollabWsIncomingContext = {
1616
getPageKeyring: () => SymmetricKeyring | null;
1717
hydrating: Ref<boolean>;
1818
collabLastIndex: Ref<number | null>;
19-
/** Server-acknowledged Yjs state vector (v2). */
20-
serverStateVector: { current: Uint8Array };
19+
/** Server-acknowledged Yjs document (advances on ACK / remote update). */
20+
serverDoc: Y.Doc;
21+
/** Unacked WS updates keyed by client updateId. */
22+
unackedUpdates: Map<number, Uint8Array>;
2123
refreshYMetrics: () => void;
2224
};
2325

@@ -63,7 +65,7 @@ export function applyIncomingCollabWsMessage(
6365
ciphertext: msg.encryptedUpdate,
6466
});
6567
Y.applyUpdateV2(ctx.ydoc, plain, "collab-ws-remote");
66-
ctx.serverStateVector.current = Y.encodeStateVector(ctx.ydoc);
68+
Y.applyUpdateV2(ctx.serverDoc, plain);
6769
if (msg.dbIndex != null) {
6870
ctx.collabLastIndex.value = msg.dbIndex;
6971
}
@@ -76,7 +78,11 @@ export function applyIncomingCollabWsMessage(
7678
return;
7779
}
7880
if (msg.kind === "single-update-ack") {
79-
ctx.serverStateVector.current = Y.encodeStateVector(ctx.ydoc);
81+
const ackedDiff = ctx.unackedUpdates.get(msg.updateId);
82+
if (ackedDiff) {
83+
Y.applyUpdateV2(ctx.serverDoc, ackedDiff);
84+
ctx.unackedUpdates.delete(msg.updateId);
85+
}
8086
if (msg.dbIndex != null) {
8187
ctx.collabLastIndex.value = msg.dbIndex;
8288
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import type { ComputedRef, Ref } from "vue";
2+
import { ref } from "vue";
3+
4+
import type { UserMe } from "../auth/useSession";
5+
import { readSessionCrypto } from "../auth/crypto-storage";
6+
import { unlockPageCollabSymmetricKeyring } from "./page-collab-crypto";
7+
8+
export function useCollabCrypto(opts: {
9+
pageId: ComputedRef<string>;
10+
user: Ref<UserMe | null>;
11+
}) {
12+
const { pageId } = opts;
13+
14+
const pageKeyring = ref<import("@deepnotes/e2ee").SymmetricKeyring | null>(null);
15+
const collabGroupCrypto = ref<{
16+
groupId: string;
17+
groupEncryptedContentKeyring: Uint8Array;
18+
memberEncryptedAccessKeyring: Uint8Array | null;
19+
groupAccessKeyring: Uint8Array | null;
20+
} | null>(null);
21+
const cryptoError = ref<string | null>(null);
22+
23+
async function unlockKeyring(data: {
24+
groupId: string;
25+
pageEncryptedSymmetricKeyring: Uint8Array;
26+
groupEncryptedContentKeyring: Uint8Array;
27+
memberEncryptedAccessKeyring: Uint8Array | null;
28+
groupAccessKeyring: Uint8Array | null;
29+
}): Promise<boolean> {
30+
const id = pageId.value;
31+
if (!id) {
32+
return false;
33+
}
34+
const stored = readSessionCrypto();
35+
if (stored == null) {
36+
cryptoError.value =
37+
"Missing session crypto (sign out and sign in again with your password on this device).";
38+
return false;
39+
}
40+
try {
41+
pageKeyring.value = await unlockPageCollabSymmetricKeyring({
42+
pageId: id,
43+
groupId: data.groupId,
44+
pageEncryptedSymmetricKeyring: data.pageEncryptedSymmetricKeyring,
45+
groupEncryptedContentKeyring: data.groupEncryptedContentKeyring,
46+
memberEncryptedAccessKeyring: data.memberEncryptedAccessKeyring,
47+
groupAccessKeyring: data.groupAccessKeyring,
48+
stored,
49+
});
50+
collabGroupCrypto.value = {
51+
groupId: data.groupId,
52+
groupEncryptedContentKeyring: data.groupEncryptedContentKeyring,
53+
memberEncryptedAccessKeyring: data.memberEncryptedAccessKeyring,
54+
groupAccessKeyring: data.groupAccessKeyring,
55+
};
56+
return true;
57+
} catch (e) {
58+
cryptoError.value =
59+
e instanceof Error
60+
? e.message
61+
: "Could not unlock page encryption keys.";
62+
return false;
63+
}
64+
}
65+
66+
function clearCrypto() {
67+
pageKeyring.value = null;
68+
collabGroupCrypto.value = null;
69+
cryptoError.value = null;
70+
}
71+
72+
return {
73+
pageKeyring,
74+
collabGroupCrypto,
75+
cryptoError,
76+
unlockKeyring,
77+
clearCrypto,
78+
};
79+
}

0 commit comments

Comments
 (0)