collabmd 0.1.20 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +98 -9
  2. package/bin/collabmd.js +3 -2
  3. package/docker-compose.yml +13 -1
  4. package/package.json +2 -1
  5. package/public/assets/css/style.css +1 -1
  6. package/public/assets/js/chunks/{chunk-HEWVH67U.js → chunk-CYK7KO7W.js} +8 -8
  7. package/public/assets/js/chunks/chunk-GFDM7YCF.js +1 -0
  8. package/public/assets/js/chunks/editor-session-S5AT7BMV.js +22 -0
  9. package/public/assets/js/chunks/{preview-render-compiler-UZ4SZDQQ.js → preview-render-compiler-6Y7TKXFJ.js} +1 -1
  10. package/public/assets/js/chunks/{quick-switcher-controller-J7I3CUET.js → quick-switcher-controller-A6SKH5HI.js} +1 -1
  11. package/public/assets/js/{excalidraw-editor-ZDYKMZOL.js → excalidraw-editor-5YWMGLKF.js} +145 -62
  12. package/public/assets/js/excalidraw-editor.js +1 -1
  13. package/public/assets/js/main.js +503 -182
  14. package/public/assets/js/preview-render-worker.js +15 -15
  15. package/public/index.html +104 -84
  16. package/src/client/application/app-shell/git-feature.js +304 -27
  17. package/src/client/application/app-shell/ui-feature.js +69 -7
  18. package/src/client/application/app-shell/workspace-feature.js +46 -0
  19. package/src/client/application/app-shell-elements.js +2 -0
  20. package/src/client/application/mermaid-preview-hydrator.js +1 -1
  21. package/src/client/application/plantuml-preview-hydrator.js +1 -1
  22. package/src/client/application/workspace-coordinator.js +147 -18
  23. package/src/client/application/workspace-preview-controller.js +34 -0
  24. package/src/client/application/workspace-route-controller.js +104 -0
  25. package/src/client/bootstrap/collabmd-app-shell.js +107 -0
  26. package/src/client/domain/runtime-paths.js +5 -0
  27. package/src/client/domain/vault-utils.js +2 -2
  28. package/src/client/excalidraw-editor.js +52 -3
  29. package/src/client/infrastructure/auth-client.js +219 -52
  30. package/src/client/infrastructure/browser-navigation-port.js +20 -0
  31. package/src/client/infrastructure/editor-collaboration-client.js +4 -0
  32. package/src/client/infrastructure/editor-session.js +51 -7
  33. package/src/client/infrastructure/editor-view-adapter.js +297 -53
  34. package/src/client/infrastructure/runtime-config.js +66 -0
  35. package/src/client/infrastructure/workspace-sync-client.js +324 -0
  36. package/src/client/presentation/comment-ui-controller.js +274 -31
  37. package/src/client/presentation/comments-panel.js +2 -2
  38. package/src/client/presentation/excalidraw-embed-controller.js +60 -2
  39. package/src/client/presentation/file-explorer-controller.js +15 -3
  40. package/src/client/presentation/file-explorer-view.js +152 -11
  41. package/src/client/presentation/file-history-view-controller.js +806 -0
  42. package/src/client/presentation/git-diff-view-controller.js +718 -121
  43. package/src/client/presentation/git-panel-controller.js +458 -66
  44. package/src/client/presentation/layout-controller.js +1 -1
  45. package/src/client/styles/components/actions.css +89 -0
  46. package/src/client/styles/components/feedback.css +43 -0
  47. package/src/client/styles/components/navigation.css +68 -0
  48. package/src/client/styles/components/scrollbars.css +104 -0
  49. package/src/client/styles/components/states.css +289 -0
  50. package/src/client/styles/components/surfaces.css +155 -0
  51. package/src/client/styles/features/backlinks.css +213 -0
  52. package/src/client/styles/features/collaboration-chat.css +228 -0
  53. package/src/client/styles/features/collaboration-dialogs.css +154 -0
  54. package/src/client/styles/features/collaboration-presence.css +103 -0
  55. package/src/client/styles/features/collaboration-ui.css +3 -0
  56. package/src/client/styles/features/comment-card.css +236 -0
  57. package/src/client/styles/features/comment-markdown.css +127 -0
  58. package/src/client/styles/features/comment-overlays.css +60 -0
  59. package/src/client/styles/features/comments-drawer.css +120 -0
  60. package/src/client/styles/features/comments.css +4 -0
  61. package/src/client/styles/features/diagram-preview.css +227 -0
  62. package/src/client/styles/features/diff-commit.css +268 -0
  63. package/src/client/styles/features/diff-lines.css +160 -0
  64. package/src/client/styles/features/diff-toolbar.css +98 -0
  65. package/src/client/styles/features/diff.css +3 -0
  66. package/src/client/styles/features/embedded-preview.css +218 -0
  67. package/src/client/styles/features/git-branch.css +155 -0
  68. package/src/client/styles/features/git-files.css +105 -0
  69. package/src/client/styles/features/git-history.css +339 -0
  70. package/src/client/styles/features/git.css +3 -0
  71. package/src/client/styles/features/media-lightbox.css +157 -0
  72. package/src/client/styles/features/outline.css +115 -0
  73. package/src/client/styles/features/preview-content.css +3 -0
  74. package/src/client/styles/features/preview-markdown.css +201 -0
  75. package/src/client/styles/features/preview-sidebars.css +2 -0
  76. package/src/client/styles/features/quick-switcher.css +167 -0
  77. package/src/client/styles/foundation/themes.css +347 -0
  78. package/src/client/styles/foundation/tokens.css +66 -0
  79. package/src/client/styles/foundation/utilities.css +36 -0
  80. package/src/client/styles/layout/editor-page.css +203 -0
  81. package/src/client/styles/layout/overlays.css +132 -0
  82. package/src/client/styles/layout/responsive.css +251 -0
  83. package/src/client/styles/layout/shell.css +314 -0
  84. package/src/client/styles/layout/sidebar.css +176 -0
  85. package/src/client/styles/layout/view-modes.css +92 -0
  86. package/src/client/styles/overrides/codemirror.css +53 -0
  87. package/src/client/styles/overrides/highlightjs.css +7 -0
  88. package/src/client/styles/overrides/vendor.css +2 -0
  89. package/src/client/styles/primitives/controls.css +276 -0
  90. package/src/client/styles/style.css +25 -4901
  91. package/src/domain/wiki-link-resolver.js +16 -5
  92. package/src/domain/workspace-change.js +68 -0
  93. package/src/domain/workspace-room.js +3 -0
  94. package/src/server/auth/create-auth-service.js +373 -15
  95. package/src/server/auth/session-cookie.js +30 -4
  96. package/src/server/config/env.js +121 -5
  97. package/src/server/create-app-server.js +49 -10
  98. package/src/server/domain/backlink-index.js +94 -1
  99. package/src/server/domain/collaboration/collaboration-room.js +281 -69
  100. package/src/server/domain/collaboration/room-registry.js +64 -10
  101. package/src/server/index.js +3 -1
  102. package/src/server/infrastructure/git/command-runner.js +2 -1
  103. package/src/server/infrastructure/git/git-service.js +48 -2
  104. package/src/server/infrastructure/git/history-service.js +683 -0
  105. package/src/server/infrastructure/git/parsers.js +30 -0
  106. package/src/server/infrastructure/git/responses.js +85 -19
  107. package/src/server/infrastructure/http/create-auth-api-handler.js +30 -2
  108. package/src/server/infrastructure/http/create-git-api-command-handler.js +55 -43
  109. package/src/server/infrastructure/http/create-git-api-handler.js +4 -0
  110. package/src/server/infrastructure/http/create-git-api-query-handler.js +61 -0
  111. package/src/server/infrastructure/http/create-request-handler.js +22 -0
  112. package/src/server/infrastructure/http/create-vault-api-command-handler.js +58 -14
  113. package/src/server/infrastructure/http/create-vault-api-handler.js +3 -0
  114. package/src/server/infrastructure/http/create-vault-api-query-handler.js +3 -1
  115. package/src/server/infrastructure/http/http-response.js +65 -28
  116. package/src/server/infrastructure/persistence/vault-file-store.js +163 -52
  117. package/src/server/infrastructure/workspace/file-system-sync-service.js +488 -0
  118. package/src/server/infrastructure/workspace/workspace-mutation-coordinator.js +551 -0
  119. package/public/assets/js/chunks/chunk-R3DDMJHH.js +0 -1
  120. package/public/assets/js/chunks/editor-session-TAV2VXTA.js +0 -22
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'url';
3
3
 
