Skip to content

Commit 1e55afe

Browse files
committed
frontend/starred file bookmarks: migrate to conat
1 parent 8395e83 commit 1e55afe

File tree

13 files changed

+272
-608
lines changed

13 files changed

+272
-608
lines changed

src/CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ CoCalc is organized as a monorepo with key packages:
7373
- **TypeScript React Components**: All frontend code is TypeScript with proper typing
7474
- **Modular Store System**: Each feature has its own store/actions (AccountStore, BillingStore, etc.)
7575
- **WebSocket Communication**: Real-time communication with backend via WebSocket messages
76+
- **Authentication Waiting**: When frontend code needs to wait for user authentication, use `redux.getStore("account").async_wait({ until: () => store.get_account_id() != null, timeout: 0 })` to wait indefinitely until authentication completes
77+
- **Conat DKV Usage**: For key-value storage with real-time sync, use `webapp_client.conat_client.dkv({ account_id, name: "store-name" })` to get a distributed key-value store that syncs across sessions
7678

7779
#### Backend Architecture
7880

@@ -81,6 +83,8 @@ CoCalc is organized as a monorepo with key packages:
8183
- **Conat System**: Container orchestration for compute servers
8284
- **Event-Driven Architecture**: Extensive use of EventEmitter patterns
8385
- **Microservice-like Packages**: Each package handles specific functionality
86+
- **Database Access**: Use `getPool()` from `@cocalc/database/pool` for direct database queries in hub/backend code. Example: `const pool = getPool(); const { rows } = await pool.query('SELECT * FROM table WHERE id = $1', [id]);`
87+
- **Hub Migration Functions**: Migration functions in hub should be designed to run once at startup, use batch processing with delays between batches to avoid database saturation
8488

8589
#### Communication Patterns
8690

src/packages/frontend/project/page/flyouts/store.ts

Lines changed: 78 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -3,115 +3,104 @@
33
* License: MS-RSL – see LICENSE.md for details
44
*/
55

6-
import { merge, sortBy, throttle, uniq, xor } from "lodash";
6+
/**
7+
// To debug starred files in the browser console:
8+
c = cc.client.conat_client
9+
bm = await c.dkv({account_id: cc.client.account_id, name: 'bookmark-starred-files'})
10+
// Check all bookmark data
11+
console.log('All bookmarks:', bm.getAll())
12+
// Check specific project bookmarks
13+
console.log('Project bookmarks (get):', bm.get("[project_id]"))
14+
// Set starred files for a project
15+
bm.set(project_id, ['file1.txt', 'folder/file2.md'])
16+
// Listen to changes
17+
bm.on('change', (e) => console.log('Bookmark change:', e))
18+
*/
19+
20+
import { sortBy, uniq } from "lodash";
721
import { useState } from "react";
822
import useAsyncEffect from "use-async-effect";
923

10-
import api from "@cocalc/frontend/client/api";
11-
import { STARRED_FILES } from "@cocalc/util/consts/bookmarks";
12-
import {
13-
GetStarredBookmarks,
14-
GetStarredBookmarksPayload,
15-
SetStarredBookmarks,
16-
} from "@cocalc/util/types/bookmarks";
17-
import {
18-
FlyoutActiveStarred,
19-
getFlyoutActiveStarred,
20-
storeFlyoutState,
21-
} from "./state";
24+
import { redux } from "@cocalc/frontend/app-framework";
25+
import { webapp_client } from "@cocalc/frontend/webapp-client";
26+
import { CONAT_BOOKMARKS_KEY } from "@cocalc/util/consts/bookmarks";
27+
import type { FlyoutActiveStarred } from "./state";
2228

