Skip to content

Commit c5c77ef

Browse files
committed
feat: continue restart plan
1 parent cddd746 commit c5c77ef

11 files changed

Lines changed: 527 additions & 10 deletions

File tree

new-deepnotes/PLAN_PROGRESS.md

Lines changed: 26 additions & 10 deletions
Large diffs are not rendered by default.

new-deepnotes/apps/api-worker/src/index.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ describe("api-worker", () => {
127127
],
128128
["POST", "/api/pages/aaaaaaaaaaaaaaaaaaaaa/move"],
129129
["POST", "/api/pages/aaaaaaaaaaaaaaaaaaaaa/bump"],
130+
["GET", "/api/pages/aaaaaaaaaaaaaaaaaaaaa/collab-updates"],
131+
["POST", "/api/pages/aaaaaaaaaaaaaaaaaaaaa/collab-updates"],
130132
["POST", "/api/pages/aaaaaaaaaaaaaaaaaaaaa/backlinks"],
131133
[
132134
"DELETE",

new-deepnotes/apps/api-worker/src/index.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
groupPagesListQuerySchema,
77
pageBacklinkCreateRequestSchema,
88
pageBumpRequestSchema,
9+
pageCollabUpdatesAppendRequestSchema,
910
pageMoveRequestSchema,
1011
pageIdPathSchema,
1112
pageSnapshotCreateResponseSchema,
@@ -1577,6 +1578,130 @@ app.post("/api/pages/:pageId/bump", async (c) => {
15771578
}
15781579
});
15791580

1581+
app.get("/api/pages/:pageId/collab-updates", async (c) => {
1582+
const sessionEnv = getSessionEnv(c.env);
1583+
if (sessionEnv == null) {
1584+
return c.json(serviceUnavailableBody, 503);
1585+
}
1586+
const hyper = c.env.HYPERDRIVE;
1587+
if (hyper == null) {
1588+
return c.json(
1589+
{
1590+
code: "SERVICE_UNAVAILABLE" as const,
1591+
message: "HYPERDRIVE binding is not configured.",
1592+
},
1593+
503,
1594+
);
1595+
}
1596+
1597+
const pParams = pageIdPathSchema.safeParse({ pageId: c.req.param("pageId") });
1598+
if (!pParams.success) {
1599+
return c.json(
1600+
{ code: "VALIDATION_ERROR", message: pParams.error.message },
1601+
400,
1602+
);
1603+
}
1604+
1605+
const db = getDbForConnectionString(hyper.connectionString);
1606+
const cookieHeader = c.req.header("Cookie");
1607+
1608+
try {
1609+
const { performGetPageCollabUpdates } = await import("@deepnotes/session");
1610+
const out = await performGetPageCollabUpdates({
1611+
db,
1612+
env: sessionEnv,
1613+
accessCookie: readCookieHeader(cookieHeader, "accessToken"),
1614+
pageId: pParams.data.pageId,
1615+
});
1616+
return c.json(
1617+
{
1618+
lastIndex: out.lastIndex,
1619+
updates: out.updates.map((u) => ({
1620+
index: u.index,
1621+
encryptedData: u.encryptedData.toString("base64"),
1622+
})),
1623+
},
1624+
200,
1625+
);
1626+
} catch (e) {
1627+
const { SessionError } = await import("@deepnotes/session");
1628+
if (e instanceof SessionError) {
1629+
return c.json(
1630+
{ code: e.code, message: e.message },
1631+
e.status as ContentfulStatusCode,
1632+
);
1633+
}
1634+
throw e;
1635+
}
1636+
});
1637+
1638+
app.post("/api/pages/:pageId/collab-updates", async (c) => {
1639+
const sessionEnv = getSessionEnv(c.env);
1640+
if (sessionEnv == null) {
1641+
return c.json(serviceUnavailableBody, 503);
1642+
}
1643+
const hyper = c.env.HYPERDRIVE;
1644+
if (hyper == null) {
1645+
return c.json(
1646+
{
1647+
code: "SERVICE_UNAVAILABLE" as const,
1648+
message: "HYPERDRIVE binding is not configured.",
1649+
},
1650+
503,
1651+
);
1652+
}
1653+
1654+
let bodyJson: unknown;
1655+
try {
1656+
bodyJson = await c.req.json();
1657+
} catch {
1658+
return c.json({ code: "BAD_REQUEST", message: "Expected JSON body." }, 400);
1659+
}
1660+
1661+
const pParams = pageIdPathSchema.safeParse({ pageId: c.req.param("pageId") });
1662+
if (!pParams.success) {
1663+
return c.json(
1664+
{ code: "VALIDATION_ERROR", message: pParams.error.message },
1665+
400,
1666+
);
1667+
}
1668+
const parsed = pageCollabUpdatesAppendRequestSchema.safeParse(bodyJson);
1669+
if (!parsed.success) {
1670+
return c.json(
1671+
{
1672+
code: "VALIDATION_ERROR",
1673+
message: parsed.error.flatten().formErrors.join("; "),
1674+
},
1675+
400,
1676+
);
1677+
}
1678+
1679+
const db = getDbForConnectionString(hyper.connectionString);
1680+
const cookieHeader = c.req.header("Cookie");
1681+
1682+
try {
1683+
const { performAppendPageCollabUpdates } = await import("@deepnotes/session");
1684+
await performAppendPageCollabUpdates({
1685+
db,
1686+
env: sessionEnv,
1687+
accessCookie: readCookieHeader(cookieHeader, "accessToken"),
1688+
pageId: pParams.data.pageId,
1689+
expectedLastIndex: parsed.data.expectedLastIndex,
1690+
updates: parsed.data.updates,
1691+
});
1692+
return c.body(null, 204);
1693+
} catch (e) {
1694+
const { SessionError } = await import("@deepnotes/session");
1695+
if (e instanceof SessionError) {
1696+
return c.json(
1697+
{ code: e.code, message: e.message },
1698+
e.status as ContentfulStatusCode,
1699+
);
1700+
}
1701+
throw e;
1702+
}
1703+
});
1704+
15801705
app.post("/api/pages/:pageId/backlinks", async (c) => {
15811706
const sessionEnv = getSessionEnv(c.env);
15821707
if (sessionEnv == null) {

new-deepnotes/docs/TRPC_REST_MAP.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ Working checklist for Phase 0 of [docs/RESTART_PLAN.md](../../docs/RESTART_PLAN.
7979
| `pages.deletion.restore` | `POST /api/pages/:pageId/restore` (**implemented**`performPageRestore`) |
8080
| `pages.deletion.deletePermanently` | `POST /api/pages/:pageId/purge` (**implemented**`performPagePurge`; `num_free_pages` +1 when `pages.free` and user not Pro) |
8181

82+
### Collab bootstrap (new; not legacy tRPC)
83+
84+
| Capability | New surface | Notes |
85+
|------------|-------------|--------|
86+
| Load encrypted Yjs update chain from DB | `GET /api/pages/:pageId/collab-updates` (**implemented**`performGetPageCollabUpdates`; `viewGroupPages`; Postgres `page_updates` only, no Redis cache) | Replaces initial `ALL_UPDATES_UNMERGED`-style payload for SPA bootstrap; binary collab WebSocket is still [Phase 3 — realtime/collab](../PLAN_PROGRESS.md#not-started-phase-3--realtime--collab-only). |
87+
| Append updates (optimistic concurrency) | `POST /api/pages/:pageId/collab-updates` (**implemented**`performAppendPageCollabUpdates`; `editGroupPages`; body `expectedLastIndex` + `updates[]`) | **409** when `expectedLastIndex` is stale. |
88+
8289
## Legacy app-server WebSocket → target
8390

8491
| Legacy handler | New surface | Notes |

new-deepnotes/packages/api/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export {
4646
groupUserIdPathSchema,
4747
pageBacklinkCreateRequestSchema,
4848
pageBumpRequestSchema,
49+
pageCollabUpdatesAppendRequestSchema,
50+
pageCollabUpdatesGetResponseSchema,
4951
pageMoveRequestSchema,
5052
pageIdPathSchema,
5153
pageSnapshotCreateResponseSchema,

new-deepnotes/packages/api/src/openapi.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ describe("getOpenApiDocument", () => {
105105
).toBeDefined();
106106
expect(doc.paths?.["/api/pages/{pageId}/move"]?.post).toBeDefined();
107107
expect(doc.paths?.["/api/pages/{pageId}/bump"]?.post).toBeDefined();
108+
expect(
109+
doc.paths?.["/api/pages/{pageId}/collab-updates"]?.get,
110+
).toBeDefined();
111+
expect(
112+
doc.paths?.["/api/pages/{pageId}/collab-updates"]?.post,
113+
).toBeDefined();
108114
expect(
109115
doc.paths?.["/api/pages/{pageId}/backlinks"]?.post,
110116
).toBeDefined();

new-deepnotes/packages/api/src/openapi.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import {
3838
groupUserIdPathSchema,
3939
pageBacklinkCreateRequestSchema,
4040
pageBumpRequestSchema,
41+
pageCollabUpdatesAppendRequestSchema,
42+
pageCollabUpdatesGetResponseSchema,
4143
pageMoveRequestSchema,
4244
pageIdPathSchema,
4345
pageSnapshotCreateResponseSchema,
@@ -1202,6 +1204,60 @@ registry.registerPath({
12021204
},
12031205
});
12041206

1207+
registry.registerPath({
1208+
method: "get",
1209+
path: "/api/pages/{pageId}/collab-updates",
1210+
summary: "List encrypted Yjs page updates (Postgres)",
1211+
description:
1212+
"Bootstrap for the editor: returns all `page_updates` rows for the page, ordered by `index`. Does not use legacy Redis collab cache — Postgres only. Full duplex collab remains a separate WebSocket track (Phase 3).",
1213+
request: { params: pageIdPathSchema },
1214+
responses: {
1215+
200: {
1216+
description: "Current ciphertext chain.",
1217+
content: {
1218+
"application/json": { schema: pageCollabUpdatesGetResponseSchema },
1219+
},
1220+
},
1221+
401: sessionUnauthorized401,
1222+
403: sessionForbidden403,
1223+
404: sessionNotFound404,
1224+
503: sessionServiceUnavailable503,
1225+
},
1226+
});
1227+
1228+
registry.registerPath({
1229+
method: "post",
1230+
path: "/api/pages/{pageId}/collab-updates",
1231+
summary: "Append page updates (optimistic concurrency)",
1232+
description:
1233+
"Appends ciphertext rows to `page_updates`. `expectedLastIndex` must match the current max index (or null when empty). **409** when another writer advanced the chain — client should re-GET and retry.",
1234+
request: {
1235+
params: pageIdPathSchema,
1236+
body: {
1237+
content: {
1238+
"application/json": {
1239+
schema: pageCollabUpdatesAppendRequestSchema,
1240+
},
1241+
},
1242+
},
1243+
},
1244+
responses: {
1245+
204: { description: "Updates persisted." },
1246+
400: {
1247+
description: "Bad index sequence or wrong `expectedLastIndex` for an empty page.",
1248+
content: { "application/json": { schema: sessionErrorResponseSchema } },
1249+
},
1250+
401: sessionUnauthorized401,
1251+
403: sessionForbidden403,
1252+
404: sessionNotFound404,
1253+
409: {
1254+
description: "Stale `expectedLastIndex` (concurrent append).",
1255+
content: { "application/json": { schema: sessionErrorResponseSchema } },
1256+
},
1257+
503: sessionServiceUnavailable503,
1258+
},
1259+
});
1260+
12051261
registry.registerPath({
12061262
method: "post",
12071263
path: "/api/pages/{pageId}/backlinks",

new-deepnotes/packages/api/src/schemas/pages-groups.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,3 +350,56 @@ export const pageSnapshotLoadResponseSchema = z
350350
.openapi({ format: "byte", description: "Base64 ciphertext." }),
351351
})
352352
.openapi("PageSnapshotLoadResponse");
353+
354+
const pageCollabUpdateItemInputSchema = z
355+
.object({
356+
index: z
357+
.number()
358+
.int()
359+
.nonnegative()
360+
.openapi({
361+
description:
362+
"Monotonic index (legacy collab / `page_updates.index`, often Yjs clock).",
363+
}),
364+
encryptedData: byteB64,
365+
})
366+
.openapi("PageCollabUpdateItemInput");
367+
368+
export const pageCollabUpdatesAppendRequestSchema = z
369+
.object({
370+
expectedLastIndex: z
371+
.number()
372+
.int()
373+
.nonnegative()
374+
.nullable()
375+
.openapi({
376+
description:
377+
"Must match `lastIndex` from GET (`null` when the page has no updates yet).",
378+
}),
379+
updates: z.array(pageCollabUpdateItemInputSchema).min(1),
380+
})
381+
.openapi("PageCollabUpdatesAppendRequest");
382+
383+
export const pageCollabUpdatesGetResponseSchema = z
384+
.object({
385+
lastIndex: z
386+
.number()
387+
.int()
388+
.nonnegative()
389+
.nullable()
390+
.openapi({
391+
description: "Max `index` in the database, or null if there are no rows.",
392+
}),
393+
updates: z.array(
394+
z.object({
395+
index: z.number().int().nonnegative(),
396+
encryptedData: z
397+
.string()
398+
.openapi({
399+
format: "byte",
400+
description: "Base64 ciphertext (`page_updates.encrypted_data`).",
401+
}),
402+
}),
403+
),
404+
})
405+
.openapi("PageCollabUpdatesGetResponse");

new-deepnotes/packages/session/src/account-flows.integration.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ import {
8888
performPageSnapshotLoad,
8989
performPageSnapshotSave,
9090
performPageSoftDelete,
91+
performAppendPageCollabUpdates,
92+
performGetPageCollabUpdates,
9193
} from "./index.js";
9294
import {
9395
performCreatePage,
@@ -1374,6 +1376,79 @@ describe.skipIf(resolveTemplateContext() == null)(
13741376
groupId: "nononononononononono1",
13751377
}),
13761378
).rejects.toMatchObject({ status: 404, code: "NOT_FOUND" });
1379+
1380+
const empty = await performGetPageCollabUpdates({
1381+
db,
1382+
env,
1383+
accessCookie: access,
1384+
pageId: reg.pageId,
1385+
});
1386+
expect(empty.lastIndex).toBeNull();
1387+
expect(empty.updates).toEqual([]);
1388+
1389+
const b0 = rand32();
1390+
await performAppendPageCollabUpdates({
1391+
db,
1392+
env,
1393+
accessCookie: access,
1394+
pageId: reg.pageId,
1395+
expectedLastIndex: null,
1396+
updates: [{ index: 0, encryptedData: b0 }],
1397+
});
1398+
1399+
const one = await performGetPageCollabUpdates({
1400+
db,
1401+
env,
1402+
accessCookie: access,
1403+
pageId: reg.pageId,
1404+
});
1405+
expect(one.lastIndex).toBe(0);
1406+
expect(one.updates).toHaveLength(1);
1407+
expect(one.updates[0]!.index).toBe(0);
1408+
expect(one.updates[0]!.encryptedData.equals(Buffer.from(b0))).toBe(
1409+
true,
1410+
);
1411+
1412+
const b1 = rand32();
1413+
await performAppendPageCollabUpdates({
1414+
db,
1415+
env,
1416+
accessCookie: access,
1417+
pageId: reg.pageId,
1418+
expectedLastIndex: 0,
1419+
updates: [{ index: 1, encryptedData: b1 }],
1420+
});
1421+
1422+
const two = await performGetPageCollabUpdates({
1423+
db,
1424+
env,
1425+
accessCookie: access,
1426+
pageId: reg.pageId,
1427+
});
1428+
expect(two.lastIndex).toBe(1);
1429+
expect(two.updates).toHaveLength(2);
1430+
1431+
await expect(
1432+
performAppendPageCollabUpdates({
1433+
db,
1434+
env,
1435+
accessCookie: access,
1436+
pageId: reg.pageId,
1437+
expectedLastIndex: 0,
1438+
updates: [{ index: 2, encryptedData: rand32() }],
1439+
}),
1440+
).rejects.toMatchObject({ status: 409, code: "CONFLICT" });
1441+
1442+
await expect(
1443+
performAppendPageCollabUpdates({
1444+
db,
1445+
env,
1446+
accessCookie: access,
1447+
pageId: reg.pageId,
1448+
expectedLastIndex: 1,
1449+
updates: [{ index: 3, encryptedData: rand32() }],
1450+
}),
1451+
).rejects.toMatchObject({ status: 400, code: "BAD_REQUEST" });
13771452
} finally {
13781453
await client.end({ timeout: 5 });
13791454
const admin2 = postgres(ctx.adminUrl, { max: 1 });

0 commit comments

Comments
 (0)