@harperfast/harper 5.2.0 → 5.2.2

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 (308) hide show
  1. package/bin/copyDb.ts +40 -11
  2. package/bin/harper.ts +20 -51
  3. package/bin/help.ts +216 -0
  4. package/components/Application.ts +236 -46
  5. package/components/ApplicationScope.ts +26 -0
  6. package/components/EntryHandler.ts +410 -105
  7. package/components/RuntimeModuleTracker.ts +189 -0
  8. package/components/Scope.ts +68 -24
  9. package/components/componentLoader.ts +70 -16
  10. package/components/deployLifecycle.ts +119 -33
  11. package/components/deploymentRecorder.ts +102 -11
  12. package/components/mcp/tools/operations.ts +6 -0
  13. package/components/operations.js +8 -6
  14. package/config/configUtils.ts +78 -20
  15. package/config/harperConfigEnvVars.ts +114 -17
  16. package/config-root.schema.json +10 -0
  17. package/dataLayer/harperBridge/ResourceBridge.ts +26 -5
  18. package/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js +4 -4
  19. package/dataLayer/hdbInfoController.ts +8 -0
  20. package/dataLayer/schemaDescribe.ts +2 -1
  21. package/dist/bin/copyDb.js +33 -9
  22. package/dist/bin/copyDb.js.map +1 -1
  23. package/dist/bin/harper.d.ts +6 -0
  24. package/dist/bin/harper.js +18 -50
  25. package/dist/bin/harper.js.map +1 -1
  26. package/dist/bin/help.d.ts +8 -0
  27. package/dist/bin/help.js +192 -0
  28. package/dist/bin/help.js.map +1 -0
  29. package/dist/components/Application.d.ts +16 -1
  30. package/dist/components/Application.js +210 -38
  31. package/dist/components/Application.js.map +1 -1
  32. package/dist/components/ApplicationScope.d.ts +7 -0
  33. package/dist/components/ApplicationScope.js +22 -0
  34. package/dist/components/ApplicationScope.js.map +1 -1
  35. package/dist/components/EntryHandler.d.ts +4 -4
  36. package/dist/components/EntryHandler.js +386 -95
  37. package/dist/components/EntryHandler.js.map +1 -1
  38. package/dist/components/RuntimeModuleTracker.d.ts +11 -0
  39. package/dist/components/RuntimeModuleTracker.js +189 -0
  40. package/dist/components/RuntimeModuleTracker.js.map +1 -0
  41. package/dist/components/Scope.d.ts +1 -0
  42. package/dist/components/Scope.js +69 -22
  43. package/dist/components/Scope.js.map +1 -1
  44. package/dist/components/componentLoader.js +69 -10
  45. package/dist/components/componentLoader.js.map +1 -1
  46. package/dist/components/deployLifecycle.d.ts +6 -2
  47. package/dist/components/deployLifecycle.js +109 -31
  48. package/dist/components/deployLifecycle.js.map +1 -1
  49. package/dist/components/deploymentRecorder.d.ts +6 -0
  50. package/dist/components/deploymentRecorder.js +94 -12
  51. package/dist/components/deploymentRecorder.js.map +1 -1
  52. package/dist/components/mcp/tools/operations.js +6 -0
  53. package/dist/components/mcp/tools/operations.js.map +1 -1
  54. package/dist/components/operations.js +8 -6
  55. package/dist/components/operations.js.map +1 -1
  56. package/dist/config/configUtils.d.ts +6 -0
  57. package/dist/config/configUtils.js +78 -18
  58. package/dist/config/configUtils.js.map +1 -1
  59. package/dist/config/harperConfigEnvVars.js +108 -16
  60. package/dist/config/harperConfigEnvVars.js.map +1 -1
  61. package/dist/dataLayer/harperBridge/ResourceBridge.js +12 -5
  62. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  63. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js +4 -4
  64. package/dist/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js.map +1 -1
  65. package/dist/dataLayer/hdbInfoController.js +4 -0
  66. package/dist/dataLayer/hdbInfoController.js.map +1 -1
  67. package/dist/dataLayer/schemaDescribe.js +2 -1
  68. package/dist/dataLayer/schemaDescribe.js.map +1 -1
  69. package/dist/resources/DatabaseTransaction.d.ts +57 -0
  70. package/dist/resources/DatabaseTransaction.js +284 -104
  71. package/dist/resources/DatabaseTransaction.js.map +1 -1
  72. package/dist/resources/ResourceInterface.d.ts +8 -2
  73. package/dist/resources/ResourceInterface.js.map +1 -1
  74. package/dist/resources/Resources.js +22 -4
  75. package/dist/resources/Resources.js.map +1 -1
  76. package/dist/resources/Table.d.ts +8 -6
  77. package/dist/resources/Table.js +158 -31
  78. package/dist/resources/Table.js.map +1 -1
  79. package/dist/resources/analytics/write.js +6 -6
  80. package/dist/resources/analytics/write.js.map +1 -1
  81. package/dist/resources/auditStore.js +27 -1
  82. package/dist/resources/auditStore.js.map +1 -1
  83. package/dist/resources/blob.d.ts +0 -1
  84. package/dist/resources/blob.js +15 -7
  85. package/dist/resources/blob.js.map +1 -1
  86. package/dist/resources/databases.d.ts +22 -5
  87. package/dist/resources/databases.js +118 -9
  88. package/dist/resources/databases.js.map +1 -1
  89. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +15 -2
  90. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +128 -39
  91. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  92. package/dist/resources/jsResource.d.ts +4 -26
  93. package/dist/resources/jsResource.js +5 -59
  94. package/dist/resources/jsResource.js.map +1 -1
  95. package/dist/resources/models/Models.d.ts +11 -1
  96. package/dist/resources/models/Models.js +10 -1
  97. package/dist/resources/models/Models.js.map +1 -1
  98. package/dist/resources/models/backendRegistry.d.ts +9 -0
  99. package/dist/resources/models/backendRegistry.js +10 -0
  100. package/dist/resources/models/backendRegistry.js.map +1 -1
  101. package/dist/resources/models/openaiStream.d.ts +16 -1
  102. package/dist/resources/models/openaiStream.js +113 -21
  103. package/dist/resources/models/openaiStream.js.map +1 -1
  104. package/dist/resources/models/v1/chatCompletions.d.ts +54 -0
  105. package/dist/resources/models/v1/chatCompletions.js +115 -0
  106. package/dist/resources/models/v1/chatCompletions.js.map +1 -0
  107. package/dist/resources/models/v1/embeddings.d.ts +11 -0
  108. package/dist/resources/models/v1/embeddings.js +71 -0
  109. package/dist/resources/models/v1/embeddings.js.map +1 -0
  110. package/dist/resources/models/v1/errors.d.ts +54 -0
  111. package/dist/resources/models/v1/errors.js +130 -0
  112. package/dist/resources/models/v1/errors.js.map +1 -0
  113. package/dist/resources/models/v1/index.d.ts +36 -0
  114. package/dist/resources/models/v1/index.js +75 -0
  115. package/dist/resources/models/v1/index.js.map +1 -0
  116. package/dist/resources/models/v1/models.d.ts +26 -0
  117. package/dist/resources/models/v1/models.js +44 -0
  118. package/dist/resources/models/v1/models.js.map +1 -0
  119. package/dist/resources/models/v1/translation.d.ts +133 -0
  120. package/dist/resources/models/v1/translation.js +298 -0
  121. package/dist/resources/models/v1/translation.js.map +1 -0
  122. package/dist/resources/roles.d.ts +1 -1
  123. package/dist/resources/roles.js +54 -7
  124. package/dist/resources/roles.js.map +1 -1
  125. package/dist/resources/search.d.ts +1 -1
  126. package/dist/resources/search.js +7 -3
  127. package/dist/resources/search.js.map +1 -1
  128. package/dist/security/jsLoader.js +84 -33
  129. package/dist/security/jsLoader.js.map +1 -1
  130. package/dist/security/role.js +4 -0
  131. package/dist/security/role.js.map +1 -1
  132. package/dist/security/superUserGuard.d.ts +7 -0
  133. package/dist/security/superUserGuard.js +23 -0
  134. package/dist/security/superUserGuard.js.map +1 -0
  135. package/dist/security/tokenAuthentication.d.ts +0 -1
  136. package/dist/security/tokenAuthentication.js +6 -2
  137. package/dist/security/tokenAuthentication.js.map +1 -1
  138. package/dist/security/user.d.ts +6 -1
  139. package/dist/security/user.js +23 -1
  140. package/dist/security/user.js.map +1 -1
  141. package/dist/server/http.d.ts +23 -2
  142. package/dist/server/http.js +99 -12
  143. package/dist/server/http.js.map +1 -1
  144. package/dist/server/operationsServer.d.ts +0 -2
  145. package/dist/server/operationsServer.js.map +1 -1
  146. package/dist/server/serverHelpers/multipartParser.js +9 -0
  147. package/dist/server/serverHelpers/multipartParser.js.map +1 -1
  148. package/dist/server/serverHelpers/operationAuthorizationState.d.ts +2 -0
  149. package/dist/server/serverHelpers/operationAuthorizationState.js +13 -0
  150. package/dist/server/serverHelpers/operationAuthorizationState.js.map +1 -0
  151. package/dist/server/serverHelpers/registeredOperations.d.ts +3 -2
  152. package/dist/server/serverHelpers/registeredOperations.js +14 -13
  153. package/dist/server/serverHelpers/registeredOperations.js.map +1 -1
  154. package/dist/server/serverHelpers/serverHandlers.js +19 -3
  155. package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
  156. package/dist/server/serverHelpers/serverUtilities.d.ts +1 -1
  157. package/dist/server/serverHelpers/serverUtilities.js +10 -7
  158. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  159. package/dist/server/static.js +57 -18
  160. package/dist/server/static.js.map +1 -1
  161. package/dist/server/storageReclamation.d.ts +17 -0
  162. package/dist/server/storageReclamation.js +90 -6
  163. package/dist/server/storageReclamation.js.map +1 -1
  164. package/dist/server/threads/manageThreads.js +8 -0
  165. package/dist/server/threads/manageThreads.js.map +1 -1
  166. package/dist/server/threads/socketRouter.js +20 -0
  167. package/dist/server/threads/socketRouter.js.map +1 -1
  168. package/dist/server/threads/threadServer.js +12 -0
  169. package/dist/server/threads/threadServer.js.map +1 -1
  170. package/dist/sqlEngine/diff/differential.js +7 -3
  171. package/dist/sqlEngine/diff/differential.js.map +1 -1
  172. package/dist/sqlEngine/parser/normalizer.js +37 -9
  173. package/dist/sqlEngine/parser/normalizer.js.map +1 -1
  174. package/dist/sqlTranslator/index.js +6 -1
  175. package/dist/sqlTranslator/index.js.map +1 -1
  176. package/dist/upgrade/upgradePrompt.d.ts +2 -2
  177. package/dist/upgrade/upgradePrompt.js +22 -3
  178. package/dist/upgrade/upgradePrompt.js.map +1 -1
  179. package/dist/utility/environment/environmentManager.d.ts +11 -0
  180. package/dist/utility/environment/environmentManager.js +154 -0
  181. package/dist/utility/environment/environmentManager.js.map +1 -1
  182. package/dist/utility/errors/commonErrors.d.ts +1 -0
  183. package/dist/utility/errors/commonErrors.js +1 -0
  184. package/dist/utility/errors/commonErrors.js.map +1 -1
  185. package/dist/utility/hdbTerms.d.ts +2 -0
  186. package/dist/utility/hdbTerms.js +2 -0
  187. package/dist/utility/hdbTerms.js.map +1 -1
  188. package/dist/utility/install/installer.js +32 -0
  189. package/dist/utility/install/installer.js.map +1 -1
  190. package/dist/utility/lmdb/environmentUtility.js +2 -1
  191. package/dist/utility/lmdb/environmentUtility.js.map +1 -1
  192. package/dist/utility/logging/harper_logger.d.ts +7 -0
  193. package/dist/utility/logging/harper_logger.js +57 -24
  194. package/dist/utility/logging/harper_logger.js.map +1 -1
  195. package/npm-shrinkwrap.json +208 -198
  196. package/package.json +10 -8
  197. package/resources/DESIGN.md +1 -0
  198. package/resources/DatabaseTransaction.ts +313 -102
  199. package/resources/ResourceInterface.ts +8 -2
  200. package/resources/Resources.ts +22 -4
  201. package/resources/Table.ts +398 -259
  202. package/resources/analytics/write.ts +22 -20
  203. package/resources/auditStore.ts +32 -1
  204. package/resources/blob.ts +15 -8
  205. package/resources/databases.ts +123 -12
  206. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +119 -45
  207. package/resources/jsResource.ts +5 -62
  208. package/resources/models/Models.ts +14 -1
  209. package/resources/models/backendRegistry.ts +10 -0
  210. package/resources/models/openaiStream.ts +131 -19
  211. package/resources/models/v1/chatCompletions.ts +128 -0
  212. package/resources/models/v1/embeddings.ts +70 -0
  213. package/resources/models/v1/errors.ts +141 -0
  214. package/resources/models/v1/index.ts +72 -0
  215. package/resources/models/v1/models.ts +53 -0
  216. package/resources/models/v1/translation.ts +362 -0
  217. package/resources/roles.ts +67 -7
  218. package/resources/search.ts +8 -3
  219. package/security/jsLoader.ts +84 -30
  220. package/security/role.ts +7 -0
  221. package/security/superUserGuard.ts +20 -0
  222. package/security/tokenAuthentication.ts +6 -3
  223. package/security/user.ts +26 -1
  224. package/server/DESIGN.md +45 -34
  225. package/server/http.ts +100 -13
  226. package/server/operationsServer.ts +0 -2
  227. package/server/serverHelpers/multipartParser.ts +9 -0
  228. package/server/serverHelpers/operationAuthorizationState.ts +11 -0
  229. package/server/serverHelpers/registeredOperations.ts +19 -15
  230. package/server/serverHelpers/serverHandlers.js +20 -3
  231. package/server/serverHelpers/serverUtilities.ts +10 -7
  232. package/server/static.ts +75 -20
  233. package/server/storageReclamation.ts +104 -8
  234. package/server/threads/manageThreads.js +8 -0
  235. package/server/threads/socketRouter.ts +20 -0
  236. package/server/threads/threadServer.js +11 -0
  237. package/sqlTranslator/index.ts +6 -1
  238. package/static/defaultConfig.yaml +2 -0
  239. package/studio/web/assets/{Chat-DoVWScmq.js → Chat-BMUjPklt.js} +5 -5
  240. package/studio/web/assets/{Chat-DoVWScmq.js.map → Chat-BMUjPklt.js.map} +1 -1
  241. package/studio/web/assets/{FloatingChat-UZ2NsUOZ.js → FloatingChat-Cbghuxe5.js} +5 -5
  242. package/studio/web/assets/{FloatingChat-UZ2NsUOZ.js.map → FloatingChat-Cbghuxe5.js.map} +1 -1
  243. package/studio/web/assets/{apiToken-BUI_04o7.js → apiToken-CkgTQAqc.js} +2 -2
  244. package/studio/web/assets/{apiToken-BUI_04o7.js.map → apiToken-CkgTQAqc.js.map} +1 -1
  245. package/studio/web/assets/{applications-D03NA7wW.js → applications-WvF4NOTN.js} +13 -13
  246. package/studio/web/assets/applications-WvF4NOTN.js.map +1 -0
  247. package/studio/web/assets/chevron-right-anDJCGlp.js +2 -0
  248. package/studio/web/assets/chevron-right-anDJCGlp.js.map +1 -0
  249. package/studio/web/assets/index-CDeqVx71.css +1 -0
  250. package/studio/web/assets/index-Sevt3MVx.js +819 -0
  251. package/studio/web/assets/index-Sevt3MVx.js.map +1 -0
  252. package/studio/web/assets/index.lazy-Dg5YYPGg.js +14 -0
  253. package/studio/web/assets/{index.lazy-Dx3MpyDC.js.map → index.lazy-Dg5YYPGg.js.map} +1 -1
  254. package/studio/web/assets/{jsonMode-Cjj7dtlr.js → jsonMode-CBSEleE7.js} +2 -2
  255. package/studio/web/assets/{jsonMode-Cjj7dtlr.js.map → jsonMode-CBSEleE7.js.map} +1 -1
  256. package/studio/web/assets/languageServices-gxedeeTo.js +2 -0
  257. package/studio/web/assets/languageServices-gxedeeTo.js.map +1 -0
  258. package/studio/web/assets/{notifications-0edoFTsb.js → notifications-Ap84YyQp.js} +2 -2
  259. package/studio/web/assets/{notifications-0edoFTsb.js.map → notifications-Ap84YyQp.js.map} +1 -1
  260. package/studio/web/assets/notifications-vehqyOLM.js +2 -0
  261. package/studio/web/assets/{notifications-CwKhipK7.js.map → notifications-vehqyOLM.js.map} +1 -1
  262. package/studio/web/assets/{pollUnlessForbidden-HHdQZW1N.js → pollUnlessForbidden-BhzefVRn.js} +2 -2
  263. package/studio/web/assets/pollUnlessForbidden-BhzefVRn.js.map +1 -0
  264. package/studio/web/assets/{profile-DUfEPQtx.js → profile-DzDpRLHY.js} +2 -2
  265. package/studio/web/assets/{profile-DUfEPQtx.js.map → profile-DzDpRLHY.js.map} +1 -1
  266. package/studio/web/assets/queryClient-CgtkoiCY.js +2 -0
  267. package/studio/web/assets/queryClient-CgtkoiCY.js.map +1 -0
  268. package/studio/web/assets/regions-DpJ8s7iN.js +2 -0
  269. package/studio/web/assets/regions-DpJ8s7iN.js.map +1 -0
  270. package/studio/web/assets/register-BDy01-q3.js +3 -0
  271. package/studio/web/assets/register-BDy01-q3.js.map +1 -0
  272. package/studio/web/assets/register-CQHRwNP4.js +3 -0
  273. package/studio/web/assets/register-CQHRwNP4.js.map +1 -0
  274. package/studio/web/assets/{setComponentFile-DMPo4UjC.js → setComponentFile-D0aPcI3W.js} +2 -2
  275. package/studio/web/assets/{setComponentFile-DMPo4UjC.js.map → setComponentFile-D0aPcI3W.js.map} +1 -1
  276. package/studio/web/assets/setup-I15FAF9H.js +3 -0
  277. package/studio/web/assets/{setup-B56Oz1_u.js.map → setup-I15FAF9H.js.map} +1 -1
  278. package/studio/web/assets/status-BF1eJshJ.js +62 -0
  279. package/studio/web/assets/status-BF1eJshJ.js.map +1 -0
  280. package/studio/web/assets/{swagger-ui-react-lQrBxfwM.js → swagger-ui-react-DwSPXXhl.js} +2 -2
  281. package/studio/web/assets/{swagger-ui-react-lQrBxfwM.js.map → swagger-ui-react-DwSPXXhl.js.map} +1 -1
  282. package/studio/web/assets/{textarea-DhLMZ3PA.js → table-DpOjNQHt.js} +2 -10
  283. package/studio/web/assets/table-DpOjNQHt.js.map +1 -0
  284. package/studio/web/assets/{tsMode-CrHCRjTK.js → tsMode-Bu51A-VG.js} +2 -2
  285. package/studio/web/assets/{tsMode-CrHCRjTK.js.map → tsMode-Bu51A-VG.js.map} +1 -1
  286. package/studio/web/assets/{useEntityRestURL-DoaBMEvU.js → useEntityRestURL-DrV1zMrN.js} +2 -2
  287. package/studio/web/assets/{useEntityRestURL-DoaBMEvU.js.map → useEntityRestURL-DrV1zMrN.js.map} +1 -1
  288. package/studio/web/index.html +6 -7
  289. package/upgrade/upgradePrompt.ts +22 -3
  290. package/utility/environment/environmentManager.ts +152 -0
  291. package/utility/errors/commonErrors.ts +2 -0
  292. package/utility/hdbTerms.ts +2 -0
  293. package/utility/install/installer.ts +37 -0
  294. package/utility/lmdb/environmentUtility.ts +2 -1
  295. package/utility/logging/harper_logger.ts +57 -26
  296. package/studio/web/assets/applications-D03NA7wW.js.map +0 -1
  297. package/studio/web/assets/index-Bh_CNAHr.js +0 -809
  298. package/studio/web/assets/index-Bh_CNAHr.js.map +0 -1
  299. package/studio/web/assets/index-C8Mv9kHV.css +0 -1
  300. package/studio/web/assets/index.lazy-Dx3MpyDC.js +0 -14
  301. package/studio/web/assets/notifications-CwKhipK7.js +0 -2
  302. package/studio/web/assets/pollUnlessForbidden-HHdQZW1N.js.map +0 -1
  303. package/studio/web/assets/queryClient-CbA8wM7J.js +0 -2
  304. package/studio/web/assets/queryClient-CbA8wM7J.js.map +0 -1
  305. package/studio/web/assets/setup-B56Oz1_u.js +0 -3
  306. package/studio/web/assets/status-BAod7p3o.js +0 -62
  307. package/studio/web/assets/status-BAod7p3o.js.map +0 -1
  308. package/studio/web/assets/textarea-DhLMZ3PA.js.map +0 -1
