Skip to content

Commit b084ba9

Browse files
committed
feat: persist page title changes to Postgres and enable live title editing
- Add drizzle-orm dependency to api-worker for DB updates - Persist encrypted page titles to Postgres in UserRealtimeRoom when HSET broadcasts occur - Add buildRealtimeHset helper to send title updates via WebSocket - Implement updatePageTitle() to encrypt and broadcast title changes - Add editedRelativeTitle/editedAbsoluteTitle refs for optimistic UI updates - Split pageLabels into currentPageRelativeTitle and currentPageAbsoluteTitle comput
1 parent 8992ad1 commit b084ba9

5 files changed

Lines changed: 146 additions & 21 deletions

File tree

new-deepnotes/apps/api-worker/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"@deepnotes/session": "workspace:*",
2323
"@deepnotes/session-core": "workspace:*",
2424
"@upstash/redis": "^1.34.8",
25+
"drizzle-orm": "^0.41.0",
2526
"msgpackr": "^1.11.8",
2627
"hono": "^4.7.7",
2728
"stripe": "^17.7.0"

new-deepnotes/apps/api-worker/src/user-realtime-room.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { Redis } from "@upstash/redis";
2+
import { eq } from "drizzle-orm";
3+
import { pages } from "@deepnotes/db/schema";
24
import {
35
decodeRealtimeClientBinaryMessage,
46
encodeRealtimeServerDataNotification,
@@ -417,6 +419,33 @@ export class UserRealtimeRoom {
417419
ws.send(out.subscribeNotifyBytes);
418420
}
419421

422+
// Persist page title changes to Postgres so they survive refresh.
423+
if (hyper != null) {
424+
const db = getDbForConnectionString(hyper.connectionString);
425+
for (const item of out.hsetBroadcastItems) {
426+
if (item.prefix !== "page") continue;
427+
const pageId = item.suffix;
428+
const b64 = typeof item.value === "string" ? item.value : "";
429+
if (b64 === "") continue;
430+
const bytes = Buffer.from(b64, "base64");
431+
try {
432+
if (item.field === "encrypted-relative-title") {
433+
await db
434+
.update(pages)
435+
.set({ encryptedRelativeTitle: bytes })
436+
.where(eq(pages.id, pageId));
437+
} else if (item.field === "encrypted-absolute-title") {
438+
await db
439+
.update(pages)
440+
.set({ encryptedAbsoluteTitle: bytes })
441+
.where(eq(pages.id, pageId));
442+
}
443+
} catch {
444+
// Best-effort DB persistence.
445+
}
446+
}
447+
}
448+
420449
for (const item of out.hsetBroadcastItems) {
421450
const subs = this._fieldSubs.get(item.fullKey);
422451
if (subs == null || subs.size === 0) {

new-deepnotes/apps/web/src/features/pages/PageEditorView.vue

Lines changed: 101 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,13 @@ import { createPageCollabDoc } from "./page-yjs-doc";
2727
import { usePageCollabEditor } from "./usePageCollabEditor";
2828
import { usePageManagement } from "./usePageManagement";
2929
import { usePagePathAndPrefs } from "./usePagePathAndPrefs";
30-
import { base64ToBytes } from "@deepnotes/e2ee";
31-
import { decryptPageRelativeTitle } from "./page-collab-crypto";
30+
import { base64ToBytes, bytesToBase64 } from "@deepnotes/e2ee";
31+
import {
32+
decryptPageRelativeTitle,
33+
decryptPageAbsoluteTitle,
34+
} from "./page-collab-crypto";
3235
import { usePagePathRealtimeTitles } from "./usePagePathRealtimeTitles";
36+
import { buildRealtimeHset, sendRealtimeRequestBatch } from "../realtime/realtime-user-ws";
3337
import { usePageSnapshots } from "./usePageSnapshots";
3438
import PageLayout from "@/layouts/PageLayout.vue";
3539
import PageStateScreens from "./screens/PageStateScreens.vue";
@@ -169,31 +173,71 @@ const { pathPageLabels } = usePagePathRealtimeTitles({
169173
cryptoError,
170174
});
171175
172-
// Merge realtime labels with the current page's bootstrap-encrypted title.
173-
// usePagePathRealtimeTitles clears and refills pathPageLabels asynchronously,
174-
// so a simple watcher would be overwritten. A computed always re-evaluates.
175-
const pageLabels = computed<Record<string, string>>(() => {
176-
const labels = { ...pathPageLabels.value };
176+
// Separate reactive overrides so local edits reflect immediately without
177+
// waiting for the async realtime round-trip.
178+
const editedRelativeTitle = ref<string | null>(null);
179+
const editedAbsoluteTitle = ref<string | null>(null);
180+
181+
watch(pageId, () => {
182+
editedRelativeTitle.value = null;
183+
editedAbsoluteTitle.value = null;
184+
});
185+
186+
const currentPageRelativeTitle = computed(() => {
187+
if (editedRelativeTitle.value != null) return editedRelativeTitle.value;
177188
const id = pageId.value;
178-
if (labels[id] && labels[id].length > 0) {
179-
return labels;
180-
}
181189
const pk = pageKeyring.value;
182190
const b64 = pageEncRelTitleB64.value;
183-
if (!id || !pk || !b64) {
184-
return labels;
191+
if (!id || !pk || !b64) return `[Page ${id}]`;
192+
try {
193+
const t = decryptPageRelativeTitle({
194+
pageKeyring: pk,
195+
pageId: id,
196+
ciphertext: base64ToBytes(b64),
197+
});
198+
return t && t.length > 0 ? t : `[Page ${id}]`;
199+
} catch {
200+
return `[Page ${id}]`;
185201
}
202+
});
203+
204+
const currentPageAbsoluteTitle = computed(() => {
205+
if (editedAbsoluteTitle.value != null) return editedAbsoluteTitle.value;
206+
const id = pageId.value;
207+
const fromRealtime = pathPageLabels.value[id];
208+
if (fromRealtime && fromRealtime.length > 0) return fromRealtime;
209+
const pk = pageKeyring.value;
210+
const b64 = pageEncAbsTitleB64.value;
211+
if (!id || !pk || !b64) return `[Page ${id}]`;
186212
try {
187-
const title = decryptPageRelativeTitle({
213+
const t = decryptPageAbsoluteTitle({
188214
pageKeyring: pk,
189215
pageId: id,
190216
ciphertext: base64ToBytes(b64),
191217
});
192-
if (title && title.length > 0) {
193-
labels[id] = title;
194-
}
218+
return t && t.length > 0 ? t : `[Page ${id}]`;
219+
} catch {
220+
return `[Page ${id}]`;
221+
}
222+
});
223+
224+
// Breadcrumb labels: absolute title from realtime, fallback to decrypted bootstrap.
225+
const pageLabels = computed<Record<string, string>>(() => {
226+
const labels = { ...pathPageLabels.value };
227+
const id = pageId.value;
228+
if (labels[id] && labels[id].length > 0) return labels;
229+
const pk = pageKeyring.value;
230+
const b64 = pageEncAbsTitleB64.value;
231+
if (!id || !pk || !b64) return labels;
232+
try {
233+
const t = decryptPageAbsoluteTitle({
234+
pageKeyring: pk,
235+
pageId: id,
236+
ciphertext: base64ToBytes(b64),
237+
});
238+
if (t && t.length > 0) labels[id] = t;
195239
} catch {
196-
// ignore decrypt failures
240+
// ignore
197241
}
198242
return labels;
199243
});
@@ -242,6 +286,42 @@ async function onUnlockWithPassword(password: string): Promise<boolean> {
242286
return ok;
243287
}
244288
289+
async function updatePageTitle(type: "relative" | "absolute", value: string) {
290+
const id = pageId.value;
291+
const pk = pageKeyring.value;
292+
if (!id || !pk) return;
293+
try {
294+
const ciphertext = pk.encrypt(new TextEncoder().encode(value), {
295+
padding: true,
296+
associatedData: {
297+
context:
298+
type === "relative" ? "PageRelativeTitle" : "PageAbsoluteTitle",
299+
pageId: id,
300+
},
301+
});
302+
void sendRealtimeRequestBatch([
303+
buildRealtimeHset(
304+
"page",
305+
id,
306+
type === "relative"
307+
? "encrypted-relative-title"
308+
: "encrypted-absolute-title",
309+
bytesToBase64(ciphertext),
310+
),
311+
]);
312+
// Optimistic update so the input stays in sync immediately.
313+
if (type === "relative") {
314+
editedRelativeTitle.value = value;
315+
} else {
316+
editedAbsoluteTitle.value = value;
317+
// Also update the shared labels map used by breadcrumb/cards.
318+
pathPageLabels.value = { ...pathPageLabels.value, [id]: value };
319+
}
320+
} catch {
321+
// ignore encrypt failures
322+
}
323+
}
324+
245325
onMounted(() => {
246326
if (!isAuthenticated.value) {
247327
void router.replace({
@@ -419,12 +499,12 @@ onMounted(() => {
419499
<PagePropertiesCard
420500
v-if="!selectedNoteId && !selectedArrowId"
421501
:page-id="pageId"
422-
:relative-title="pageLabels[pageId]"
423-
:absolute-title="pageLabels[pageId]"
502+
:relative-title="currentPageRelativeTitle"
503+
:absolute-title="currentPageAbsoluteTitle"
424504
:is-favorite="isFavorite"
425505
:read-only="cryptoError !== null"
426-
@update:relative-title="() => {}"
427-
@update:absolute-title="() => {}"
506+
@update:relative-title="updatePageTitle('relative', $event)"
507+
@update:absolute-title="updatePageTitle('absolute', $event)"
428508
@toggle-favorite="toggleFavorite()"
429509
/>
430510

new-deepnotes/apps/web/src/features/realtime/realtime-user-ws.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,15 @@ export function buildRealtimeUnsubscribe(
294294
args: [prefix, suffix, field],
295295
};
296296
}
297+
298+
export function buildRealtimeHset(
299+
prefix: string,
300+
suffix: string,
301+
field: string,
302+
value: unknown,
303+
): RealtimeClientCommand {
304+
return {
305+
type: RealtimeCommandType.HSET,
306+
args: [prefix, suffix, field, value],
307+
};
308+
}

new-deepnotes/pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)