23-
// Additionally to local storage, we back the state of the starred files in the database.
24-
// Errors with the API are ignored, because we primarily rely on local storage.
25-
// The only really important situation to think of are when there is nothing in local storage but in the database,
26-
// or when there is
29+
// Starred files are now managed entirely through conat with in-memory state.
30+
// No local storage dependency - conat handles synchronization and persistence.
2731
export function useStarredFilesManager(project_id: string) {
28-
const [starred, setStarred] = useState<FlyoutActiveStarred>(
29-
getFlyoutActiveStarred(project_id),
30-
);
32+
const [starred, setStarred] = useState<FlyoutActiveStarred>([]);
33+
const [bookmarks, setBookmarks] = useState<any>(null);
34+
const [isInitialized, setIsInitialized] = useState(false);
3135

32-
// once after mounting this, we update the starred bookmarks (which merges with what we have) and then stores it
36+
// Initialize conat bookmarks once on mount, waiting for authentication
3337
useAsyncEffect(async () => {
34-
await updateStarred();
38+
// Wait until account is authenticated
39+
const store = redux.getStore("account");
40+
await store.async_wait({
41+
until: () => store.get_account_id() != null,
42+
timeout: 0, // indefinite timeout
43+
});
44+
45+
await initializeConatBookmarks();
3546
}, []);
3647

37-
function setStarredLS(starred: string[]) {
38-
setStarred(starred);
39-
storeFlyoutState(project_id, "active", { starred: starred });
48+
async function initializeConatBookmarks() {
49+
try {
50+
const conatBookmarks = await webapp_client.conat_client.dkv<string[]>({
51+
account_id: webapp_client.client.account_id,
52+
name: CONAT_BOOKMARKS_KEY,
53+
});
54+
55+
setBookmarks(conatBookmarks);
56+
57+
// Listen for changes from other clients
58+
conatBookmarks.on(
59+
"change",
60+
(changeEvent: { key: string; value?: string[]; prev?: string[] }) => {
61+
if (changeEvent.key === project_id) {
62+
const remoteStars = changeEvent.value || [];
63+
setStarred(sortBy(uniq(remoteStars)));
64+
}
65+
},
66+
);
67+
68+
// Load initial data from conat
69+
const initialStars = conatBookmarks.get(project_id) || [];
70+
if (Array.isArray(initialStars)) {
71+
setStarred(sortBy(uniq(initialStars)));
72+
}
73+
74+
setIsInitialized(true);
75+
} catch (err) {
76+
console.warn(`conat bookmark initialization warning -- ${err}`);
77+
setIsInitialized(true); // Set initialized even on error to avoid infinite loading
78+
}
4079
}
4180

42-
// TODO: there are also add/remove API endpoints, but for now we stick with set. Hardly worth optimizing.
4381
function setStarredPath(path: string, starState: boolean) {
82+
if (!bookmarks || !isInitialized) {
83+
console.warn("Conat bookmarks not yet initialized");
84+
return;
85+
}
86+
4487
const next = starState
45-
? [...starred, path]
88+
? sortBy(uniq([...starred, path]))
4689
: starred.filter((p) => p !== path);
47-
setStarredLS(next);
48-
storeStarred(next);
49-
}
5090

51-
async function storeStarred(stars: string[]) {
91+
// Update local state immediately for responsive UI
92+
setStarred(next);
93+
94+
// Store to conat (this will also trigger the change event for other clients)
5295
try {
53-
const payload: SetStarredBookmarks = {
54-
type: STARRED_FILES,
55-
project_id,
56-
stars,
57-
};
58-
await api("bookmarks/set", payload);
96+
bookmarks.set(project_id, next);
5997
} catch (err) {
60-
console.warn(`bookmark: warning -- ${err}`);
98+
console.warn(`conat bookmark storage warning -- ${err}`);
99+
// Revert local state on error
100+
setStarred(starred);
61101
}
62102
}
63103

64-
// this is called once, when the flyout/tabs component is mounted
65-
// throtteled, to usually take 1 sec from opening the panel to loading the stars
66-
const updateStarred = throttle(
67-
async () => {
68-
try {
69-
const payload: GetStarredBookmarksPayload = {
70-
type: STARRED_FILES,
71-
project_id,
72-
};
73-
const data: GetStarredBookmarks = await api("bookmarks/get", payload);
74-
75-
const { type, status } = data;
76-
77-
if (type !== STARRED_FILES) {
78-
console.error(
79-
`flyout/store/starred type must be ${STARRED_FILES} but we got`,
80-
type,
81-
);
82-
return;
83-
}
84-
85-
if (status === "success") {
86-
const { stars } = data;
87-
if (
88-
Array.isArray(stars) &&
89-
stars.every((x) => typeof x === "string")
90-
) {
91-
stars.sort(); // sorted for the xor check below
92-
const next = sortBy(uniq(merge(starred, stars)));
93-
setStarredLS(next);
94-
if (xor(stars, next).length > 0) {
95-
// if there is a change (e.g. nothing in the database stored yet), store the stars
96-
await storeStarred(next);
97-
}
98-
} else {
99-
console.error("flyout/store/starred invalid payload", stars);
100-
}
101-
} else if (status === "error") {
102-
const { error } = data;
103-
console.error("flyout/store/starred error", error);
104-
} else {
105-
console.error("flyout/store/starred error: unknown status", status);
106-
}
107-
} catch (err) {
108-
console.warn(`bookmark: warning -- ${err}`);
109-
}
110-
},
111-
1000,
112-
{ trailing: true, leading: false },
113-
);
114-
115104
return {
116105
starred,
117106
setStarredPath,

src/packages/hub/hub.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { callback } from "awaiting";
1111
import blocked from "blocked";
1212
import { spawn } from "child_process";
1313
import { program as commander, Option } from "commander";
14+
1415
import basePath from "@cocalc/backend/base-path";
1516
import {
1617
pghost as DEFAULT_DB_HOST,
@@ -21,10 +22,18 @@ import { trimLogFileSize } from "@cocalc/backend/logger";
2122
import port from "@cocalc/backend/port";
2223
import { init_start_always_running_projects } from "@cocalc/database/postgres/always-running";
2324
import { load_server_settings_from_env } from "@cocalc/database/settings/server-settings";
25+
import {
26+
initConatApi,
27+
initConatChangefeedServer,
28+
initConatPersist,
29+
loadConatConfiguration,
30+
} from "@cocalc/server/conat";
31+
import { initConatServer } from "@cocalc/server/conat/socketio";
2432
import { init_passport } from "@cocalc/server/hub/auth";
2533
import { initialOnPremSetup } from "@cocalc/server/initial-onprem-setup";
2634
import initHandleMentions from "@cocalc/server/mentions/handle";
2735
import initMessageMaintenance from "@cocalc/server/messages/maintenance";
36+
import { start as startHubRegister } from "@cocalc/server/metrics/hub_register";
2837
import initProjectControl, {
2938
COCALC_MODES,
3039
} from "@cocalc/server/projects/control";
@@ -35,22 +44,15 @@ import initSalesloftMaintenance from "@cocalc/server/salesloft/init";
3544
import { stripe_sync } from "@cocalc/server/stripe/sync";
3645
import { callback2, retry_until_success } from "@cocalc/util/async-utils";
3746
import { set_agent_endpoint } from "./health-checks";
38-
import { start as startHubRegister } from "@cocalc/server/metrics/hub_register";
3947
import { getLogger } from "./logger";
4048
import initDatabase, { database } from "./servers/database";
4149
import initExpressApp from "./servers/express-app";
42-
import {
43-
loadConatConfiguration,
44-
initConatChangefeedServer,
45-
initConatApi,
46-
initConatPersist,
47-
} from "@cocalc/server/conat";
48-
import { initConatServer } from "@cocalc/server/conat/socketio";
4950

5051
import initHttpRedirect from "./servers/http-redirect";
5152

52-
import * as MetricsRecorder from "@cocalc/server/metrics/metrics-recorder";
5353
import { addErrorListeners } from "@cocalc/server/metrics/error-listener";
54+
import * as MetricsRecorder from "@cocalc/server/metrics/metrics-recorder";
55+
import { migrateBookmarksToConat } from "./migrate-bookmarks";
5456

5557
// Logger tagged with 'hub' for this file.
5658
const logger = getLogger("hub");
@@ -291,6 +293,10 @@ async function startServer(): Promise<void> {
291293
// upgrades of projects.
292294
initPurchasesMaintenanceLoop();
293295
initSalesloftMaintenance();
296+
// Migrate bookmarks from database to conat (runs once at startup)
297+
migrateBookmarksToConat().catch((err) => {
298+
logger.error("Failed to migrate bookmarks to conat:", err);
299+
});
294300
setInterval(trimLogFileSize, 1000 * 60 * 3);
295301
}
296302

src/packages/hub/migrate-bookmarks.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/*
2+
* This file is part of CoCalc: Copyright © 2024 Sagemath, Inc.
3+
* License: MS-RSL – see LICENSE.md for details
4+
*/
5+
6+
import { delay } from "awaiting";
7+
8+
import { conat } from "@cocalc/backend/conat/conat";
9+
import { dkv } from "@cocalc/backend/conat/sync";
10+
import {
11+
CONAT_BOOKMARKS_KEY,
12+
STARRED_FILES,
13+
} from "@cocalc/util/consts/bookmarks";
14+
import { getLogger } from "./logger";
15+
import getPool from "@cocalc/database/pool";
16+
17+
const L = getLogger("hub:migrate-bookmarks");
18+
19+
const BATCH_SIZE = 100;
20+
const MIGRATION_DELAY = 500; // ms between batches to avoid database saturation
21+
22+
export async function migrateBookmarksToConat(): Promise<void> {
23+
L.info("Starting migration of bookmarks to conat...");
24+
25+
const pool = getPool();
26+
let totalMigrated = 0;
27+
let batchCount = 0;
28+
29+
while (true) {
30+
try {
31+
// Query for a batch of bookmark entries
32+
const { rows } = await pool.query(
33+
`
34+
SELECT id, account_id, project_id, stars
35+
FROM bookmarks
36+
WHERE type = $1
37+
ORDER BY id
38+
LIMIT $2
39+
`,
40+
[STARRED_FILES, BATCH_SIZE],
41+
);
42+
43+
if (rows.length === 0) {
44+
L.info(
45+
`Migration completed. Total migrated: ${totalMigrated} bookmarks`,
46+
);
47+
break;
48+
}
49+
50+
batchCount++;
51+
L.info(`Processing batch ${batchCount} with ${rows.length} bookmarks...`);
52+
53+
// Process each bookmark in the batch
54+
const processedIds: string[] = [];
55+
56+
for (const row of rows) {
57+
try {
58+
const { id, account_id, project_id, stars } = row;
59+
60+
if (!account_id || !project_id || !Array.isArray(stars)) {
61+
L.warn(
62+
`Skipping invalid bookmark ${id}: account_id=${account_id}, project_id=${project_id}, stars=${stars}`,
63+
);
64+
processedIds.push(id);
65+
continue;
66+
}
67+
68+
// Get or create conat DKV for this account
69+
const bookmarks = await dkv<string[]>({
70+
name: CONAT_BOOKMARKS_KEY,
71+
account_id,
72+
client: conat(),
73+
});
74+
75+
// Set the starred files for this project
76+
bookmarks.set(project_id, stars);
77+
L.debug(
78+
`Migrated bookmark ${id} for account ${account_id}, project ${project_id} with ${stars.length} stars`,
79+
);
80+
81+
processedIds.push(id);
82+
totalMigrated++;
83+
} catch (err) {
84+
L.error(`Failed to migrate bookmark ${row.id}: ${err}`);
85+
// Still add to processedIds so we don't get stuck on this one
86+
processedIds.push(row.id);
87+
}
88+
}
89+
90+
// Delete the processed bookmarks from the database
91+
if (processedIds.length > 0) {
92+
await pool.query(`DELETE FROM bookmarks WHERE id = ANY($1)`, [
93+
processedIds,
94+
]);
95+
L.debug(`Deleted ${processedIds.length} bookmarks from database`);
96+
}
97+
98+
// Wait between batches to avoid database saturation
99+
if (rows.length === BATCH_SIZE) {
100+
L.debug(`Waiting ${MIGRATION_DELAY}ms before next batch...`);
101+
await delay(MIGRATION_DELAY);
102+
}
103+
} catch (err) {
104+
L.error(`Error in migration batch: ${err}`);
105+
// Wait longer on error before retrying
106+
await delay(MIGRATION_DELAY * 3);
107+
}
108+
}
109+
110+
L.info("Bookmark migration to conat completed successfully");
111+
}

0 commit comments

Comments
 (0)