@@ -1,7 +1,8 @@
1
1
  import { type Logger } from '../utility/logging/logger.ts';
2
2
  import { loggerWithTag } from '../utility/logging/harper_logger.ts';
3
+ import { createHash } from 'node:crypto';
3
4
  import type { Stats } from 'node:fs';
4
- import { EventEmitter, once } from 'node:events';
5
+ import { EventEmitter } from 'node:events';
5
6
  import { Component, FileAndURLPathConfig } from './Component.ts';
6
7
  import chokidar, { FSWatcher, FSWatcherEventMap } from 'chokidar';
7
8
  import { join } from 'node:path';
@@ -74,25 +75,39 @@ export type EntryHandlerEventMap = {
74
75
  unlinkDir: [entry: UnlinkDirectoryEvent];
75
76
  };
76
77
 
78
+ type EntrySnapshot =
79
+ { entryType: 'file'; urlPath: string; digest: Buffer } | { entryType: 'directory'; urlPath: string };
80
+
81
+ type RedeployScan = {
82
+ generation: number;
83
+ previousEntries: Map<string, EntrySnapshot>;
84
+ currentEntries: Map<string, EntrySnapshot>;
85
+ removalsEmitted: Set<string>;
86
+ readFailures: Map<string, unknown>;
87
+ };
88
+
77
89
  export class EntryHandler extends EventEmitter<EntryHandlerEventMap> {
78
90
  #component: Component;
79
91
  #watcher?: FSWatcher;
80
92
  #logger: Logger;
81
93
  #pendingFileReads: Set<Promise<void>>;
94
+ #pendingFileReadsByGeneration: Map<number, Set<Promise<void>>>;
82
95
  #isInitialScanComplete: boolean;
83
- // When true, #watch() short-circuits without creating a chokidar watcher.
84
- // pause() sets it, resume() clears it. Lets a deploy quiesce the watcher
85
- // without losing the EntryHandler instance (and therefore listener
86
- // attachments registered by plugins via scope.handleEntry(handler)).
87
- #paused: boolean = false;
88
- // Tracks the in-flight close() promise from pause() so resume() can await
89
- // the old watcher's inotify handles releasing before installing a fresh
90
- // chokidar instance — otherwise a rapid pause→resume can overlap teardown
91
- // and setup, which under inotify pressure can produce spurious EMFILE.
96
+ #readyPending = false;
97
+ #readyGeneration = 0;
98
+ #watchGeneration = 0;
99
+ #eventSequence = 0;
100
+ #latestPathSequence = new Map<string, number>();
101
+ #entries = new Map<string, EntrySnapshot>();
102
+ #redeployScan?: RedeployScan;
103
+ #paused = false;
92
104
  #pausedClose?: Promise<void>;
93
- #usingPolling: boolean = false;
94
- #closed: boolean = false;
95
- #openCount: number = 0;
105
+ // Serializes replacements so concurrent update/recovery calls cannot orphan an intervening watcher.
106
+ #watchInstall?: Promise<void>;
107
+ #usingPolling = false;
108
+ #closed = false;
109
+ #openCount = 0;
110
+ #liveWatchers = new Set<FSWatcher>();
96
111
  ready: Promise<any[]>;
97
112
 
98
113
  constructor(name: string, directory: string, config: FilesOption | FileAndURLPathConfig, logger?: Logger) {
@@ -101,9 +116,40 @@ export class EntryHandler extends EventEmitter<EntryHandlerEventMap> {
101
116
  this.#component = new Component(name, directory, castConfig(config));
102
117
  this.#logger = logger || loggerWithTag(name);
103
118
  this.#pendingFileReads = new Set();
119
+ this.#pendingFileReadsByGeneration = new Map();
104
120
  this.#isInitialScanComplete = false;
105
- this.ready = once(this, 'ready');
106
- this.#watch();
121
+ this.ready = this.#createReadyLatch();
122
+ this.#watch().catch(() => {});
123
+ }
124
+
125
+ #createReadyLatch(): Promise<any[]> {
126
+ if (this.#readyPending) return this.ready;
127
+ this.#readyPending = true;
128
+ const ready = new Promise<any[]>((resolve, reject) => {
129
+ const cleanup = () => {
130
+ this.#readyPending = false;
131
+ this.off('ready', handleReady);
132
+ this.off('error', handleError);
133
+ this.off('close', handleClose);
134
+ };
135
+ const handleReady = () => {
136
+ cleanup();
137
+ resolve([]);
138
+ };
139
+ const handleError = (error: unknown) => {
140
+ cleanup();
141
+ reject(error);
142
+ };
143
+ const handleClose = () => {
144
+ cleanup();
145
+ reject(new Error(`Entry handler for ${this.name} closed before becoming ready`));
146
+ };
147
+ this.once('ready', handleReady);
148
+ this.once('error', handleError);
149
+ this.once('close', handleClose);
150
+ });
151
+ void ready.catch(() => {});
152
+ return ready;
107
153
  }
108
154
 
109
155
  get name(): string {
@@ -114,7 +160,8 @@ export class EntryHandler extends EventEmitter<EntryHandlerEventMap> {
114
160
  return this.#component.directory;
115
161
  }
116
162
 
117
- #handleAll(...[event, path, stats]: FSWatcherEventMap['all']): void {
163
+ #handleAll(generation: number, ...[event, path, stats]: FSWatcherEventMap['all']): void {
164
+ if (generation !== this.#watchGeneration) return;
118
165
  if (path === '') path = '/';
119
166
 
120
167
  if (!isMatch(path, this.#component.globOptions.source, { ignore: this.#component.globOptions.ignore })) return;
@@ -124,26 +171,7 @@ export class EntryHandler extends EventEmitter<EntryHandlerEventMap> {
124
171
  switch (event) {
125
172
  case 'add':
126
173
  case 'change': {
127
- const urlPath = deriveURLPath(this.#component, path, 'file');
128
- const fileReadPromise = readFile(absolutePath)
129
- .then((contents) => {
130
- const entry: AddFileEvent | ChangeFileEvent = {
131
- eventType: event,
132
- entryType: 'file' as const,
133
- contents,
134
- stats,
135
- absolutePath,
136
- urlPath,
137
- };
138
- this.emit('all', entry);
139
- this.emit(event, entry as any);
140
- })
141
- .finally(() => {
142
- this.#pendingFileReads.delete(fileReadPromise);
143
- this.#checkIfAllComplete();
144
- });
145
-
146
- this.#pendingFileReads.add(fileReadPromise);
174
+ this.#queueFileRead(generation, event, path, absolutePath, stats, readFile(absolutePath));
147
175
  break;
148
176
  }
149
177
  case 'unlink': {
@@ -155,28 +183,214 @@ export class EntryHandler extends EventEmitter<EntryHandlerEventMap> {
155
183
  absolutePath,
156
184
  urlPath,
157
185
  };
158
- this.emit('all', entry);
159
- this.emit(event, entry);
186
+ this.#handleRemovedEntry(generation, entry);
160
187
  break;
161
188
  }
162
189
  case 'addDir':
163
190
  case 'unlinkDir': {
164
191
  const urlPath = deriveURLPath(this.#component, path, 'directory');
165
- const entry: DirectoryEntryEvent = {
166
- eventType: event,
167
- entryType: 'directory' as const,
168
- stats,
169
- absolutePath,
170
- urlPath,
171
- };
172
- this.emit('all', entry);
173
- this.emit(event, entry as any);
192
+ if (event === 'addDir') {
193
+ const entry: AddDirectoryEvent = {
194
+ eventType: event,
195
+ entryType: 'directory',
196
+ stats,
197
+ absolutePath,
198
+ urlPath,
199
+ };
200
+ this.#handleAddedEntry(generation, entry, this.#snapshot(entry));
201
+ } else {
202
+ const entry: UnlinkDirectoryEvent = {
203
+ eventType: event,
204
+ entryType: 'directory',
205
+ stats,
206
+ absolutePath,
207
+ urlPath,
208
+ };
209
+ this.#handleRemovedEntry(generation, entry);
210
+ }
174
211
  break;
175
212
  }
176
213
  }
177
214
  }
178
215
 
179
- #handleError(error: unknown): void {
216
+ #queueFileRead(
217
+ generation: number,
218
+ event: 'add' | 'change',
219
+ path: string,
220
+ absolutePath: string,
221
+ stats: Stats | undefined,
222
+ contentsPromise: Promise<Buffer>
223
+ ): Promise<void> {
224
+ const sequence = ++this.#eventSequence;
225
+ this.#latestPathSequence.set(absolutePath, sequence);
226
+
227
+ const fileReadPromise = contentsPromise
228
+ .then(
229
+ (contents) => {
230
+ if (generation !== this.#watchGeneration || this.#latestPathSequence.get(absolutePath) !== sequence) return;
231
+ if (this.#redeployScan?.generation === generation) this.#redeployScan.readFailures.delete(absolutePath);
232
+ const entry: AddFileEvent | ChangeFileEvent = {
233
+ eventType: event,
234
+ entryType: 'file',
235
+ contents,
236
+ stats,
237
+ absolutePath,
238
+ urlPath: deriveURLPath(this.#component, path, 'file'),
239
+ };
240
+ try {
241
+ this.#handleAddedEntry(generation, entry, this.#snapshot(entry));
242
+ } catch (error) {
243
+ this.#reportConsumerError(error);
244
+ }
245
+ },
246
+ (error) => {
247
+ if (
248
+ generation !== this.#watchGeneration ||
249
+ this.#latestPathSequence.get(absolutePath) !== sequence ||
250
+ (error as NodeJS.ErrnoException | null | undefined)?.code === 'ENOENT'
251
+ )
252
+ return;
253
+ if (this.#redeployScan?.generation === generation) {
254
+ this.#redeployScan.readFailures.set(absolutePath, error);
255
+ } else {
256
+ this.#handleWatcherError(error);
257
+ }
258
+ }
259
+ )
260
+ .finally(() => {
261
+ if (this.#latestPathSequence.get(absolutePath) === sequence) this.#latestPathSequence.delete(absolutePath);
262
+ this.#pendingFileReads.delete(fileReadPromise);
263
+ const generationReads = this.#pendingFileReadsByGeneration.get(generation);
264
+ generationReads?.delete(fileReadPromise);
265
+ if (generationReads?.size === 0) this.#pendingFileReadsByGeneration.delete(generation);
266
+ this.#checkIfAllComplete(generation);
267
+ });
268
+
269
+ this.#pendingFileReads.add(fileReadPromise);
270
+ let generationReads = this.#pendingFileReadsByGeneration.get(generation);
271
+ if (!generationReads) this.#pendingFileReadsByGeneration.set(generation, (generationReads = new Set()));
272
+ generationReads.add(fileReadPromise);
273
+ return fileReadPromise;
274
+ }
275
+
276
+ #snapshot(entry: AddFileEvent | ChangeFileEvent | AddDirectoryEvent): EntrySnapshot {
277
+ if (entry.entryType === 'file')
278
+ return {
279
+ entryType: entry.entryType,
280
+ urlPath: entry.urlPath,
281
+ digest: createHash('sha256').update(entry.contents).digest(),
282
+ };
283
+ return { entryType: entry.entryType, urlPath: entry.urlPath };
284
+ }
285
+
286
+ #handleAddedEntry(
287
+ generation: number,
288
+ entry: AddFileEvent | ChangeFileEvent | AddDirectoryEvent,
289
+ snapshot: EntrySnapshot
290
+ ): void {
291
+ if (generation !== this.#watchGeneration) return;
292
+ const scan = this.#redeployScan?.generation === generation ? this.#redeployScan : undefined;
293
+ if (!scan) {
294
+ const wasKnown = this.#entries.has(entry.absolutePath);
295
+ this.#entries.set(entry.absolutePath, snapshot);
296
+ // A newer change read can supersede an initial add read for the same path. The consumer still
297
+ // needs its first event to be an add so it can load the entry before handling later changes.
298
+ if (entry.entryType === 'file' && entry.eventType === 'change' && !wasKnown)
299
+ this.#emitEntrySafely({ ...entry, eventType: 'add' });
300
+ else this.#emitEntrySafely(entry);
301
+ return;
302
+ }
303
+
304
+ const wasRemovedDuringScan = scan.removalsEmitted.delete(entry.absolutePath);
305
+ const currentEntry = scan.currentEntries.get(entry.absolutePath);
306
+ const previousEntry = wasRemovedDuringScan
307
+ ? undefined
308
+ : (currentEntry ?? scan.previousEntries.get(entry.absolutePath));
309
+ scan.currentEntries.set(entry.absolutePath, snapshot);
310
+
311
+ if (!previousEntry) {
312
+ this.#emitAddition(entry);
313
+ return;
314
+ }
315
+ if (previousEntry.entryType !== snapshot.entryType || previousEntry.urlPath !== snapshot.urlPath) {
316
+ try {
317
+ this.#emitRemoval(previousEntry, entry.absolutePath);
318
+ } catch (error) {
319
+ this.#reportConsumerError(error);
320
+ }
321
+ this.#emitAddition(entry);
322
+ return;
323
+ }
324
+ if (
325
+ snapshot.entryType === 'file' &&
326
+ previousEntry.entryType === 'file' &&
327
+ !previousEntry.digest.equals(snapshot.digest)
328
+ ) {
329
+ this.#emitEntrySafely({ ...entry, eventType: 'change' } as ChangeFileEvent);
330
+ }
331
+ }
332
+
333
+ #emitAddition(entry: AddFileEvent | ChangeFileEvent | AddDirectoryEvent): void {
334
+ if (entry.entryType === 'file') this.#emitEntrySafely({ ...entry, eventType: 'add' });
335
+ else this.#emitEntrySafely({ ...entry, eventType: 'addDir' });
336
+ }
337
+
338
+ #handleRemovedEntry(generation: number, entry: UnlinkFileEvent | UnlinkDirectoryEvent): void {
339
+ if (generation !== this.#watchGeneration) return;
340
+ this.#latestPathSequence.delete(entry.absolutePath);
341
+ const scan = this.#redeployScan?.generation === generation ? this.#redeployScan : undefined;
342
+ if (!scan) {
343
+ this.#entries.delete(entry.absolutePath);
344
+ this.#emitEntrySafely(entry);
345
+ return;
346
+ }
347
+
348
+ const knownEntry = scan.currentEntries.get(entry.absolutePath) ?? scan.previousEntries.get(entry.absolutePath);
349
+ scan.currentEntries.delete(entry.absolutePath);
350
+ if (!knownEntry || scan.removalsEmitted.has(entry.absolutePath)) return;
351
+ try {
352
+ this.#emitRemoval(knownEntry, entry.absolutePath);
353
+ } catch (error) {
354
+ this.#reportConsumerError(error);
355
+ }
356
+ scan.removalsEmitted.add(entry.absolutePath);
357
+ }
358
+
359
+ #emitRemoval(snapshot: EntrySnapshot, absolutePath: string): void {
360
+ if (snapshot.entryType === 'file')
361
+ this.#emitEntry({ eventType: 'unlink', entryType: 'file', absolutePath, urlPath: snapshot.urlPath });
362
+ else this.#emitEntry({ eventType: 'unlinkDir', entryType: 'directory', absolutePath, urlPath: snapshot.urlPath });
363
+ }
364
+
365
+ #emitEntry(entry: FileEntryEvent | DirectoryEntryEvent): void {
366
+ this.emit('all', entry);
367
+ switch (entry.eventType) {
368
+ case 'add':
369
+ this.emit('add', entry);
370
+ break;
371
+ case 'change':
372
+ this.emit('change', entry);
373
+ break;
374
+ case 'unlink':
375
+ this.emit('unlink', entry);
376
+ break;
377
+ case 'addDir':
378
+ this.emit('addDir', entry);
379
+ break;
380
+ case 'unlinkDir':
381
+ this.emit('unlinkDir', entry);
382
+ }
383
+ }
384
+
385
+ #emitEntrySafely(entry: FileEntryEvent | DirectoryEntryEvent): void {
386
+ try {
387
+ this.#emitEntry(entry);
388
+ } catch (error) {
389
+ this.#reportConsumerError(error);
390
+ }
391
+ }
392
+
393
+ #handleWatcherError(error: unknown): void {
180
394
  if (isWatcherExhaustionError(error)) {
181
395
  // Swallow every exhaustion error — chokidar can emit several before the
182
396
  // failed native watcher closes, and we don't want a flurry of ENOSPC to
@@ -196,55 +410,136 @@ export class EntryHandler extends EventEmitter<EntryHandlerEventMap> {
196
410
  }
197
411
  return;
198
412
  }
199
- this.emit('error', error);
413
+ this.#reportConsumerError(error);
414
+ }
415
+
416
+ #reportConsumerError(error: unknown): void {
417
+ if (this.listenerCount('error') > 0) this.emit('error', error);
418
+ else this.#logger.error?.('Error in entry listener:', error);
200
419
  }
201
420
 
202
- #handleReady(): void {
421
+ #handleReady(generation: number): void {
422
+ if (generation !== this.#watchGeneration) return;
203
423
  this.#isInitialScanComplete = true;
204
- if (this.#pendingFileReads.size > 0) {
205
- this.#logger.debug?.(
206
- `Initial scan complete, still waiting for ${this.#pendingFileReads.size} pending file reads`
207
- );
424
+ const pendingReads = this.#pendingFileReadsByGeneration.get(generation)?.size ?? 0;
425
+ if (pendingReads > 0) {
426
+ this.#logger.debug?.(`Initial scan complete, still waiting for ${pendingReads} pending file reads`);
208
427
  }
209
- this.#checkIfAllComplete();
428
+ this.#checkIfAllComplete(generation);
210
429
  }
211
430
 
212
- #checkIfAllComplete(): void {
213
- // Only emit 'ready' once the initial scan is complete AND all file reads are done
214
- if (this.#isInitialScanComplete && this.#pendingFileReads.size === 0) {
431
+ #checkIfAllComplete(generation: number): void {
432
+ if (
433
+ generation === this.#watchGeneration &&
434
+ this.#readyGeneration !== generation &&
435
+ this.#isInitialScanComplete &&
436
+ !this.#pendingFileReadsByGeneration.has(generation)
437
+ ) {
438
+ this.#readyGeneration = generation;
439
+ const { listenerErrors, readErrors } = this.#finishRedeployScan(generation);
215
440
  this.emit('ready');
441
+ // The readiness latch rejects if `error` is emitted first. Surface consumer failures only
442
+ // after resolving the generation's ready latch so one bad removal listener cannot strand startup.
443
+ for (const error of listenerErrors) this.#reportConsumerError(error);
444
+ for (const error of readErrors) this.#handleWatcherError(error);
445
+ }
446
+ }
447
+
448
+ #finishRedeployScan(generation: number): { listenerErrors: unknown[]; readErrors: unknown[] } {
449
+ const scan = this.#redeployScan;
450
+ if (!scan || scan.generation !== generation) return { listenerErrors: [], readErrors: [] };
451
+ const listenerErrors: unknown[] = [];
452
+ for (const absolutePath of scan.readFailures.keys()) {
453
+ const previousEntry = scan.previousEntries.get(absolutePath);
454
+ if (previousEntry && !scan.currentEntries.has(absolutePath)) scan.currentEntries.set(absolutePath, previousEntry);
455
+ }
456
+ const missingEntries = [...scan.previousEntries].filter(
457
+ ([absolutePath]) => !scan.currentEntries.has(absolutePath) && !scan.removalsEmitted.has(absolutePath)
458
+ );
459
+ // Match watcher deletion semantics: descendants disappear before the directory that contained them.
460
+ missingEntries.sort(([a], [b]) => b.length - a.length);
461
+ // Commit the generation before invoking consumer code. A synchronously throwing removal listener must
462
+ // not leave the old snapshot installed or keep the readiness latch permanently unresolved.
463
+ this.#entries = scan.currentEntries;
464
+ this.#redeployScan = undefined;
465
+ for (const [absolutePath, snapshot] of missingEntries) {
466
+ try {
467
+ this.#emitRemoval(snapshot, absolutePath);
468
+ } catch (error) {
469
+ listenerErrors.push(error);
470
+ }
216
471
  }
472
+ return { listenerErrors, readErrors: [...scan.readFailures.values()] };
473
+ }
474
+
475
+ #observedEntries(): Map<string, EntrySnapshot> {
476
+ const scan = this.#redeployScan;
477
+ if (!scan) return this.#entries;
478
+ // A generation can be replaced before `ready`, after some events have already reached consumers.
479
+ // Carry exactly that observed state forward: retain previously known paths that this partial scan
480
+ // has not reached, apply additions/changes it emitted, and remove paths whose unlink was emitted.
481
+ const observedEntries = new Map(scan.previousEntries);
482
+ for (const absolutePath of scan.removalsEmitted) observedEntries.delete(absolutePath);
483
+ for (const [absolutePath, snapshot] of scan.currentEntries) observedEntries.set(absolutePath, snapshot);
484
+ return observedEntries;
217
485
  }
218
486
 
219
487
  async #watch() {
220
- // If pause() retained an in-flight close, wait for it to release inotify
221
- // handles before we install a new watcher. Otherwise a fast pause→resume
222
- // can overlap teardown and setup under inotify pressure.
488
+ const install = this.#watchInstall ? this.#watchInstall.then(() => this.#installWatcher()) : this.#installWatcher();
489
+ const settled: Promise<void> = install.then(
490
+ () => {
491
+ if (this.#watchInstall === settled) this.#watchInstall = undefined;
492
+ },
493
+ () => {
494
+ if (this.#watchInstall === settled) this.#watchInstall = undefined;
495
+ }
496
+ );
497
+ this.#watchInstall = settled;
498
+ await install;
499
+
500
+ return this.ready;
501
+ }
502
+
503
+ async #installWatcher(): Promise<void> {
223
504
  if (this.#pausedClose) {
224
505
  await this.#pausedClose;
225
506
  this.#pausedClose = undefined;
226
507
  }
227
508
 
228
- await this.#watcher?.close();
229
- this.#watcher = undefined;
509
+ const outgoing = this.#watcher;
510
+ if (outgoing) {
511
+ // Stays installed across the await so a close() landing in the gap still includes this
512
+ // teardown in its own settle set; only clear it if close() didn't already replace it.
513
+ try {
514
+ await outgoing.close();
515
+ } catch (error) {
516
+ this.#logger.warn?.(`Failed to close the previous watcher for ${this.name}; replacing it anyway:`, error);
517
+ } finally {
518
+ this.#liveWatchers.delete(outgoing);
519
+ }
520
+ if (this.#watcher === outgoing) this.#watcher = undefined;
521
+ }
230
522
 
231
- // If close() landed while a previous close()/recreate was awaiting, don't
232
- // install a fresh watcher — it would outlive the EntryHandler.
233
- if (this.#closed) return this.ready;
523
+ if (this.#closed) return;
234
524
 
235
- // pause() may have landed in the gap before our async close resolved.
236
- // If so, do not install a replacement watcher — resume() will.
237
- if (this.#paused) return this.ready;
525
+ if (this.#paused) return;
526
+ const generation = ++this.#watchGeneration;
238
527
 
239
- // When a fresh watcher is installed (after pause+resume, or update), the
240
- // initial scan emits add events anew, so reset the readiness latch so
241
- // `ready` resolves after the new scan completes.
242
528
  this.#isInitialScanComplete = false;
529
+ const previousEntries = this.#observedEntries();
530
+ this.#entries = previousEntries;
531
+ this.#redeployScan = {
532
+ generation,
533
+ previousEntries,
534
+ currentEntries: new Map(),
535
+ removalsEmitted: new Set(),
536
+ readFailures: new Map(),
537
+ };
243
538
 
244
539
  const allowedBases = this.#component.patternBases.map((base) => join(this.#component.directory, base));
245
540
 
246
541
  this.#openCount++;
247
- this.#watcher = chokidar
542
+ const watcher = (this.#watcher = chokidar
248
543
  .watch(this.#component.commonPatternBase, {
249
544
  cwd: this.#component.directory,
250
545
  persistent: false,
@@ -280,39 +575,48 @@ export class EntryHandler extends EventEmitter<EntryHandlerEventMap> {
280
575
  );
281
576
  },
282
577
  })
283
- .on('all', this.#handleAll.bind(this))
284
- .on('error', this.#handleError.bind(this))
285
- .on('ready', this.#handleReady.bind(this));
286
-
287
- return this.ready;
578
+ .on('all', (...args) => this.#handleAll(generation, ...args))
579
+ .on('error', (error) => {
580
+ if (generation === this.#watchGeneration) this.#handleWatcherError(error);
581
+ })
582
+ .on('ready', () => this.#handleReady(generation)));
583
+ this.#liveWatchers.add(watcher);
288
584
  }
289
585
 
290
- // Test-only: simulate the underlying chokidar watcher emitting an error.
291
- // Exposed so the polling-fallback path can be exercised without triggering a
292
- // real ENOSPC/EMFILE on the host.
293
586
  _simulateWatcherErrorForTests(error: unknown): void {
294
- this.#handleError(error);
587
+ this.#handleWatcherError(error);
588
+ }
589
+
590
+ _simulateFileReadForTests(event: 'add' | 'change', path: string, contents: Promise<Buffer>): Promise<void> {
591
+ const absolutePath = join(this.directory, path);
592
+ return this.#queueFileRead(this.#watchGeneration, event, path, absolutePath, undefined, contents);
295
593
  }
296
594
 
297
- // Test-only: whether the watcher has fallen back to polling.
298
595
  get _usingPollingForTests(): boolean {
299
596
  return this.#usingPolling;
300
597
  }
301
598
 
302
- // Test-only: number of times the underlying watcher has been (re)opened.
303
- // Used to assert that a close()-during-fallback race didn't install a
304
- // replacement watcher.
305
599
  get _openCountForTests(): number {
306
600
  return this.#openCount;
307
601
  }
308
602
 
603
+ get _liveWatcherCountForTests(): number {
604
+ return this.#liveWatchers.size;
605
+ }
606
+
309
607
  close(): Promise<this> {
608
+ this.#watchGeneration++;
310
609
  this.#closed = true;
311
610
  const pendingReads = [...this.#pendingFileReads];
312
- const watcherClose = this.#watcher ? Promise.resolve(this.#watcher.close()).catch(() => {}) : Promise.resolve();
611
+ const outgoing = this.#watcher;
612
+ const watcherClose = outgoing
613
+ ? Promise.resolve(outgoing.close())
614
+ .catch(() => {})
615
+ .then(() => {
616
+ this.#liveWatchers.delete(outgoing);
617
+ })
618
+ : Promise.resolve();
313
619
  this.#watcher = undefined;
314
- // If paused, there may be an in-flight close from pause() that hasn't settled yet.
315
- // Include it so close() doesn't resolve while inotify handles are still releasing.
316
620
  const pausedClose = this.#pausedClose ?? Promise.resolve();
317
621
  this.#pausedClose = undefined;
318
622
 
@@ -332,41 +636,42 @@ export class EntryHandler extends EventEmitter<EntryHandlerEventMap> {
332
636
  * Idempotent. Awaiting `ready` while paused will not resolve until resume().
333
637
  */
334
638
  pause(): void {
639
+ if (this.#paused || this.#closed) return;
640
+ this.#watchGeneration++;
335
641
  this.#paused = true;
336
- // Reset `ready` to a fresh pending promise so the documented "awaiting
337
- // ready while paused will not resolve until resume()" contract holds even
338
- // when the watcher had already become ready before pause(). The next
339
- // 'ready' emit will come from the chokidar instance installed by resume().
340
- this.ready = once(this, 'ready');
642
+ this.ready = this.#createReadyLatch();
341
643
  if (this.#watcher) {
342
- // Retain the close promise so resume()→#watch() can await full
343
- // teardown before opening a new watcher.
344
- this.#pausedClose = Promise.resolve(this.#watcher.close()).catch(() => {
345
- // Teardown errors aren't actionable; swallow so resume can proceed.
346
- });
644
+ const outgoing = this.#watcher;
645
+ this.#pausedClose = Promise.resolve(outgoing.close())
646
+ .catch(() => {})
647
+ .then(() => {
648
+ this.#liveWatchers.delete(outgoing);
649
+ });
347
650
  this.#watcher = undefined;
348
651
  }
349
652
  }
350
653
 
351
654
  /**
352
655
  * Reinstate the watcher previously stopped by pause(). The fresh chokidar
353
- * instance does an initial scan and emits add events for every file
354
- * currently matching the configured globs by design, since the typical
355
- * caller (Scope, on deploy:end) wants plugins to see the post-deploy tree
356
- * as if loading cold.
656
+ * instance scans the post-pause tree, compares it with the retained snapshot,
657
+ * and emits only the logical add, change, unlink, addDir, and unlinkDir events.
357
658
  *
358
659
  * No-op if not currently paused.
359
660
  */
360
661
  resume(): Promise<any[]> {
361
662
  if (!this.#paused) return this.ready;
362
663
  this.#paused = false;
363
- // `this.ready` was already reset to a pending promise in pause(); just
364
- // trigger the watcher recreation and let its 'ready' emit resolve it.
365
664
  return this.#watch();
366
665
  }
367
666
 
368
667
  update(config: FilesOption | FileAndURLPathConfig) {
668
+ if (this.#closed) return this.ready;
669
+ this.#watchGeneration++;
369
670
  this.#component = new Component(this.name, this.directory, castConfig(config));
671
+ // Re-arm the readiness latch for parity with pause(). Without this, awaiting update() on an
672
+ // already-ready handler resolves against the outgoing generation's 'ready' — before the
673
+ // replacement generation has scanned, digested, and emitted its logical events.
674
+ this.ready = this.#createReadyLatch();
370
675
 
371
676
  return this.#watch();
372
677
  }