4
4
  import {
5
5
  AUTH_STRATEGY_NONE,
6
+ AUTH_STRATEGY_OIDC,
6
7
  AUTH_STRATEGY_PASSWORD,
7
8
  SUPPORTED_AUTH_STRATEGIES,
8
9
  createRandomAuthPassword,
@@ -54,6 +55,30 @@ function normalizeWsBasePath(basePath) {
54
55
  return normalizeRoutePath(basePath, '/ws');
55
56
  }
56
57
 
58
+ function normalizePublicBaseUrl(rawValue) {
59
+ const normalized = normalizeOptionalString(rawValue);
60
+ if (!normalized) {
61
+ return '';
62
+ }
63
+
64
+ let parsedUrl;
65
+ try {
66
+ parsedUrl = new URL(normalized);
67
+ } catch {
68
+ throw new Error('PUBLIC_BASE_URL must be an absolute URL.');
69
+ }
70
+
71
+ if (parsedUrl.protocol !== 'https:' && parsedUrl.hostname !== 'localhost' && parsedUrl.hostname !== '127.0.0.1') {
72
+ throw new Error('PUBLIC_BASE_URL must use https unless it points to localhost.');
73
+ }
74
+
75
+ if ((parsedUrl.pathname && parsedUrl.pathname !== '/') || parsedUrl.search || parsedUrl.hash) {
76
+ throw new Error('PUBLIC_BASE_URL must not include a path, query string, or hash.');
77
+ }
78
+
79
+ return parsedUrl.origin;
80
+ }
81
+
57
82
  function normalizeAuthStrategy(rawStrategy) {
58
83
  const normalized = String(rawStrategy ?? AUTH_STRATEGY_NONE).trim().toLowerCase();
59
84
  if (!SUPPORTED_AUTH_STRATEGIES.has(normalized)) {
@@ -65,8 +90,42 @@ function normalizeAuthStrategy(rawStrategy) {
65
90
  return normalized;
66
91
  }
67
92
 
93
+ function normalizeCsvList(value) {
94
+ if (Array.isArray(value)) {
95
+ return value
96
+ .map((entry) => String(entry ?? '').trim())
97
+ .filter(Boolean);
98
+ }
99
+
100
+ return String(value ?? '')
101
+ .split(',')
102
+ .map((entry) => entry.trim())
103
+ .filter(Boolean);
104
+ }
105
+
106
+ function normalizeEmailAllowlist(value) {
107
+ return normalizeCsvList(value)
108
+ .map((entry) => entry.toLowerCase());
109
+ }
110
+
111
+ function normalizeDomainAllowlist(value) {
112
+ return normalizeCsvList(value)
113
+ .map((entry) => entry.replace(/^@+/, '').toLowerCase())
114
+ .filter(Boolean);
115
+ }
116
+
68
117
  const projectRoot = resolve(fileURLToPath(new URL('../../../', import.meta.url)));
69
118
 
119
+ export function resolveConfiguredVaultDir(overrides = {}, env = process.env) {
120
+ return overrides.vaultDir
121
+ || env.COLLABMD_VAULT_DIR
122
+ || resolve(projectRoot, 'data/vault');
123
+ }
124
+
125
+ export function resolveCliVaultDir(positionals = [], env = process.env) {
126
+ return resolve(positionals[0] || env.COLLABMD_VAULT_DIR || '.');
127
+ }
128
+
70
129
  function getDefaultHost(nodeEnv) {
71
130
  return nodeEnv === 'production' ? '0.0.0.0' : '127.0.0.1';
72
131
  }
@@ -134,11 +193,63 @@ function loadGitConfig(overrides = {}) {
134
193
  };
135
194
  }
136
195
 
196
+ function loadOidcConfig(overrides = {}, { basePath = '' } = {}) {
197
+ const clientId = normalizeOptionalString(
198
+ overrides.clientId
199
+ ?? process.env.AUTH_OIDC_CLIENT_ID,
200
+ );
201
+ const clientSecret = normalizeOptionalString(
202
+ overrides.clientSecret
203
+ ?? process.env.AUTH_OIDC_CLIENT_SECRET,
204
+ );
205
+ const publicBaseUrl = normalizePublicBaseUrl(
206
+ overrides.publicBaseUrl
207
+ ?? process.env.PUBLIC_BASE_URL,
208
+ );
209
+ const issuer = normalizeOptionalString(
210
+ overrides.issuer
211
+ ?? process.env.AUTH_OIDC_ISSUER_URL,
212
+ ) || 'https://accounts.google.com';
213
+ const flowCookieName = normalizeOptionalString(
214
+ overrides.flowCookieName
215
+ ?? process.env.AUTH_OIDC_FLOW_COOKIE_NAME,
216
+ ) || 'collabmd_auth_flow';
217
+ const allowedEmails = normalizeEmailAllowlist(
218
+ overrides.allowedEmails
219
+ ?? process.env.AUTH_OIDC_ALLOWED_EMAILS,
220
+ );
221
+ const allowedDomains = normalizeDomainAllowlist(
222
+ overrides.allowedDomains
223
+ ?? process.env.AUTH_OIDC_ALLOWED_DOMAINS,
224
+ );
225
+
226
+ if (!publicBaseUrl) {
227
+ throw new Error('OIDC auth requires PUBLIC_BASE_URL.');
228
+ }
229
+ if (!clientId) {
230
+ throw new Error('OIDC auth requires AUTH_OIDC_CLIENT_ID.');
231
+ }
232
+ if (!clientSecret) {
233
+ throw new Error('OIDC auth requires AUTH_OIDC_CLIENT_SECRET.');
234
+ }
235
+
236
+ return {
237
+ allowedDomains,
238
+ allowedEmails,
239
+ callbackUrl: `${publicBaseUrl}${basePath}/api/auth/oidc/callback`,
240
+ clientId,
241
+ clientSecret,
242
+ flowCookieName,
243
+ issuer,
244
+ provider: 'google',
245
+ publicBaseUrl,
246
+ };
247
+ }
248
+
137
249
  export function loadConfig(overrides = {}) {
138
250
  const nodeEnv = process.env.NODE_ENV || 'development';
139
- const vaultDir = overrides.vaultDir
140
- || process.env.COLLABMD_VAULT_DIR
141
- || resolve(projectRoot, 'data/vault');
251
+ const vaultDir = resolveConfiguredVaultDir(overrides);
252
+ const basePath = normalizeAppBasePath(process.env.BASE_PATH || '');
142
253
  const authOverrides = overrides.auth ?? {};
143
254
  const authStrategy = normalizeAuthStrategy(
144
255
  authOverrides.strategy
@@ -150,18 +261,22 @@ export function loadConfig(overrides = {}) {
150
261
  const password = authStrategy === AUTH_STRATEGY_PASSWORD
151
262
  ? (authOverrides.password || process.env.AUTH_PASSWORD || createRandomAuthPassword())
152
263
  : '';
264
+ const oidc = authStrategy === AUTH_STRATEGY_OIDC
265
+ ? loadOidcConfig(authOverrides.oidc, { basePath })
266
+ : null;
153
267
  const git = loadGitConfig(overrides.git);
154
268
 
155
269
  return {
156
270
  auth: {
157
271
  generatedPassword: passwordWasGenerated ? password : '',
272
+ oidc,
158
273
  password,
159
274
  passwordWasGenerated,
160
275
  sessionCookieName: authOverrides.sessionCookieName || process.env.AUTH_SESSION_COOKIE_NAME || 'collabmd_auth',
161
276
  sessionSecret: authOverrides.sessionSecret || process.env.AUTH_SESSION_SECRET || createRandomSessionSecret(),
162
277
  strategy: authStrategy,
163
278
  },
164
- basePath: normalizeAppBasePath(process.env.BASE_PATH || ''),
279
+ basePath,
165
280
  host: process.env.HOST || getDefaultHost(nodeEnv),
166
281
  httpHeadersTimeoutMs: parsePositiveInt(process.env.HTTP_HEADERS_TIMEOUT_MS, 60_000),
167
282
  httpKeepAliveTimeoutMs: parsePositiveInt(process.env.HTTP_KEEP_ALIVE_TIMEOUT_MS, 5_000),
@@ -174,8 +289,9 @@ export function loadConfig(overrides = {}) {
174
289
  publicDir: resolve(projectRoot, 'public'),
175
290
  vaultDir,
176
291
  publicWsBaseUrl: process.env.PUBLIC_WS_BASE_URL || '',
292
+ testWsRoomHydrateDelayMs: parsePositiveInt(process.env.TEST_WS_ROOM_HYDRATE_DELAY_MS, 0),
177
293
  wsHeartbeatIntervalMs: parsePositiveInt(process.env.WS_HEARTBEAT_INTERVAL_MS, 30_000),
178
- wsRoomIdleGraceMs: parsePositiveInt(process.env.WS_ROOM_IDLE_GRACE_MS, 15_000),
294
+ wsRoomIdleGraceMs: parsePositiveInt(process.env.WS_ROOM_IDLE_GRACE_MS, 60_000),
179
295
  wsBasePath: normalizeWsBasePath(process.env.WS_BASE_PATH || '/ws'),
180
296
  wsMaxBufferedAmountBytes: parsePositiveInt(
181
297
  process.env.WS_MAX_BUFFERED_AMOUNT_BYTES,
@@ -11,6 +11,9 @@ import { RoomRegistry } from './domain/collaboration/room-registry.js';
11
11
  import { createRequestHandler } from './infrastructure/http/create-request-handler.js';
12
12
  import { VaultFileStore } from './infrastructure/persistence/vault-file-store.js';
13
13
  import { attachCollaborationGateway } from './infrastructure/websocket/attach-collaboration-gateway.js';
14
+ import { WORKSPACE_ROOM_NAME } from '../domain/workspace-room.js';
15
+ import { FileSystemSyncService } from './infrastructure/workspace/file-system-sync-service.js';
16
+ import { WorkspaceMutationCoordinator } from './infrastructure/workspace/workspace-mutation-coordinator.js';
14
17
 
15
18
  function getDisplayHost(host) {
16
19
  return host === '127.0.0.1' ? 'localhost' : host;
@@ -45,18 +48,43 @@ export function createAppServer(config = loadConfig()) {
45
48
  enabled: config.gitEnabled,
46
49
  vaultDir: config.vaultDir,
47
50
  });
51
+ const testControls = {
52
+ wsRoomHydrateDelayMs: Math.max(0, Number(config.testWsRoomHydrateDelayMs || 0)),
53
+ };
54
+ let workspaceMutationCoordinator = null;
48
55
  const roomRegistry = new RoomRegistry({
49
- createRoom: ({ name, onEmpty }) => new CollaborationRoom({
50
- documentStore: new CollaborationDocumentStore({
51
- backlinkIndex: name === '__lobby__' ? null : backlinkIndex,
56
+ createRoom: ({ name, onEmpty }) => {
57
+ const room = new CollaborationRoom({
58
+ documentStore: new CollaborationDocumentStore({
59
+ backlinkIndex: name === '__lobby__' || name === WORKSPACE_ROOM_NAME ? null : backlinkIndex,
60
+ name,
61
+ vaultFileStore: name === '__lobby__' || name === WORKSPACE_ROOM_NAME ? null : vaultFileStore,
62
+ }),
63
+ getHydrateDelayMs: () => testControls.wsRoomHydrateDelayMs,
64
+ idleGraceMs: config.wsRoomIdleGraceMs,
65
+ maxBufferedAmountBytes: config.wsMaxBufferedAmountBytes,
52
66
  name,
53
- vaultFileStore: name === '__lobby__' ? null : vaultFileStore,
54
- }),
55
- idleGraceMs: config.wsRoomIdleGraceMs,
56
- maxBufferedAmountBytes: config.wsMaxBufferedAmountBytes,
57
- name,
58
- onEmpty,
59
- }),
67
+ onEmpty,
68
+ });
69
+
70
+ if (name === WORKSPACE_ROOM_NAME && workspaceMutationCoordinator?.workspaceState) {
71
+ room.replaceWorkspaceEntries(workspaceMutationCoordinator.workspaceState.entries, {
72
+ generatedAt: workspaceMutationCoordinator.workspaceState.scannedAt,
73
+ });
74
+ }
75
+
76
+ return room;
77
+ },
78
+ });
79
+ workspaceMutationCoordinator = new WorkspaceMutationCoordinator({
80
+ backlinkIndex,
81
+ roomRegistry,
82
+ vaultFileStore,
83
+ });
84
+ vaultFileStore.setManagedWriteTracker(workspaceMutationCoordinator);
85
+ const fileSystemSyncService = new FileSystemSyncService({
86
+ mutationCoordinator: workspaceMutationCoordinator,
87
+ vaultFileStore,
60
88
  });
61
89
  const requestHandler = createRequestHandler(
62
90
  config,
@@ -66,6 +94,9 @@ export function createAppServer(config = loadConfig()) {
66
94
  roomRegistry,
67
95
  plantUmlRenderer,
68
96
  gitService,
97
+ testControls,
98
+ workspaceMutationCoordinator,
99
+ fileSystemSyncService,
69
100
  );
70
101
  const httpServer = createServer((req, res) => {
71
102
  requestHandler(req, res).catch((error) => {
@@ -95,6 +126,8 @@ export function createAppServer(config = loadConfig()) {
95
126
  async function listen() {
96
127
  vaultFileCount = await vaultFileStore.countVaultFiles();
97
128
  await backlinkIndex.build();
129
+ await workspaceMutationCoordinator.initialize();
130
+ await fileSystemSyncService.start();
98
131
 
99
132
  return new Promise((resolve, reject) => {
100
133
  httpServer.once('error', reject);
@@ -118,6 +151,7 @@ export function createAppServer(config = loadConfig()) {
118
151
 
119
152
  shutdownPromise = (async () => {
120
153
  await collaborationGateway.close();
154
+ await fileSystemSyncService.close();
121
155
  await roomRegistry.reset();
122
156
  await Promise.all([
123
157
  closeHttpServer(httpServer),
@@ -135,8 +169,13 @@ export function createAppServer(config = loadConfig()) {
135
169
  httpServer,
136
170
  listen,
137
171
  roomRegistry,
172
+ workspaceMutationCoordinator,
138
173
  authService,
174
+ fileSystemSyncService,
139
175
  gitService,
176
+ setTestHydrateDelayMs(delayMs = 0) {
177
+ testControls.wsRoomHydrateDelayMs = Math.max(0, Number(delayMs) || 0);
178
+ },
140
179
  vaultFileStore,
141
180
  get vaultFileCount() { return vaultFileCount; },
142
181
  };
@@ -14,9 +14,27 @@ import { createWikiTargetIndex, resolveWikiTargetWithIndex } from '../../domain/
14
14
 
15
15
  const WIKI_LINK_RE = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
16
16
 
17
+ function createDeferred() {
18
+ let resolve;
19
+ let reject;
20
+ const promise = new Promise((nextResolve, nextReject) => {
21
+ resolve = nextResolve;
22
+ reject = nextReject;
23
+ });
24
+ return { promise, reject, resolve };
25
+ }
26
+
17
27
  export class BacklinkIndex {
18
- constructor({ vaultFileStore }) {
28
+ constructor({
29
+ rebuildDelayMs = 150,
30
+ setTimeoutFn = setTimeout,
31
+ clearTimeoutFn = clearTimeout,
32
+ vaultFileStore,
33
+ }) {
19
34
  this.vaultFileStore = vaultFileStore;
35
+ this.rebuildDelayMs = rebuildDelayMs;
36
+ this.setTimeoutFn = setTimeoutFn;
37
+ this.clearTimeoutFn = clearTimeoutFn;
20
38
  /** @type {Map<string, Set<string>>} sourcePath → set of resolved target paths */
21
39
  this.forward = new Map();
22
40
  /** @type {Map<string, Set<string>>} targetPath → set of source paths */
@@ -29,6 +47,11 @@ export class BacklinkIndex {
29
47
  this._fileSet = new Set();
30
48
  this._wikiTargetIndex = createWikiTargetIndex(this._fileList);
31
49
  this._built = false;
50
+ this._requestedBuildVersion = 0;
51
+ this._completedBuildVersion = 0;
52
+ this._buildPromise = null;
53
+ this._scheduledBuildTimer = null;
54
+ this._scheduledBuildDeferred = null;
32
55
  }
33
56
 
34
57
  /**
@@ -36,6 +59,74 @@ export class BacklinkIndex {
36
59
  * Called once at server startup.
37
60
  */
38
61
  async build() {
62
+ this._requestedBuildVersion += 1;
63
+ return this.flushScheduledBuild();
64
+ }
65
+
66
+ scheduleBuild({ delayMs = this.rebuildDelayMs } = {}) {
67
+ this._requestedBuildVersion += 1;
68
+ if (!this._scheduledBuildDeferred) {
69
+ this._scheduledBuildDeferred = createDeferred();
70
+ }
71
+
72
+ if (this._scheduledBuildTimer) {
73
+ this.clearTimeoutFn(this._scheduledBuildTimer);
74
+ }
75
+
76
+ this._scheduledBuildTimer = this.setTimeoutFn(() => {
77
+ this._scheduledBuildTimer = null;
78
+ const deferred = this._scheduledBuildDeferred;
79
+ this._scheduledBuildDeferred = null;
80
+ this._ensureBuiltToRequestedVersion()
81
+ .then(() => deferred?.resolve())
82
+ .catch((error) => deferred?.reject(error));
83
+ }, delayMs);
84
+ this._scheduledBuildTimer.unref?.();
85
+
86
+ return this._scheduledBuildDeferred.promise;
87
+ }
88
+
89
+ async flushScheduledBuild() {
90
+ if (this._scheduledBuildTimer) {
91
+ this.clearTimeoutFn(this._scheduledBuildTimer);
92
+ this._scheduledBuildTimer = null;
93
+ }
94
+
95
+ const deferred = this._scheduledBuildDeferred;
96
+ this._scheduledBuildDeferred = null;
97
+
98
+ try {
99
+ await this._ensureBuiltToRequestedVersion();
100
+ deferred?.resolve();
101
+ } catch (error) {
102
+ deferred?.reject(error);
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ async _ensureBuiltToRequestedVersion() {
108
+ while (this._completedBuildVersion < this._requestedBuildVersion) {
109
+ await this._runSingleBuild();
110
+ }
111
+ }
112
+
113
+ async _runSingleBuild() {
114
+ if (this._buildPromise) {
115
+ return this._buildPromise;
116
+ }
117
+
118
+ const targetVersion = this._requestedBuildVersion;
119
+ this._buildPromise = (async () => {
120
+ await this._performBuild();
121
+ this._completedBuildVersion = Math.max(this._completedBuildVersion, targetVersion);
122
+ })().finally(() => {
123
+ this._buildPromise = null;
124
+ });
125
+
126
+ return this._buildPromise;
127
+ }
128
+
129
+ async _performBuild() {
39
130
  this.forward.clear();
40
131
  this.reverse.clear();
41
132
  this.contextsBySource.clear();
@@ -169,6 +260,8 @@ export class BacklinkIndex {
169
260
  * Returns: [{ file: string, contexts: string[] }]
170
261
  */
171
262
  async getBacklinks(filePath) {
263
+ await this.flushScheduledBuild();
264
+
172
265
  const sources = this.reverse.get(filePath);
173
266
  if (!sources || sources.size === 0) {
174
267
  return [];