collabmd 0.1.29 → 0.1.30

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 (166) hide show
  1. package/README.md +5 -0
  2. package/dist/client/assets/{CodeMirrorEditor-UCMRAMCV-9k1Shd4H.js → CodeMirrorEditor-UCMRAMCV-tj0_ycIx.js} +1 -1
  3. package/dist/client/assets/architectureDiagram-2XIMDMQ5-BPG5b65m.js +36 -0
  4. package/dist/client/assets/base-CaIEJUF9.css +1 -0
  5. package/dist/client/assets/{blockDiagram-WCTKOSBZ-0rR9ivwb.js → blockDiagram-WCTKOSBZ-F38TRzI3.js} +1 -1
  6. package/dist/client/assets/{chunk-ETSVUAFD-CN-gPl_j.js → chunk-AXZTUZBK-Cxfm7hmm.js} +1 -1
  7. package/dist/client/assets/{chunk-GLR3WWYH-ZnuaBvXp.js → chunk-GLR3WWYH-CQ1LryrI.js} +2 -2
  8. package/dist/client/assets/{chunk-NQ4KR5QH-BCjGNd0g.js → chunk-NQ4KR5QH-BBX89uUa.js} +1 -1
  9. package/dist/client/assets/chunk-NU2UP6EN-CGeKI3BV.js +35 -0
  10. package/dist/client/assets/{chunk-WL4C6EOR-OuH0lN6M.js → chunk-WL4C6EOR-zSV-8h7F.js} +1 -1
  11. package/dist/client/assets/classDiagram-VBA2DB6C-BQqG4q1b.js +1 -0
  12. package/dist/client/assets/classDiagram-v2-RAHNMMFH-BUKbprX1.js +1 -0
  13. package/dist/client/assets/dagre-KLK3FWXG-CAG8WeES.js +4 -0
  14. package/dist/client/assets/diagram-E7M64L7V-Bm5FD0I5.js +24 -0
  15. package/dist/client/assets/diagram-IFDJBPK2-pqicH-av.js +43 -0
  16. package/dist/client/assets/diagram-P4PSJMXO-DwIXO27H.js +24 -0
  17. package/dist/client/assets/dist-3bQB_we3.js +1 -0
  18. package/dist/client/assets/dist-3uAMa5w5.js +1 -0
  19. package/dist/client/assets/{dist-D9c0A4Uh.js → dist-B-02vKOA.js} +1 -1
  20. package/dist/client/assets/{dist-A0eSMIyy.js → dist-BLAlZer_.js} +1 -1
  21. package/dist/client/assets/dist-BVB8rn7o.js +1 -0
  22. package/dist/client/assets/{dist-DveOCUUJ.js → dist-Bc2ND6i3.js} +1 -1
  23. package/dist/client/assets/{dist-BNcU46Ik.js → dist-BqQjnEWr.js} +1 -1
  24. package/dist/client/assets/{dist-Bay3vk5z.js → dist-BtnXjRx0.js} +1 -1
  25. package/dist/client/assets/{dist-CEYpWxod.js → dist-BzbYwa8m.js} +1 -1
  26. package/dist/client/assets/{dist-Ct1bmQnL.js → dist-BzpfHRaE.js} +1 -1
  27. package/dist/client/assets/{dist-BbKpzsTB.js → dist-CGV1S_as.js} +1 -1
  28. package/dist/client/assets/dist-CL-jjdvG.js +1 -0
  29. package/dist/client/assets/{dist-D0e6Qsad.js → dist-CXpKYW9s.js} +1 -1
  30. package/dist/client/assets/{dist-BchrxlnW.js → dist-CjII8VDS.js} +1 -1
  31. package/dist/client/assets/{dist-DnrO-JZd.js → dist-CxfohGIV.js} +1 -1
  32. package/dist/client/assets/{dist-CMHIjMVe.js → dist-D5QOYy7y.js} +1 -1
  33. package/dist/client/assets/{dist-CEtbxXlI.js → dist-DIi0uqLJ.js} +1 -1
  34. package/dist/client/assets/dist-DJ16N7ol.js +1 -0
  35. package/dist/client/assets/dist-DUvaQ-Bj.js +1 -0
  36. package/dist/client/assets/{dist-DnxPQ0-w.js → dist-DWJL3_bF.js} +6 -6
  37. package/dist/client/assets/dist-DsL7sqZR.js +1 -0
  38. package/dist/client/assets/{dist-DbhQ5ANS.js → dist-Dyt7n5Ui.js} +1 -1
  39. package/dist/client/assets/{dist-BOrlJDrR.js → dist-E_jPb93e.js} +1 -1
  40. package/dist/client/assets/{dist-D-gRgZ6e.js → dist-O1z0Ayg0.js} +1 -1
  41. package/dist/client/assets/dist-OQE68wIX.js +1 -0
  42. package/dist/client/assets/dist-k47-Io7D.js +1 -0
  43. package/dist/client/assets/{dist-Cdzvi8cf.js → dist-lDhnP-K1.js} +1 -1
  44. package/dist/client/assets/{dist-DS6mEQYy.js → dist-rMhng5DK.js} +1 -1
  45. package/dist/client/assets/dist-xO1ImhKz.js +1 -0
  46. package/dist/client/assets/{drawio-editor-CgbaOvpF.js → drawio-editor-CTZtMo1J.js} +1 -1
  47. package/dist/client/assets/{drawioEditor-Eb2yYhjE.js → drawioEditor-keSJVTcv.js} +2 -2
  48. package/dist/client/assets/editor-session-C9l9XwVg.js +16 -0
  49. package/dist/client/assets/{embedded-editor-base-DXRHYz6R.css → embedded-editor-base-DgmRcsR8.css} +1 -1
  50. package/dist/client/assets/{en-CTDJDZ6K-BHbXrqU0.js → en-IQZW2TQM-CZeIyvu2.js} +1 -1
  51. package/dist/client/assets/{erDiagram-INFDFZHY-DP3f20BF.js → erDiagram-INFDFZHY-DlhPv4az.js} +1 -1
  52. package/dist/client/assets/{excalidraw-editor-Dj0c0PO6.js → excalidraw-editor-CBEWfuhb.js} +2 -2
  53. package/dist/client/assets/excalidraw-editor-Dj3i59LW.css +1 -0
  54. package/dist/client/assets/excalidraw-scene-BL-OtvCd.js +176 -0
  55. package/dist/client/assets/excalidrawEditor-Ws-fwGOc.js +2 -0
  56. package/dist/client/assets/{export-page-CUOMczdp.js → export-page-B6xYnwy7.js} +2 -2
  57. package/dist/client/assets/exportDocument-8JBw0p8O.js +2 -0
  58. package/dist/client/assets/{exportDocument-CLPYN_gg.css → exportDocument-CtX_4PSk.css} +1 -1
  59. package/dist/client/assets/{flowDiagram-PKNHOUZH-CRbK5_5h.js → flowDiagram-PKNHOUZH-BU4MOSq8.js} +1 -1
  60. package/dist/client/assets/ganttDiagram-A5KZAMGK-CbX67lZu.js +292 -0
  61. package/dist/client/assets/gitGraphDiagram-K3NZZRJ6-DhyvWcW1.js +65 -0
  62. package/dist/client/assets/image-XLNPCCAD-B4wZgh-Y.js +1 -0
  63. package/dist/client/assets/{index-CVgVzuh6.js → index-CywRKxsk.js} +2 -2
  64. package/dist/client/assets/index-R4OArFku.css +1 -0
  65. package/dist/client/assets/infoDiagram-LFFYTUFH-Bap6MyHX.js +2 -0
  66. package/dist/client/assets/{ishikawaDiagram-PHBUUO56-BDVPgkv4.js → ishikawaDiagram-PHBUUO56-DbftskqN.js} +1 -1
  67. package/dist/client/assets/{journeyDiagram-4ABVD52K-DtNnwL48.js → journeyDiagram-4ABVD52K-165YoX6B.js} +1 -1
  68. package/dist/client/assets/{jsx-runtime-B8dyIONw.js → jsx-runtime-DiaMmmG6.js} +1 -1
  69. package/dist/client/assets/{kanban-definition-K7BYSVSG-C565sUNo.js → kanban-definition-K7BYSVSG-MlixoNyx.js} +1 -1
  70. package/dist/client/assets/{main-C8adLQnt.js → main-CB97rY6B.js} +172 -133
  71. package/dist/client/assets/mermaid-parser.core-lf87rdG-.js +4 -0
  72. package/dist/client/assets/{mermaid-runtime-DqncQoYK.js → mermaid-runtime-C4a7TrTS.js} +3 -3
  73. package/dist/client/assets/{mindmap-definition-YRQLILUH-BbYzGfTD.js → mindmap-definition-YRQLILUH-_YbQSc4Q.js} +1 -1
  74. package/dist/client/assets/pieDiagram-SKSYHLDU-CqCgXFom.js +30 -0
  75. package/dist/client/assets/preview-render-compiler-TmHpBDlh.js +1 -0
  76. package/dist/client/assets/preview-render-compiler-aYpnXmNm.js +35 -0
  77. package/dist/client/assets/preview-render-worker-BVbVXXPS.js +59 -0
  78. package/dist/client/assets/{quadrantDiagram-337W2JSQ-DhlKLozd.js → quadrantDiagram-337W2JSQ-B8eMuhg4.js} +1 -1
  79. package/dist/client/assets/{requirementDiagram-Z7DCOOCP-B7ZOMsIb.js → requirementDiagram-Z7DCOOCP-B6POvqz4.js} +1 -1
  80. package/dist/client/assets/{room-B0UGbzQj.js → room-COWSP--i.js} +1 -1
  81. package/dist/client/assets/stateDiagram-RAJIS63D-DbewhqwJ.js +1 -0
  82. package/dist/client/assets/stateDiagram-v2-FVOUBMTO-DbW-jVkS.js +1 -0
  83. package/dist/client/assets/{vennDiagram-LZ73GAT5-Cs9aQmVM.js → vennDiagram-LZ73GAT5-C1hrSbf2.js} +1 -1
  84. package/dist/client/assets/{xychartDiagram-JWTSCODW-B40ybbzC.js → xychartDiagram-JWTSCODW-DhpWp8LV.js} +1 -1
  85. package/dist/client/assets/{yjs-provider-reset-guard-DUP9XupO.js → yjs-provider-reset-guard-DWGhQClh.js} +1 -1
  86. package/dist/client/drawio-editor.html +1 -1
  87. package/dist/client/excalidraw-editor.html +1 -1
  88. package/dist/client/export-document.html +4 -4
  89. package/dist/client/index.html +4 -4
  90. package/docker-compose.yml +1 -0
  91. package/package.json +23 -21
  92. package/src/client/application/app-shell/ui-feature-shell.js +47 -1
  93. package/src/client/application/app-shell/ui-feature-toolbar.js +25 -3
  94. package/src/client/application/app-shell-elements.js +2 -0
  95. package/src/client/application/diagram-preview-hydrator.js +1 -1
  96. package/src/client/application/markdown-frontmatter.js +152 -0
  97. package/src/client/application/preview-render-compiler.js +10 -1
  98. package/src/client/application/preview-render-executor.js +8 -1
  99. package/src/client/application/preview-render-worker.js +4 -0
  100. package/src/client/application/preview-renderer.js +51 -1
  101. package/src/client/application/workspace-preview-controller.js +1 -0
  102. package/src/client/bootstrap/collabmd-app-shell.js +5 -0
  103. package/src/client/domain/markdown-formatting.js +1 -1
  104. package/src/client/domain/markdown-toolbar-actions.js +36 -0
  105. package/src/client/export/export-pipeline.js +3 -1
  106. package/src/client/infrastructure/editor-session.js +4 -0
  107. package/src/client/infrastructure/editor-view-adapter.js +36 -2
  108. package/src/client/infrastructure/excalidraw-room-client.js +12 -4
  109. package/src/client/presentation/app-shell-template.js +15 -0
  110. package/src/client/presentation/file-action-controller.js +1 -2
  111. package/src/client/presentation/git-diff-view-controller.js +7 -10
  112. package/src/client/styles/base.css +5 -0
  113. package/src/client/styles/features/preview-markdown.css +138 -0
  114. package/src/client/styles/layout/editor-page.css +6 -0
  115. package/src/client/styles/layout/responsive.css +89 -0
  116. package/src/client/styles/layout/shell.css +6 -0
  117. package/src/client/styles/overrides/codemirror.css +74 -0
  118. package/src/server/auth/create-auth-service.js +32 -6
  119. package/src/server/config/env.js +27 -0
  120. package/src/server/config/perf-logging.js +39 -0
  121. package/src/server/create-app-server.js +102 -5
  122. package/src/server/domain/backlink-index.js +31 -7
  123. package/src/server/domain/collaboration/collaboration-room.js +28 -5
  124. package/src/server/infrastructure/git/history-service.js +1 -1
  125. package/src/server/infrastructure/persistence/pull-backup-store.js +1 -1
  126. package/src/server/infrastructure/persistence/vault-file-store.js +74 -31
  127. package/src/server/infrastructure/workspace/file-system-sync-service.js +46 -12
  128. package/src/server/infrastructure/workspace/workspace-mutation-coordinator.js +32 -7
  129. package/src/server/shared/async-utils.js +25 -0
  130. package/dist/client/assets/architectureDiagram-2XIMDMQ5-Bhh8YQHn.js +0 -36
  131. package/dist/client/assets/base-B5gONpJK.css +0 -1
  132. package/dist/client/assets/chunk-27N2SPOM-DbHKsQ_j.js +0 -34
  133. package/dist/client/assets/classDiagram-VBA2DB6C-Bpdym3dj.js +0 -1
  134. package/dist/client/assets/classDiagram-v2-RAHNMMFH-CRAsdVDB.js +0 -1
  135. package/dist/client/assets/dagre-KLK3FWXG-CXSnjrX-.js +0 -4
  136. package/dist/client/assets/diagram-E7M64L7V-De0StFef.js +0 -24
  137. package/dist/client/assets/diagram-IFDJBPK2-B2m6AJbU.js +0 -43
  138. package/dist/client/assets/diagram-P4PSJMXO-YBWLVENw.js +0 -24
  139. package/dist/client/assets/dist-BUEzOS23.js +0 -1
  140. package/dist/client/assets/dist-BflL-_8D.js +0 -1
  141. package/dist/client/assets/dist-CI_qEj5K.js +0 -1
  142. package/dist/client/assets/dist-CtFNDttZ.js +0 -1
  143. package/dist/client/assets/dist-DS_4VpEd.js +0 -1
  144. package/dist/client/assets/dist-DwsFUbuB.js +0 -1
  145. package/dist/client/assets/dist-VCYAp958.js +0 -1
  146. package/dist/client/assets/dist-Yxt8txtM.js +0 -1
  147. package/dist/client/assets/dist-npxgF_6V.js +0 -1
  148. package/dist/client/assets/dist-u0zDRMQt.js +0 -1
  149. package/dist/client/assets/editor-session-DFzBn4RY.js +0 -16
  150. package/dist/client/assets/excalidraw-editor-Bat1FRoT.css +0 -1
  151. package/dist/client/assets/excalidraw-scene-CixfM-jL.js +0 -174
  152. package/dist/client/assets/excalidrawEditor-diK4VITg.js +0 -2
  153. package/dist/client/assets/exportDocument-BM4_f2n8.js +0 -2
  154. package/dist/client/assets/ganttDiagram-A5KZAMGK-R-AMIgxR.js +0 -292
  155. package/dist/client/assets/gitGraphDiagram-K3NZZRJ6-CIPit_Sh.js +0 -65
  156. package/dist/client/assets/image-32MOCVDX-BB6utPzr.js +0 -1
  157. package/dist/client/assets/index-BOOhEiLo.css +0 -1
  158. package/dist/client/assets/infoDiagram-LFFYTUFH-CF8jXFTm.js +0 -2
  159. package/dist/client/assets/mermaid-parser.core-By_5k2SF.js +0 -4
  160. package/dist/client/assets/pieDiagram-SKSYHLDU-CAU_gC7U.js +0 -30
  161. package/dist/client/assets/preview-render-compiler-CVfDHnqM.js +0 -1
  162. package/dist/client/assets/preview-render-compiler-CapHJDHT.js +0 -1
  163. package/dist/client/assets/preview-render-worker-wMS710Bv.js +0 -23
  164. package/dist/client/assets/stateDiagram-RAJIS63D-CPoSrNwn.js +0 -1
  165. package/dist/client/assets/stateDiagram-v2-FVOUBMTO-5GW0Y21a.js +0 -1
  166. /package/dist/client/assets/{base-r5Zg2n3k.js → base-h1uscjTi.js} +0 -0
@@ -2,6 +2,7 @@ import { createServer } from 'http';
2
2
 
3
3
  import { loadConfig } from './config/env.js';
4
4
  import { createAuthService } from './auth/create-auth-service.js';
5
+ import { logPerfEvent } from './config/perf-logging.js';
5
6
  import { BacklinkIndex } from './domain/backlink-index.js';
6
7
  import { CollaborationDocumentStore } from './domain/collaboration/collaboration-document-store.js';
7
8
  import { CollaborationRoom } from './domain/collaboration/collaboration-room.js';
@@ -38,6 +39,36 @@ function closeHttpServer(httpServer) {
38
39
  });
39
40
  }
40
41
 
42
+ function workspaceStateMetadataEqual(left = null, right = null) {
43
+ const leftMetadata = left?.metadata;
44
+ const rightMetadata = right?.metadata;
45
+ if (!(leftMetadata instanceof Map) || !(rightMetadata instanceof Map)) {
46
+ return false;
47
+ }
48
+
49
+ if (leftMetadata.size !== rightMetadata.size) {
50
+ return false;
51
+ }
52
+
53
+ for (const [pathValue, leftEntry] of leftMetadata.entries()) {
54
+ const rightEntry = rightMetadata.get(pathValue);
55
+ if (!rightEntry) {
56
+ return false;
57
+ }
58
+
59
+ if (
60
+ leftEntry.type !== rightEntry.type
61
+ || leftEntry.size !== rightEntry.size
62
+ || leftEntry.inode !== rightEntry.inode
63
+ || leftEntry.mtimeMs !== rightEntry.mtimeMs
64
+ ) {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ return true;
70
+ }
71
+
41
72
  export function createAppServer(config = loadConfig()) {
42
73
  const authService = createAuthService(config);
43
74
  const vaultFileStore = new VaultFileStore({ vaultDir: config.vaultDir });
@@ -69,6 +100,7 @@ export function createAppServer(config = loadConfig()) {
69
100
  maxBufferedAmountBytes: config.wsMaxBufferedAmountBytes,
70
101
  name,
71
102
  onEmpty,
103
+ perfLoggingEnabled: config.perfLoggingEnabled,
72
104
  });
73
105
 
74
106
  if (name === WORKSPACE_ROOM_NAME && workspaceMutationCoordinator?.workspaceState) {
@@ -88,6 +120,7 @@ export function createAppServer(config = loadConfig()) {
88
120
  vaultFileStore.setManagedWriteTracker(workspaceMutationCoordinator);
89
121
  const fileSystemSyncService = new FileSystemSyncService({
90
122
  mutationCoordinator: workspaceMutationCoordinator,
123
+ perfLoggingEnabled: config.perfLoggingEnabled,
91
124
  vaultFileStore,
92
125
  });
93
126
  const requestHandler = createRequestHandler(
@@ -129,21 +162,84 @@ export function createAppServer(config = loadConfig()) {
129
162
  let vaultFileCount = 0;
130
163
 
131
164
  async function listen() {
132
- vaultFileCount = await vaultFileStore.countVaultFiles();
133
- await backlinkIndex.build();
134
- await workspaceMutationCoordinator.initialize();
135
- await fileSystemSyncService.start();
165
+ const startupStartedAt = Date.now();
166
+
167
+ const initialWorkspaceScanStartedAt = Date.now();
168
+ const initialWorkspaceSnapshot = await vaultFileStore.scanWorkspaceState();
169
+ vaultFileCount = initialWorkspaceSnapshot.vaultFileCount ?? 0;
170
+ logPerfEvent(config.perfLoggingEnabled, 'startup', {
171
+ durationMs: Date.now() - initialWorkspaceScanStartedAt,
172
+ phase: 'workspace-scan',
173
+ vaultFileCount,
174
+ });
175
+
176
+ const backlinkBuildStartedAt = Date.now();
177
+ await backlinkIndex.build({ workspaceState: initialWorkspaceSnapshot });
178
+ logPerfEvent(config.perfLoggingEnabled, 'startup', {
179
+ durationMs: Date.now() - backlinkBuildStartedAt,
180
+ markdownFileCount: initialWorkspaceSnapshot.markdownPaths?.length ?? 0,
181
+ phase: 'backlink-build',
182
+ });
183
+
184
+ const liveWorkspaceScanStartedAt = Date.now();
185
+ const liveWorkspaceSnapshot = await vaultFileStore.scanWorkspaceState();
186
+ const workspaceChangedDuringStartup = !workspaceStateMetadataEqual(initialWorkspaceSnapshot, liveWorkspaceSnapshot);
187
+ vaultFileCount = liveWorkspaceSnapshot.vaultFileCount ?? vaultFileCount;
188
+ logPerfEvent(config.perfLoggingEnabled, 'startup', {
189
+ changedDuringStartup: workspaceChangedDuringStartup,
190
+ durationMs: Date.now() - liveWorkspaceScanStartedAt,
191
+ phase: 'workspace-rescan',
192
+ vaultFileCount,
193
+ });
194
+
195
+ if (workspaceChangedDuringStartup) {
196
+ const backlinkRebuildStartedAt = Date.now();
197
+ await backlinkIndex.build({ workspaceState: liveWorkspaceSnapshot });
198
+ logPerfEvent(config.perfLoggingEnabled, 'startup', {
199
+ durationMs: Date.now() - backlinkRebuildStartedAt,
200
+ markdownFileCount: liveWorkspaceSnapshot.markdownPaths?.length ?? 0,
201
+ phase: 'backlink-rebuild',
202
+ });
203
+ }
204
+
205
+ const workspaceInitStartedAt = Date.now();
206
+ await workspaceMutationCoordinator.initialize({ snapshot: liveWorkspaceSnapshot });
207
+ logPerfEvent(config.perfLoggingEnabled, 'startup', {
208
+ durationMs: Date.now() - workspaceInitStartedAt,
209
+ phase: 'workspace-init',
210
+ });
211
+
212
+ const watcherStartStartedAt = Date.now();
213
+ await fileSystemSyncService.start({ snapshot: liveWorkspaceSnapshot });
214
+ logPerfEvent(config.perfLoggingEnabled, 'startup', {
215
+ durationMs: Date.now() - watcherStartStartedAt,
216
+ phase: 'watcher-start',
217
+ });
136
218
 
137
219
  return new Promise((resolve, reject) => {
220
+ const listenStartedAt = Date.now();
138
221
  httpServer.once('error', reject);
139
222
  httpServer.listen(config.port, config.host, () => {
140
223
  httpServer.off('error', reject);
141
224
  const address = httpServer.address();
142
- resolve({
225
+ const result = {
143
226
  address,
144
227
  host: getDisplayHost(config.host),
145
228
  port: typeof address === 'object' && address ? address.port : config.port,
146
229
  wsPath: `${config.basePath || ''}${config.wsBasePath}/:file`,
230
+ };
231
+ logPerfEvent(config.perfLoggingEnabled, 'startup', {
232
+ durationMs: Date.now() - listenStartedAt,
233
+ phase: 'listen',
234
+ port: result.port,
235
+ });
236
+ logPerfEvent(config.perfLoggingEnabled, 'startup-total', {
237
+ durationMs: Date.now() - startupStartedAt,
238
+ markdownFileCount: liveWorkspaceSnapshot.markdownPaths?.length ?? 0,
239
+ vaultFileCount,
240
+ });
241
+ resolve({
242
+ ...result,
147
243
  });
148
244
  });
149
245
  });
@@ -176,6 +272,7 @@ export function createAppServer(config = loadConfig()) {
176
272
  roomRegistry,
177
273
  workspaceMutationCoordinator,
178
274
  authService,
275
+ backlinkIndex,
179
276
  fileSystemSyncService,
180
277
  gitService,
181
278
  setTestHydrateDelayMs(delayMs = 0) {
@@ -11,8 +11,10 @@
11
11
  */
12
12
 
13
13
  import { createWikiTargetIndex, resolveWikiTargetWithIndex } from '../../domain/wiki-link-resolver.js';
14
+ import { mapWithConcurrency } from '../shared/async-utils.js';
14
15
 
15
16
  const WIKI_LINK_RE = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
17
+ const BACKLINK_BUILD_CONCURRENCY = 8;
16
18
 
17
19
  function createDeferred() {
18
20
  let resolve;
@@ -58,13 +60,15 @@ export class BacklinkIndex {
58
60
  * Build the full index by scanning every markdown file in the vault.
59
61
  * Called once at server startup.
60
62
  */
61
- async build() {
63
+ async build({ workspaceState = null } = {}) {
62
64
  this._requestedBuildVersion += 1;
65
+ this._latestRequestedWorkspaceState = workspaceState;
63
66
  return this.flushScheduledBuild();
64
67
  }
65
68
 
66
69
  scheduleBuild({ delayMs = this.rebuildDelayMs } = {}) {
67
70
  this._requestedBuildVersion += 1;
71
+ this._latestRequestedWorkspaceState = null;
68
72
  if (!this._scheduledBuildDeferred) {
69
73
  this._scheduledBuildDeferred = createDeferred();
70
74
  }
@@ -116,8 +120,9 @@ export class BacklinkIndex {
116
120
  }
117
121
 
118
122
  const targetVersion = this._requestedBuildVersion;
123
+ const workspaceState = this._latestRequestedWorkspaceState;
119
124
  this._buildPromise = (async () => {
120
- await this._performBuild();
125
+ await this._performBuild(workspaceState);
121
126
  this._completedBuildVersion = Math.max(this._completedBuildVersion, targetVersion);
122
127
  })().finally(() => {
123
128
  this._buildPromise = null;
@@ -126,18 +131,26 @@ export class BacklinkIndex {
126
131
  return this._buildPromise;
127
132
  }
128
133
 
129
- async _performBuild() {
134
+ async _performBuild(workspaceState = null) {
130
135
  this.forward.clear();
131
136
  this.reverse.clear();
132
137
  this.contextsBySource.clear();
133
138
 
134
- const tree = await this.vaultFileStore.tree();
135
- this._fileList = flattenTree(tree);
139
+ const snapshot = workspaceState ?? await this._resolveWorkspaceState();
140
+ this._fileList = Array.from(snapshot?.markdownPaths ?? []);
136
141
  this._fileSet = new Set(this._fileList);
137
142
  this._refreshWikiTargetIndex();
138
143
 
139
- for (const filePath of this._fileList) {
140
- const content = await this.vaultFileStore.readMarkdownFile(filePath);
144
+ const fileContents = await mapWithConcurrency(
145
+ this._fileList,
146
+ BACKLINK_BUILD_CONCURRENCY,
147
+ async (filePath) => ({
148
+ content: await this.vaultFileStore.readMarkdownFile(filePath),
149
+ filePath,
150
+ }),
151
+ );
152
+
153
+ for (const { content, filePath } of fileContents) {
141
154
  if (content !== null) {
142
155
  this._indexFile(filePath, content);
143
156
  }
@@ -147,6 +160,17 @@ export class BacklinkIndex {
147
160
  console.log(`[backlinks] Index built: ${this._fileList.length} files, ${this.reverse.size} targets with backlinks`);
148
161
  }
149
162
 
163
+ async _resolveWorkspaceState() {
164
+ if (typeof this.vaultFileStore.scanWorkspaceState === 'function') {
165
+ return this.vaultFileStore.scanWorkspaceState();
166
+ }
167
+
168
+ const tree = await this.vaultFileStore.tree();
169
+ return {
170
+ markdownPaths: flattenTree(tree),
171
+ };
172
+ }
173
+
150
174
  /**
151
175
  * Incrementally update the index when a file's content changes.
152
176
  * Call this after every persist / write.
@@ -8,6 +8,7 @@ import { MSG_AWARENESS, MSG_SYNC } from './protocol.js';
8
8
  import { CollaborationDocumentStore } from './collaboration-document-store.js';
9
9
  import { RoomClientStateStore } from './room-client-state-store.js';
10
10
  import { RoomPersistenceController } from './room-persistence-controller.js';
11
+ import { logPerfEvent } from '../../config/perf-logging.js';
11
12
  import { populateCommentThreads, serializeCommentThreads } from '../../../domain/comment-threads.js';
12
13
  import {
13
14
  isExcalidrawRoomDocStructured,
@@ -131,6 +132,7 @@ export class CollaborationRoom {
131
132
  name,
132
133
  idleGraceMs = 0,
133
134
  maxBufferedAmountBytes,
135
+ perfLoggingEnabled = false,
134
136
  vaultFileStore,
135
137
  backlinkIndex,
136
138
  onEmpty,
@@ -138,6 +140,7 @@ export class CollaborationRoom {
138
140
  this.name = name;
139
141
  this.idleGraceMs = idleGraceMs;
140
142
  this.maxBufferedAmountBytes = maxBufferedAmountBytes;
143
+ this.perfLoggingEnabled = perfLoggingEnabled;
141
144
  this.documentStore = documentStore ?? new CollaborationDocumentStore({
142
145
  backlinkIndex,
143
146
  name,
@@ -187,6 +190,8 @@ export class CollaborationRoom {
187
190
  let snapshotExists = false;
188
191
  let snapshotValid = false;
189
192
  let commentThreadCount = 0;
193
+ let snapshotReadDurationMs = 0;
194
+ let contentFallbackDurationMs = 0;
190
195
  const hydrateDelayMs = Math.max(0, Number(this.getHydrateDelayMs?.() || 0));
191
196
 
192
197
  try {
@@ -195,7 +200,9 @@ export class CollaborationRoom {
195
200
  }
196
201
 
197
202
  if (this.documentStore?.hasPersistence()) {
203
+ const snapshotReadStartedAt = Date.now();
198
204
  const snapshot = await this.documentStore.readSnapshot();
205
+ snapshotReadDurationMs = Date.now() - snapshotReadStartedAt;
199
206
  if (snapshot) {
200
207
  snapshotExists = true;
201
208
  try {
@@ -227,10 +234,12 @@ export class CollaborationRoom {
227
234
  }
228
235
  }
229
236
 
237
+ const fallbackStartedAt = Date.now();
230
238
  const [content, commentThreads] = await Promise.all([
231
239
  this.documentStore.readContent(),
232
240
  this.documentStore.readCommentThreads(),
233
241
  ]);
242
+ contentFallbackDurationMs = Date.now() - fallbackStartedAt;
234
243
  commentThreadCount = commentThreads.length;
235
244
  if (content !== null || commentThreads.length > 0) {
236
245
  hydrateSource = 'content';
@@ -262,14 +271,23 @@ export class CollaborationRoom {
262
271
  this.debugMetrics.hydrateCount += 1;
263
272
  this.debugMetrics.lastHydrate = {
264
273
  commentThreadCount,
274
+ contentFallbackDurationMs,
265
275
  durationMs: Date.now() - startedAt,
276
+ snapshotReadDurationMs,
266
277
  snapshotExists,
267
278
  snapshotValid,
268
279
  source: hydrateSource,
269
280
  };
270
- console.info(
271
- `[perf][room:${this.name}] hydrate durationMs=${this.debugMetrics.lastHydrate.durationMs} source=${this.debugMetrics.lastHydrate.source} snapshotExists=${this.debugMetrics.lastHydrate.snapshotExists} snapshotValid=${this.debugMetrics.lastHydrate.snapshotValid} commentThreads=${this.debugMetrics.lastHydrate.commentThreadCount}`,
272
- );
281
+ logPerfEvent(this.perfLoggingEnabled, `room:${this.name}`, {
282
+ commentThreads: this.debugMetrics.lastHydrate.commentThreadCount,
283
+ contentFallbackDurationMs: this.debugMetrics.lastHydrate.contentFallbackDurationMs,
284
+ durationMs: this.debugMetrics.lastHydrate.durationMs,
285
+ event: 'hydrate',
286
+ snapshotExists: this.debugMetrics.lastHydrate.snapshotExists,
287
+ snapshotReadDurationMs: this.debugMetrics.lastHydrate.snapshotReadDurationMs,
288
+ snapshotValid: this.debugMetrics.lastHydrate.snapshotValid,
289
+ source: this.debugMetrics.lastHydrate.source,
290
+ });
273
291
  }
274
292
  }
275
293
  })().catch((error) => {
@@ -522,8 +540,13 @@ export class CollaborationRoom {
522
540
  sendInitialSync(ws) {
523
541
  this.debugMetrics.initialSyncCount += 1;
524
542
  this.debugMetrics.lastInitialSyncAt = Date.now();
525
- console.info(`[perf][room:${this.name}] send-initial-sync count=${this.debugMetrics.initialSyncCount}`);
526
- return sendMessage.call(this, ws, this.getInitialSyncMessage(), this);
543
+ const initialSyncMessage = this.getInitialSyncMessage();
544
+ logPerfEvent(this.perfLoggingEnabled, `room:${this.name}`, {
545
+ bytes: initialSyncMessage.byteLength,
546
+ count: this.debugMetrics.initialSyncCount,
547
+ event: 'initial-sync',
548
+ });
549
+ return sendMessage.call(this, ws, initialSyncMessage, this);
527
550
  }
528
551
 
529
552
  async addClient(ws, { sendInitialSync: shouldSendInitialSync = true } = {}) {
@@ -430,7 +430,7 @@ export class GitHistoryService {
430
430
  }
431
431
 
432
432
  return this.runRequest(cacheKey, async () => {
433
- let content = '';
433
+ let content;
434
434
  try {
435
435
  content = await this.commandRunner.execGit(['show', `${normalizedHash}:${normalizedPath}`]);
436
436
  } catch {
@@ -240,7 +240,7 @@ export class PullBackupStore {
240
240
  }
241
241
 
242
242
  async listBackups() {
243
- let entries = [];
243
+ let entries;
244
244
  try {
245
245
  entries = await readdir(this.getStorageRoot(), { withFileTypes: true });
246
246
  } catch (error) {
@@ -7,6 +7,7 @@ import {
7
7
  isMarkdownFilePath,
8
8
  isVaultFilePath,
9
9
  } from '../../../domain/file-kind.js';
10
+ import { mapWithConcurrency } from '../../shared/async-utils.js';
10
11
  import { getVaultContentAdapter } from './vault-content-adapter.js';
11
12
  import {
12
13
  INVALID_VAULT_FILE_PATH_ERROR,
@@ -36,6 +37,7 @@ const MIME_TYPE_TO_IMAGE_EXTENSION = Object.freeze({
36
37
  'image/svg+xml': '.svg',
37
38
  'image/webp': '.webp',
38
39
  });
40
+ const WORKSPACE_SCAN_CONCURRENCY = 8;
39
41
 
40
42
  function createTransactionalPath(targetPath, label) {
41
43
  return `${targetPath}.collabmd-${label}-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
@@ -74,6 +76,16 @@ function createWorkspaceEntry(relativePath, type) {
74
76
  };
75
77
  }
76
78
 
79
+ function createWorkspaceMetadata(pathValue, type, info) {
80
+ return {
81
+ inode: Number(info.ino || 0),
82
+ mtimeMs: Number(info.mtimeMs || 0),
83
+ path: pathValue,
84
+ size: type === 'directory' ? 0 : Number(info.size || 0),
85
+ type,
86
+ };
87
+ }
88
+
77
89
  function replacePathPrefix(pathValue, oldPrefix, newPrefix) {
78
90
  if (pathValue === oldPrefix) {
79
91
  return newPrefix;
@@ -94,6 +106,14 @@ function sortWorkspacePaths(paths = [], direction = 'asc') {
94
106
  });
95
107
  }
96
108
 
109
+ function sortDirectoryEntries(entries = []) {
110
+ return [...entries].sort((left, right) => {
111
+ if (left.isDirectory() && !right.isDirectory()) return -1;
112
+ if (!left.isDirectory() && right.isDirectory()) return 1;
113
+ return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
114
+ });
115
+ }
116
+
97
117
  async function cleanupPaths(paths = []) {
98
118
  await Promise.allSettled(paths.filter(Boolean).map((pathValue) => rm(pathValue, { force: true })));
99
119
  }
@@ -398,8 +418,8 @@ export class VaultFileStore {
398
418
  const timestamp = createAttachmentTimestamp(now);
399
419
  const baseFileName = `${attachmentStem}-${timestamp}`;
400
420
  let collisionIndex = 0;
401
- let storedPath = '';
402
- let absolutePath = '';
421
+ let storedPath;
422
+ let absolutePath;
403
423
 
404
424
  do {
405
425
  const suffix = collisionIndex > 0 ? `-${collisionIndex + 1}` : '';
@@ -765,7 +785,8 @@ export class VaultFileStore {
765
785
  }
766
786
 
767
787
  async countVaultFiles() {
768
- return this.countFilesInDir(this.vaultDir);
788
+ const snapshot = await this.scanWorkspaceState();
789
+ return snapshot.vaultFileCount;
769
790
  }
770
791
 
771
792
  async reconcileSidecars({
@@ -838,6 +859,8 @@ export class VaultFileStore {
838
859
  async scanWorkspaceState() {
839
860
  const entries = new Map();
840
861
  const metadata = new Map();
862
+ const markdownPaths = [];
863
+ let vaultFileCount = 0;
841
864
 
842
865
  const visitDirectory = async (dirPath) => {
843
866
  let dirEntries;
@@ -847,64 +870,84 @@ export class VaultFileStore {
847
870
  return;
848
871
  }
849
872
 
850
- const sorted = dirEntries.sort((left, right) => {
851
- if (left.isDirectory() && !right.isDirectory()) return -1;
852
- if (!left.isDirectory() && right.isDirectory()) return 1;
853
- return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
854
- });
873
+ const sorted = sortDirectoryEntries(dirEntries);
855
874
 
856
- for (const entry of sorted) {
875
+ await mapWithConcurrency(sorted, WORKSPACE_SCAN_CONCURRENCY, async (entry) => {
857
876
  if (isIgnoredVaultEntry(entry.name)) {
858
- continue;
877
+ return;
859
878
  }
860
879
 
861
880
  const fullPath = join(dirPath, entry.name);
862
881
  const relativePath = toVaultRelativePath(this.vaultDir, fullPath).replace(/\\/g, '/');
882
+ const direntKind = entry.isDirectory()
883
+ ? 'directory'
884
+ : (entry.isFile() ? 'file' : null);
863
885
 
864
- if (entry.isDirectory()) {
886
+ if (direntKind === 'directory') {
865
887
  entries.set(relativePath, createWorkspaceEntry(relativePath, 'directory'));
866
888
  try {
867
889
  const info = await stat(fullPath);
868
- metadata.set(relativePath, {
869
- inode: Number(info.ino || 0),
870
- mtimeMs: Number(info.mtimeMs || 0),
871
- path: relativePath,
872
- size: 0,
873
- type: 'directory',
874
- });
890
+ metadata.set(relativePath, createWorkspaceMetadata(relativePath, 'directory', info));
875
891
  } catch {
876
892
  // Ignore transient directories that disappear during scans.
877
893
  }
878
894
  await visitDirectory(fullPath);
879
- continue;
895
+ return;
880
896
  }
881
897
 
882
- if (!isVaultFilePath(entry.name)) {
883
- continue;
898
+ if (direntKind === 'file') {
899
+ if (!isVaultFilePath(entry.name)) {
900
+ return;
901
+ }
902
+
903
+ entries.set(relativePath, createWorkspaceEntry(relativePath, 'file'));
904
+ vaultFileCount += 1;
905
+ if (isMarkdownFilePath(relativePath)) {
906
+ markdownPaths.push(relativePath);
907
+ }
908
+ try {
909
+ const info = await stat(fullPath);
910
+ metadata.set(relativePath, createWorkspaceMetadata(relativePath, 'file', info));
911
+ } catch {
912
+ // Ignore transient files that disappear during scans.
913
+ }
914
+ return;
884
915
  }
885
916
 
886
- entries.set(relativePath, createWorkspaceEntry(relativePath, 'file'));
887
917
  try {
888
918
  const info = await stat(fullPath);
889
- metadata.set(relativePath, {
890
- inode: Number(info.ino || 0),
891
- mtimeMs: Number(info.mtimeMs || 0),
892
- path: relativePath,
893
- size: Number(info.size || 0),
894
- type: 'file',
895
- });
919
+ if (info.isDirectory()) {
920
+ entries.set(relativePath, createWorkspaceEntry(relativePath, 'directory'));
921
+ metadata.set(relativePath, createWorkspaceMetadata(relativePath, 'directory', info));
922
+ await visitDirectory(fullPath);
923
+ return;
924
+ }
925
+
926
+ if (!info.isFile() || !isVaultFilePath(entry.name)) {
927
+ return;
928
+ }
929
+
930
+ entries.set(relativePath, createWorkspaceEntry(relativePath, 'file'));
931
+ metadata.set(relativePath, createWorkspaceMetadata(relativePath, 'file', info));
932
+ vaultFileCount += 1;
933
+ if (isMarkdownFilePath(relativePath)) {
934
+ markdownPaths.push(relativePath);
935
+ }
896
936
  } catch {
897
- // Ignore transient files that disappear during scans.
937
+ // Ignore transient entries that disappear during scans.
898
938
  }
899
- }
939
+ });
900
940
  };
901
941
 
902
942
  await visitDirectory(this.vaultDir);
943
+ markdownPaths.sort((left, right) => left.localeCompare(right, undefined, { sensitivity: 'base' }));
903
944
 
904
945
  return {
905
946
  entries,
906
947
  metadata,
948
+ markdownPaths,
907
949
  scannedAt: Date.now(),
950
+ vaultFileCount,
908
951
  };
909
952
  }
910
953
  }