sourcemonkey 0.0.0-stage → 0.5.0

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 (367) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -2
  3. package/dist/bytes.js +37 -0
  4. package/dist/channel-placeholder.js +5 -0
  5. package/dist/collection-scan.js +17 -0
  6. package/dist/collections.html +353 -0
  7. package/dist/compare.html +358 -0
  8. package/dist/composed-source.js +205 -0
  9. package/dist/connect-prompt.html +47 -0
  10. package/dist/debug/icons.html +211 -0
  11. package/dist/debug/storage.html +163 -0
  12. package/dist/debug-log.js +75 -0
  13. package/dist/diagnostic-location.js +64 -0
  14. package/dist/eval-hook.js +308 -0
  15. package/dist/extension/background.js +5263 -0
  16. package/dist/extension/channel.js +133 -0
  17. package/dist/extension/code-links.js +187 -0
  18. package/dist/extension/code-tabs.js +460 -0
  19. package/dist/extension/collection-dialog.js +619 -0
  20. package/dist/extension/collection-display.js +322 -0
  21. package/dist/extension/collections.js +2905 -0
  22. package/dist/extension/compare.js +867 -0
  23. package/dist/extension/concurrency.js +186 -0
  24. package/dist/extension/connect-grants.js +109 -0
  25. package/dist/extension/connect-prompt-page.js +70 -0
  26. package/dist/extension/connect-prompt.js +266 -0
  27. package/dist/extension/copy-button.js +63 -0
  28. package/dist/extension/copy-dialog.js +502 -0
  29. package/dist/extension/copy-preference.js +66 -0
  30. package/dist/extension/debug/icons.js +203 -0
  31. package/dist/extension/debug/storage.js +365 -0
  32. package/dist/extension/deep-link.js +77 -0
  33. package/dist/extension/delete-dialog.js +432 -0
  34. package/dist/extension/delete-icon.js +40 -0
  35. package/dist/extension/details-tooltips.js +66 -0
  36. package/dist/extension/directories.js +679 -0
  37. package/dist/extension/export-backup.js +407 -0
  38. package/dist/extension/export-dialog.js +142 -0
  39. package/dist/extension/file-access.js +74 -0
  40. package/dist/extension/file-changes.js +346 -0
  41. package/dist/extension/frame-info.js +85 -0
  42. package/dist/extension/github-provider.js +336 -0
  43. package/dist/extension/glyph.js +84 -0
  44. package/dist/extension/gm-bindings.js +601 -0
  45. package/dist/extension/gm-download.js +419 -0
  46. package/dist/extension/gm-menu.js +228 -0
  47. package/dist/extension/gm-notification.js +141 -0
  48. package/dist/extension/gm-originator-map.js +23 -0
  49. package/dist/extension/gm-tab-data.js +121 -0
  50. package/dist/extension/gm-tab.js +160 -0
  51. package/dist/extension/gm-url-change.js +111 -0
  52. package/dist/extension/gm-url-scheme.js +27 -0
  53. package/dist/extension/gm-values.js +176 -0
  54. package/dist/extension/gm-window-control.js +104 -0
  55. package/dist/extension/gm-xhr-headers.js +333 -0
  56. package/dist/extension/gm-xhr.js +584 -0
  57. package/dist/extension/injector.js +3145 -0
  58. package/dist/extension/install-capture.js +277 -0
  59. package/dist/extension/line-diff.js +233 -0
  60. package/dist/extension/local-copy.js +560 -0
  61. package/dist/extension/local-delete.js +276 -0
  62. package/dist/extension/location-kinds.js +108 -0
  63. package/dist/extension/log-format.js +158 -0
  64. package/dist/extension/managed-changes.js +361 -0
  65. package/dist/extension/managed-items.js +57 -0
  66. package/dist/extension/match-index.js +375 -0
  67. package/dist/extension/missing-scripts.js +66 -0
  68. package/dist/extension/more-menu.js +109 -0
  69. package/dist/extension/move-dialog.js +197 -0
  70. package/dist/extension/offscreen.js +37 -0
  71. package/dist/extension/options.js +504 -0
  72. package/dist/extension/page-heading.js +170 -0
  73. package/dist/extension/page-links.js +199 -0
  74. package/dist/extension/parse-check-client.js +70 -0
  75. package/dist/extension/parse-check.js +161 -0
  76. package/dist/extension/parse-sandbox-host.js +172 -0
  77. package/dist/extension/parse-sandbox.js +175 -0
  78. package/dist/extension/popup-commands-open.js +41 -0
  79. package/dist/extension/popup-view.js +387 -0
  80. package/dist/extension/popup.js +703 -0
  81. package/dist/extension/prism-userscript.js +53 -0
  82. package/dist/extension/refresh-error.js +136 -0
  83. package/dist/extension/refresh-targets.js +160 -0
  84. package/dist/extension/related-versions.js +1144 -0
  85. package/dist/extension/resize-observer-loop.js +19 -0
  86. package/dist/extension/row-cache.js +387 -0
  87. package/dist/extension/script-blobs.js +413 -0
  88. package/dist/extension/script-details.js +658 -0
  89. package/dist/extension/script-enablement.js +285 -0
  90. package/dist/extension/script-identity.js +310 -0
  91. package/dist/extension/script-keys.js +54 -0
  92. package/dist/extension/script-log-id.js +155 -0
  93. package/dist/extension/script-log.js +290 -0
  94. package/dist/extension/script-rejection.js +104 -0
  95. package/dist/extension/script-sites.js +158 -0
  96. package/dist/extension/script-store.js +3507 -0
  97. package/dist/extension/script-updater.js +991 -0
  98. package/dist/extension/script-url-hash.js +61 -0
  99. package/dist/extension/select-all.js +37 -0
  100. package/dist/extension/select-open-guard.js +90 -0
  101. package/dist/extension/site-icon.js +1261 -0
  102. package/dist/extension/sm-dev-link.js +302 -0
  103. package/dist/extension/solo-click.js +58 -0
  104. package/dist/extension/sticky-numbers.js +188 -0
  105. package/dist/extension/storage-breakdown.js +253 -0
  106. package/dist/extension/storage-scan.js +86 -0
  107. package/dist/extension/storage-usage.js +194 -0
  108. package/dist/extension/structural-keys.js +29 -0
  109. package/dist/extension/sw-teardown.js +36 -0
  110. package/dist/extension/tab-status.js +403 -0
  111. package/dist/extension/toolbar-summary.js +105 -0
  112. package/dist/extension/user-scripts-access.js +43 -0
  113. package/dist/extension/userscript.js +3672 -0
  114. package/dist/extension/value-store.js +204 -0
  115. package/dist/extension/version-probe-fetch.js +68 -0
  116. package/dist/extension/worker-client.js +109 -0
  117. package/dist/extension/worker-ops.js +171 -0
  118. package/dist/extension/zip-writer.js +24 -0
  119. package/dist/external-files.js +878 -0
  120. package/dist/file-loader.js +436 -0
  121. package/dist/file-locations.js +439 -0
  122. package/dist/generated-wrappers.js +5 -0
  123. package/dist/github-auth.js +148 -0
  124. package/dist/github-urls.js +46 -0
  125. package/dist/gm-api-catalog.js +217 -0
  126. package/dist/gm-connect.js +273 -0
  127. package/dist/gm-xhr-types.js +5 -0
  128. package/dist/http-read.js +142 -0
  129. package/dist/icons/icon-128.png +0 -0
  130. package/dist/icons/icon-16.png +0 -0
  131. package/dist/icons/icon-48.png +0 -0
  132. package/dist/local-file-overrides.js +558 -0
  133. package/dist/manifest.json +38 -0
  134. package/dist/markup-response.js +126 -0
  135. package/dist/match-targets.js +305 -0
  136. package/dist/menu-captions.js +22 -0
  137. package/dist/offscreen.html +39 -0
  138. package/dist/options.html +197 -0
  139. package/dist/parse-check-protocol.js +51 -0
  140. package/dist/parse-location.js +171 -0
  141. package/dist/parse-sandbox.html +56 -0
  142. package/dist/phase-timing.js +155 -0
  143. package/dist/popup.html +197 -0
  144. package/dist/public-suffixes.js +6957 -0
  145. package/dist/run-context.js +31 -0
  146. package/dist/runtime-messages.js +34 -0
  147. package/dist/sandboxed-page.js +35 -0
  148. package/dist/script-build.js +787 -0
  149. package/dist/script-display.js +383 -0
  150. package/dist/script-icon.js +43 -0
  151. package/dist/script-links.js +204 -0
  152. package/dist/script-source-url.js +214 -0
  153. package/dist/script-world.js +65 -0
  154. package/dist/sm-dev-protocol.js +84 -0
  155. package/dist/source-map.js +177 -0
  156. package/dist/stack-link.js +138 -0
  157. package/dist/styles.css +825 -0
  158. package/dist/unsafe-window-use.js +229 -0
  159. package/dist/url-match.js +734 -0
  160. package/dist/userscript-header.js +248 -0
  161. package/dist/userscript.html +1165 -0
  162. package/dist/vendor/fflate.LICENSE +21 -0
  163. package/dist/vendor/fflate.js +2692 -0
  164. package/dist/vendor/prism/prism-line-numbers.css +1 -0
  165. package/dist/vendor/prism/prism-line-numbers.js +1 -0
  166. package/dist/vendor/prism/prism.css +1 -0
  167. package/dist/vendor/prism/prism.js +1946 -0
  168. package/dist/version-compare.js +141 -0
  169. package/dist/version-probe.js +120 -0
  170. package/lib/build-info.json +1 -0
  171. package/lib/bytes.d.ts +3 -0
  172. package/lib/bytes.js +37 -0
  173. package/lib/channel-placeholder.d.ts +1 -0
  174. package/lib/channel-placeholder.js +5 -0
  175. package/lib/collection-scan.d.ts +8 -0
  176. package/lib/collection-scan.js +17 -0
  177. package/lib/composed-source.d.ts +28 -0
  178. package/lib/composed-source.js +205 -0
  179. package/lib/debug-log.d.ts +9 -0
  180. package/lib/debug-log.js +75 -0
  181. package/lib/diagnostic-location.d.ts +8 -0
  182. package/lib/diagnostic-location.js +64 -0
  183. package/lib/eval-hook.d.ts +34 -0
  184. package/lib/eval-hook.js +308 -0
  185. package/lib/external-files.d.ts +111 -0
  186. package/lib/external-files.js +878 -0
  187. package/lib/file-loader.d.ts +48 -0
  188. package/lib/file-loader.js +436 -0
  189. package/lib/file-locations.d.ts +44 -0
  190. package/lib/file-locations.js +439 -0
  191. package/lib/generated-wrappers.d.ts +11 -0
  192. package/lib/generated-wrappers.js +5 -0
  193. package/lib/github-auth.d.ts +12 -0
  194. package/lib/github-auth.js +148 -0
  195. package/lib/github-urls.d.ts +1 -0
  196. package/lib/github-urls.js +46 -0
  197. package/lib/gm-api-catalog.d.ts +24 -0
  198. package/lib/gm-api-catalog.js +217 -0
  199. package/lib/gm-connect.d.ts +34 -0
  200. package/lib/gm-connect.js +273 -0
  201. package/lib/gm-xhr-types.d.ts +68 -0
  202. package/lib/gm-xhr-types.js +5 -0
  203. package/lib/harness/browser-extensions.d.ts +73 -0
  204. package/lib/harness/browser-extensions.js +285 -0
  205. package/lib/harness/browser-lang.d.ts +20 -0
  206. package/lib/harness/browser-lang.js +86 -0
  207. package/lib/harness/browser-launch.d.ts +14 -0
  208. package/lib/harness/browser-launch.js +184 -0
  209. package/lib/harness/checks.d.ts +110 -0
  210. package/lib/harness/checks.js +321 -0
  211. package/lib/harness/cli.d.ts +2 -0
  212. package/lib/harness/cli.js +2259 -0
  213. package/lib/harness/command-args.d.ts +91 -0
  214. package/lib/harness/command-args.js +369 -0
  215. package/lib/harness/command-lines.d.ts +80 -0
  216. package/lib/harness/command-lines.js +180 -0
  217. package/lib/harness/command-list.d.ts +206 -0
  218. package/lib/harness/command-list.js +1475 -0
  219. package/lib/harness/console-commands.d.ts +463 -0
  220. package/lib/harness/console-commands.js +3216 -0
  221. package/lib/harness/download-watch.d.ts +21 -0
  222. package/lib/harness/download-watch.js +156 -0
  223. package/lib/harness/extension-backend.d.ts +41 -0
  224. package/lib/harness/extension-backend.js +100 -0
  225. package/lib/harness/extension-host.d.ts +112 -0
  226. package/lib/harness/extension-host.js +552 -0
  227. package/lib/harness/extension-launch.d.ts +67 -0
  228. package/lib/harness/extension-launch.js +285 -0
  229. package/lib/harness/extension-link.d.ts +315 -0
  230. package/lib/harness/extension-link.js +607 -0
  231. package/lib/harness/external-manager.d.ts +146 -0
  232. package/lib/harness/external-manager.js +414 -0
  233. package/lib/harness/fixture.d.ts +165 -0
  234. package/lib/harness/fixture.js +461 -0
  235. package/lib/harness/frame-sessions.d.ts +71 -0
  236. package/lib/harness/frame-sessions.js +248 -0
  237. package/lib/harness/frames.d.ts +68 -0
  238. package/lib/harness/frames.js +216 -0
  239. package/lib/harness/help-format.d.ts +17 -0
  240. package/lib/harness/help-format.js +127 -0
  241. package/lib/harness/host.d.ts +352 -0
  242. package/lib/harness/host.js +423 -0
  243. package/lib/harness/index.d.ts +8 -0
  244. package/lib/harness/index.js +10 -0
  245. package/lib/harness/input-file.d.ts +18 -0
  246. package/lib/harness/input-file.js +61 -0
  247. package/lib/harness/json-output.d.ts +565 -0
  248. package/lib/harness/json-output.js +71 -0
  249. package/lib/harness/manager-actions.d.ts +21 -0
  250. package/lib/harness/manager-actions.js +129 -0
  251. package/lib/harness/manager-commands.d.ts +142 -0
  252. package/lib/harness/manager-commands.js +1196 -0
  253. package/lib/harness/manager-fetch.d.ts +33 -0
  254. package/lib/harness/manager-fetch.js +239 -0
  255. package/lib/harness/managers.d.ts +24 -0
  256. package/lib/harness/managers.js +27 -0
  257. package/lib/harness/output-style.d.ts +125 -0
  258. package/lib/harness/output-style.js +322 -0
  259. package/lib/harness/page-commands.d.ts +134 -0
  260. package/lib/harness/page-commands.js +1141 -0
  261. package/lib/harness/page-output.d.ts +67 -0
  262. package/lib/harness/page-output.js +139 -0
  263. package/lib/harness/page-watch.d.ts +171 -0
  264. package/lib/harness/page-watch.js +607 -0
  265. package/lib/harness/parse-check-node.d.ts +3 -0
  266. package/lib/harness/parse-check-node.js +132 -0
  267. package/lib/harness/path-args.d.ts +22 -0
  268. package/lib/harness/path-args.js +148 -0
  269. package/lib/harness/playwright-chromium.d.ts +16 -0
  270. package/lib/harness/playwright-chromium.js +86 -0
  271. package/lib/harness/prepare.d.ts +168 -0
  272. package/lib/harness/prepare.js +364 -0
  273. package/lib/harness/prompt-editor.d.ts +17 -0
  274. package/lib/harness/prompt-editor.js +354 -0
  275. package/lib/harness/prompt-history.d.ts +13 -0
  276. package/lib/harness/prompt-history.js +89 -0
  277. package/lib/harness/rebuild-advice.d.ts +6 -0
  278. package/lib/harness/rebuild-advice.js +39 -0
  279. package/lib/harness/run-log.d.ts +47 -0
  280. package/lib/harness/run-log.js +140 -0
  281. package/lib/harness/run-logs.d.ts +132 -0
  282. package/lib/harness/run-logs.js +241 -0
  283. package/lib/harness/run.d.ts +194 -0
  284. package/lib/harness/run.js +1973 -0
  285. package/lib/harness/runs.d.ts +229 -0
  286. package/lib/harness/runs.js +726 -0
  287. package/lib/harness/save-html.d.ts +39 -0
  288. package/lib/harness/save-html.js +89 -0
  289. package/lib/harness/saved-pages.d.ts +120 -0
  290. package/lib/harness/saved-pages.js +275 -0
  291. package/lib/harness/script-mark.d.ts +17 -0
  292. package/lib/harness/script-mark.js +56 -0
  293. package/lib/harness/snapshot.d.ts +143 -0
  294. package/lib/harness/snapshot.js +474 -0
  295. package/lib/harness/stuck-tabs.d.ts +19 -0
  296. package/lib/harness/stuck-tabs.js +131 -0
  297. package/lib/harness/tab-refs.d.ts +20 -0
  298. package/lib/harness/tab-refs.js +80 -0
  299. package/lib/harness/tampermonkey.d.ts +71 -0
  300. package/lib/harness/tampermonkey.js +356 -0
  301. package/lib/harness/tm-ids.d.ts +18 -0
  302. package/lib/harness/tm-ids.js +88 -0
  303. package/lib/harness/violentmonkey.d.ts +34 -0
  304. package/lib/harness/violentmonkey.js +162 -0
  305. package/lib/harness/wait.d.ts +77 -0
  306. package/lib/harness/wait.js +458 -0
  307. package/lib/http-read.d.ts +30 -0
  308. package/lib/http-read.js +142 -0
  309. package/lib/local-file-overrides.d.ts +35 -0
  310. package/lib/local-file-overrides.js +558 -0
  311. package/lib/manifest.json +38 -0
  312. package/lib/markup-response.d.ts +11 -0
  313. package/lib/markup-response.js +126 -0
  314. package/lib/match-targets.d.ts +18 -0
  315. package/lib/match-targets.js +305 -0
  316. package/lib/menu-captions.d.ts +1 -0
  317. package/lib/menu-captions.js +22 -0
  318. package/lib/parse-check-protocol.d.ts +27 -0
  319. package/lib/parse-check-protocol.js +51 -0
  320. package/lib/parse-location.d.ts +12 -0
  321. package/lib/parse-location.js +171 -0
  322. package/lib/phase-timing.d.ts +12 -0
  323. package/lib/phase-timing.js +155 -0
  324. package/lib/public-suffixes.d.ts +1 -0
  325. package/lib/public-suffixes.js +6957 -0
  326. package/lib/run-context.d.ts +2 -0
  327. package/lib/run-context.js +31 -0
  328. package/lib/runtime-messages.d.ts +10 -0
  329. package/lib/runtime-messages.js +34 -0
  330. package/lib/sandboxed-page.d.ts +5 -0
  331. package/lib/sandboxed-page.js +35 -0
  332. package/lib/script-build.d.ts +111 -0
  333. package/lib/script-build.js +787 -0
  334. package/lib/script-display.d.ts +51 -0
  335. package/lib/script-display.js +383 -0
  336. package/lib/script-icon.d.ts +16 -0
  337. package/lib/script-icon.js +43 -0
  338. package/lib/script-links.d.ts +24 -0
  339. package/lib/script-links.js +204 -0
  340. package/lib/script-source-url.d.ts +8 -0
  341. package/lib/script-source-url.js +214 -0
  342. package/lib/script-world.d.ts +6 -0
  343. package/lib/script-world.js +65 -0
  344. package/lib/sm-dev-protocol.d.ts +251 -0
  345. package/lib/sm-dev-protocol.js +84 -0
  346. package/lib/source-map.d.ts +32 -0
  347. package/lib/source-map.js +177 -0
  348. package/lib/stack-link.d.ts +15 -0
  349. package/lib/stack-link.js +138 -0
  350. package/lib/unsafe-window-use.d.ts +1 -0
  351. package/lib/unsafe-window-use.js +229 -0
  352. package/lib/url-match.d.ts +40 -0
  353. package/lib/url-match.js +734 -0
  354. package/lib/userscript-header.d.ts +33 -0
  355. package/lib/userscript-header.js +248 -0
  356. package/lib/version-compare.d.ts +6 -0
  357. package/lib/version-compare.js +141 -0
  358. package/lib/version-probe.d.ts +17 -0
  359. package/lib/version-probe.js +120 -0
  360. package/package.json +55 -3
  361. package/skills/SourceMonkey-dev/SKILL.md +868 -0
  362. package/skills/SourceMonkey-dev/references/cdp.md +293 -0
  363. package/skills/SourceMonkey-dev/references/commands.md +297 -0
  364. package/skills/SourceMonkey-dev/references/other-managers.md +63 -0
  365. package/skills/SourceMonkey-dev/references/specs.md +46 -0
  366. package/skills/SourceMonkey-friction/SKILL.md +103 -0
  367. package/skills/SourceMonkey-friction/references/template.md +59 -0
@@ -0,0 +1,3507 @@
1
+ // Persistent userscript store backed by chrome.storage.local.
2
+ //
3
+ // Layout (one row per record, no monolithic blob):
4
+ //
5
+ // "collections" → string[] index of collection IDs
6
+ // "collection:<cid>" → StoredCollection per-collection metadata
7
+ // "script:<scid>" → StoredScript per-script content+header
8
+ //
9
+ // Collections own their scripts via `scriptIds`. Scripts back-reference
10
+ // their collection via `collectionId` so the viewer page can refresh a
11
+ // single script without first looking up its parent.
12
+ //
13
+ // All add/refresh flows go through one pipeline:
14
+ // listing → fetch each file → splitHeaderBody → parseHeader →
15
+ // validateHeader → distillMatching → buildScriptFromText.
16
+ // `validateHeader` onward is in script-build.ts, which touches no
17
+ // storage (the header parser is userscript-header.ts).
18
+ //
19
+ // A script is `ok` iff fetch succeeded AND splitHeaderBody returned no
20
+ // error AND validateHeader returned null. Otherwise `status: 'invalid'`
21
+ // with an `errorMessage`. On refresh, files that were known before but
22
+ // no longer appear in the scan listing are dropped — row and storage key
23
+ // both — whatever kind of collection they sat in, and however many
24
+ // went: a successful scan is the collection saying what it holds now.
25
+ // (A listing that isn't one — an error page, a moved directory — fails
26
+ // the scan instead, and a failed scan touches nothing; see
27
+ // `isDirectoryListing`.) In every error case we preserve the
28
+ // previously loaded content so the viewer (and the injector) keep
29
+ // working.
30
+ //
31
+ // Refresh failures get special, gentler handling so a transient
32
+ // outage never disables a working script. When a prior `ok` row's
33
+ // refresh fails — a hard fetch failure (network / 404 / unreadable
34
+ // file / disconnected mount), an http(s) 200 that parses `invalid` (a
35
+ // login / error page in place of the script), or a failed
36
+ // `@require`/`@resource` fetch — we keep the last-known-good row
37
+ // running (`status` stays `ok`, still injectable) and record the
38
+ // reason in `refreshError`. The next clean refresh clears it. A
39
+ // file:// body that parses `invalid` is the one exception: a local
40
+ // read returns the file's real bytes, so that's a genuine edit and we
41
+ // flag it `invalid` immediately. See [docs/storage.md].
42
+ //
43
+ // If the scan step itself fails (unreadable listing, unreadable
44
+ // manifest, …), refreshCollection records the error in `scanError` on
45
+ // the collection row and leaves every existing script untouched — see
46
+ // that function's docstring for the rationale.
47
+ import { canonicalCollectionInput, containerKindForSingleFileInput, containerPath, fetchScriptFileUrlText, fetchScriptFileWithStat, fetchHttpText, isContainerCollection, isHttpUrl, isLocalDirectoryCollection, isSingleFileInput, isScratchUrl, scratchFilesKey, scratchSourceKey, scratchTextKey, scratchUrl, isSmDevCollection, smDevContainerPath, smDevRunOfContainer, baseName, isFileUrl, isManifestCollection, parentDir, newContainerPath, normalizeCollectionInput, SINGLE_SCRIPT_USE_ADD_FIELD, scanForUserScripts, scriptFileUrl, statFileByListing, collectionCanonicalUrl, locationFsPath, statFromListing, hasCachedListing, } from './directories.js';
48
+ import { getValue, parseHeader, } from '../userscript-header.js';
49
+ import { bindListingCache, prefetchKnownStandIns, } from '../external-files.js';
50
+ import { scanAllStorage, scanStorageRows } from './storage-scan.js';
51
+ import { cachedRows, cachedScriptRows, allCachedRows } from './row-cache.js';
52
+ import { callWorker } from './worker-client.js';
53
+ import { assertWorkerContext, isPageContext } from '../run-context.js';
54
+ import { blobKeysOf, clearMigrationFailureForTests, fromStorage, hydrateScriptRows, carryBodyRef, MISSING_BLOB_MESSAGE, markMissingBlobs, newBlobItems, storedForm, migrateToBlobs, STORE_FORMAT, STORE_FORMAT_KEY, sweepUnusedBlobs, withBlobs, } from './script-blobs.js';
55
+ import { sameScript } from './script-keys.js';
56
+ import { compareInCollection, preferredCopy } from './copy-preference.js';
57
+ import { wipeValues } from './value-store.js';
58
+ import { connectGrantsKey } from './connect-grants.js';
59
+ import { sourceUsesUnsafeWindow } from '../unsafe-window-use.js';
60
+ import { describeDiagnostic, } from '../diagnostic-location.js';
61
+ import { collectionNameInUse, inferDisplayName, mayUseReservedName, RESERVED_COLLECTION_NAME, } from './collection-display.js';
62
+ import { appendScriptLog, forgetScriptLogs } from './script-log.js';
63
+ import { displayLocation, formatBytes, stripSriFragment } from '../script-display.js';
64
+ import { setRefreshError, pruneRefreshErrors } from './refresh-error.js';
65
+ import { mapWithConcurrency, FETCH_SCRIPTS_PER_COLLECTION_CONCURRENCY, FETCH_COLLECTIONS_CONCURRENCY, PARSE_RECHECK_CONCURRENCY, } from './concurrency.js';
66
+ import { dlog } from '../debug-log.js';
67
+ import { COLLECTIONS_INDEX_KEY, COLLECTION_PREFIX, ENABLEMENT_TIMES_KEY, SCRIPT_PREFIX, collectionKey, scriptKey, } from './structural-keys.js';
68
+ import { startPhaseTimer, storageSize, timePhase, formatMs, timingNow } from '../phase-timing.js';
69
+ import { applyFetchError, applyFiles, applyParseCheck, applyVerdictFields, buildInitialInvalidEntry, buildScriptFromText, describeError, locationIn, parseChecker, parseCheckKey, rederivedStartupLogs, RETIRED_CONTEXT_MENU_UNSUPPORTED, setScriptError, validateHeader, } from '../script-build.js';
70
+ // The storage-free half of the pipeline, re-exported for the callers
71
+ // that reach it through here.
72
+ export { applyFetchError, applyFiles, buildInitialInvalidEntry, buildScriptFromText, RETIRED_CONTEXT_MENU_UNSUPPORTED, describeError, distillMatching, extractStartupLogs, hashSource, isolatedUnsafeWindowWarning, NO_MATCH_OR_INCLUDE, NO_NAME, noConnectInfo, setParseChecker, setScriptError, SYNTAX_ERROR_PREFIX, unsupportedRunAtWarning, validateHeader, } from '../script-build.js';
73
+ // Two states that used to share the message `not loaded`, which read as
74
+ // the same fact but isn't: one is a row waiting for its first fetch, the
75
+ // other is a row whose fetch never happened and won't be retried until
76
+ // the next refresh.
77
+ const NOT_LOADED_YET = 'Not loaded yet';
78
+ const NOT_READ = 'Could not be read';
79
+ // The per-script Log entry a row's *persisted load state* warrants, or
80
+ // null when the row is healthy (`ok` with no `refreshError`).
81
+ //
82
+ // Single source of truth for "how do we phrase this failure in the
83
+ // Log tab", shared by every path that can surface one: the add path
84
+ // (script-store), the refresh outcome path and the startup replay
85
+ // (script-updater), and the deferred parse check. Sharing it means the
86
+ // same failure reads the same way whichever path reported it, so a
87
+ // user comparing two rows can tell "the same problem again" from "a
88
+ // different problem".
89
+ //
90
+ // Each entry carries the `state` it moved the row into — the same word
91
+ // the Status badge shows — so the Log tab's status column and the badge
92
+ // agree (see `LogEntry.state`). Because it carries a state, the entry
93
+ // never bumps the `loglevel:` sentinel: the badge comes from the row
94
+ // and clears when the row recovers.
95
+ //
96
+ // Levels (still recorded on the entry, though the Log tab's Status
97
+ // cell shows the `state` word instead):
98
+ // - `invalid` → **error**. The script cannot run: the injector skips
99
+ // `invalid` rows outright.
100
+ // - `refreshError` → **warning**. The script IS still running, from
101
+ // its last-known-good copy; only the *update* failed.
102
+ export function loadStateLogEntry(script) {
103
+ if (script.status === 'invalid') {
104
+ // The location travels as its own field rather than being baked
105
+ // into the text, so the Log tab can link it — and so this entry
106
+ // says exactly what the status badge's hover text says.
107
+ return {
108
+ level: 'error',
109
+ state: 'invalid',
110
+ message: script.errorMessage ?? 'Unknown error',
111
+ ...(script.errorLocation ? { location: script.errorLocation } : {}),
112
+ };
113
+ }
114
+ if (script.refreshError) {
115
+ return { level: 'warning', state: 'refresh-failed', message: `Refresh failed: ${script.refreshError}` };
116
+ }
117
+ return null;
118
+ }
119
+ // Map a StartupLog level (page-console-flavored: log/info/warn/error)
120
+ // to the per-script LogEntry level (info/warning/error). 'log' and
121
+ // 'info' both land as 'info' because the Log tab doesn't render a
122
+ // separate 'log' level.
123
+ function startupLogLevelToLogLevel(level) {
124
+ if (level === 'warn')
125
+ return 'warning';
126
+ if (level === 'error')
127
+ return 'error';
128
+ return 'info';
129
+ }
130
+ // A `StartupLog` as the Log tab's own entry shape. Every replay path —
131
+ // the new-content one in `emitScriptOutcomeLog`, the once-per-session
132
+ // one in `ensureStartupLogsInLogTab` and the add path's
133
+ // `emitInitialLoadLog` — goes through here so a field added
134
+ // to StartupLog can't reach one of them and not the others. `location`
135
+ // is exactly such a field.
136
+ export function startupLogEntry(sl) {
137
+ return {
138
+ level: startupLogLevelToLogLevel(sl.level),
139
+ source: 'parser',
140
+ message: sl.message,
141
+ ...(sl.location ? { location: sl.location } : {}),
142
+ };
143
+ }
144
+ // Log lines for local stand-ins that started, stopped, or changed on
145
+ // disk between two versions of a row.
146
+ //
147
+ // Why log this at all: running something other than what the `@require`
148
+ // line names is exactly the kind of substitution that must not be
149
+ // silent. The viewer marks it too (a `local` chip on the tab and in the
150
+ // details panel), but the Log is what says *when* it happened, and a
151
+ // re-read after an edit is the signal a developer is actually watching
152
+ // for.
153
+ //
154
+ // Why it isn't noisy: `contentHash` gates the "still using it" case, so
155
+ // a stand-in that hasn't changed says nothing on the dozens of
156
+ // opportunistic refreshes between edits.
157
+ //
158
+ // All `info`, losing a stand-in included: the script is back on the
159
+ // code its `@require` line names, which is not a fault. The line says
160
+ // what is loaded now, so the switch is still on record.
161
+ // How a declared reference reads in a Log line: the SRI hash tail
162
+ // dropped (it's most of the line's width and nothing the reader can act
163
+ // on) and percent-escapes decoded, so a URL carrying a script's name
164
+ // reads as `my lib/util.js` rather than `my%20lib/util.js`. Same
165
+ // treatment every other URL a human sees already gets.
166
+ const refText = (ref) => displayLocation(stripSriFragment(ref));
167
+ export function localFileLogMessages(prior, next) {
168
+ const out = [];
169
+ // Keyed by KIND + declared URL. The URL alone would collide: a
170
+ // `@require` and a `@resource` pointing at the same URL is an
171
+ // explicitly supported case (they share one fetch — see
172
+ // external-files.ts), and a single map would hand the require the
173
+ // resource's record on a last-write-wins basis.
174
+ //
175
+ // The URL rather than the ref is the right half of the key: it's the
176
+ // field that stays put while the stand-in appears and disappears
177
+ // underneath it.
178
+ const priorFiles = new Map();
179
+ const priorKey = (kind, url) => `${kind}\n${url}`;
180
+ for (const f of prior?.requires ?? []) {
181
+ priorFiles.set(priorKey('require', f.url), { localUrl: f.localUrl, contentHash: f.contentHash });
182
+ }
183
+ for (const f of prior?.resources ?? []) {
184
+ priorFiles.set(priorKey('resource', f.url), { localUrl: f.localUrl, contentHash: f.contentHash });
185
+ }
186
+ for (const kind of ['require', 'resource']) {
187
+ const list = kind === 'require' ? (next.requires ?? []) : (next.resources ?? []);
188
+ for (const f of list) {
189
+ const was = priorFiles.get(priorKey(kind, f.url));
190
+ if (f.localUrl) {
191
+ // Same file, same bytes as last pass → nothing happened.
192
+ if (was?.localUrl === f.localUrl && was.contentHash === f.contentHash)
193
+ continue;
194
+ out.push({
195
+ level: 'info',
196
+ message: `@${kind} ${refText(f.ref)} is loading from ${displayLocation(f.localUrl)}`,
197
+ });
198
+ }
199
+ else if (was?.localUrl) {
200
+ out.push({
201
+ level: 'info',
202
+ message: `@${kind} ${refText(f.ref)} is no longer loading from a local file; `
203
+ + 'the remote version is loaded instead',
204
+ });
205
+ }
206
+ }
207
+ }
208
+ // A declaration the script dropped isn't reported — the `@require`
209
+ // line going away is the event, and it isn't this function's to tell.
210
+ //
211
+ // Deduped because two `@resource` lines may name the same URL under
212
+ // different names (deliberately — see `distillResources`), and both
213
+ // entries would otherwise produce the identical line.
214
+ const seen = new Set();
215
+ return out.filter((e) => !seen.has(e.message) && seen.add(e.message));
216
+ }
217
+ // Treat any pre-`localFiles`-era stored entry as enabled, the same way
218
+ // `isCollectionEnabled` treats `enabled`.
219
+ export function collectionUsesLocalFiles(collection) {
220
+ return collection.localFiles !== false;
221
+ }
222
+ // Everything the local-stand-in resolver needs from a collection row,
223
+ // in one value — so no caller can pass the toggle and forget the root.
224
+ export function localFileScope(collection) {
225
+ return { enabled: collectionUsesLocalFiles(collection), collectionPath: collection.path };
226
+ }
227
+ // Treat any pre-`enabled`-era stored entry as enabled. The schema now
228
+ // requires `enabled: boolean`, but rows persisted before this field was
229
+ // introduced will read back as undefined; only an explicit `false`
230
+ // should count as disabled.
231
+ export function isCollectionEnabled(collection) {
232
+ return collection.enabled !== false;
233
+ }
234
+ export function isScriptEnabled(script) {
235
+ return script.enabled !== false;
236
+ }
237
+ // Re-derive the parse verdict for rows that carry none, and persist
238
+ // what comes back.
239
+ //
240
+ // ---------- Why a second entry point ----------
241
+ //
242
+ // `applyParseCheck` runs inside the load pipeline, so it only ever
243
+ // sees rows that are being *built* — added, refreshed, edited. A row
244
+ // that was stored before the check existed, or while the check was
245
+ // failing open (no offscreen document, sandbox unavailable), keeps
246
+ // `status: 'ok'` with no `parseCheckedHash` and is never revisited
247
+ // until something rebuilds it. Registering one of those is how
248
+ // unparsable source reaches Chrome.
249
+ //
250
+ // That matters beyond the script not running. A registered entry that
251
+ // V8 refuses to compile raises an uncaught `SyntaxError` attributed to
252
+ // the extension, which Chrome files under
253
+ // `chrome://extensions/?errors` — nothing can catch it in-page, and it
254
+ // reads as SourceMonkey being broken rather than the script being
255
+ // broken. Not registering it is the only way to keep it out of there;
256
+ // the seal entry (the registry's `sealed`) stays as the backstop for whatever
257
+ // still gets through.
258
+ //
259
+ // Caller passes the rows it is about to register — checking a
260
+ // disabled script would spend a round trip on code nothing will run.
261
+ //
262
+ // Returns, keyed by id, the rows whose STORED state differs from the
263
+ // copy the caller passed in — the ones this pass wrote, and the ones it
264
+ // refused to write because someone else had already moved the row. The
265
+ // caller's job is the same either way: swap ours in. Returning only the
266
+ // rows we WROTE is what made the refusal branches below a bug —
267
+ // `revalidateCandidates` falls back to the snapshot it read before the
268
+ // check, which is the row that still says `ok`, and registers the very
269
+ // body we just declined to write a verdict over.
270
+ //
271
+ // One case is deliberately not covered: a row DELETED while the check
272
+ // was out is absent from the result, because there is no stored row to
273
+ // hand back. The caller keeps its snapshot and registers a script that
274
+ // no longer exists for one cycle, until the write that deleted it
275
+ // schedules the sync that drops it.
276
+ //
277
+ // Fails open exactly like the pipeline check: no checker, an
278
+ // inconclusive verdict, or a throw all leave the row alone.
279
+ export async function verifyParseChecks(scripts) {
280
+ const changed = new Map();
281
+ if (!parseChecker)
282
+ return changed;
283
+ // A row the check couldn't decide on records nothing, so it stays
284
+ // stale and is asked about again on the next sync. That is
285
+ // deliberate: the two ways to be undecided are "the machinery isn't
286
+ // there" (answered without compiling anything, and it can come back
287
+ // — the off-switch the tests use lives here) and "V8 ran out of
288
+ // parser budget", which the sandbox's own circuit breaker already
289
+ // rations. Remembering the verdict-free attempt instead would mean a
290
+ // row checked while the machinery was down never gets checked again.
291
+ const stale = scripts.filter((s) => s.status === 'ok' && s.parseCheckedHash !== parseCheckKey(s));
292
+ if (stale.length === 0)
293
+ return changed;
294
+ // Rows another pass is already compiling are joined rather than
295
+ // compiled again. Both entry points reach the same bytes on the
296
+ // reload path — the deferred check the refresh started, and the sync
297
+ // that same refresh's write scheduled — and a compile is the
298
+ // expensive part of this whole feature. See `inFlightChecks`.
299
+ const own = [];
300
+ const joined = [];
301
+ for (const s of stale) {
302
+ const running = inFlightChecks.get(inFlightCheckKey(s));
303
+ if (running)
304
+ joined.push(running.then((row) => { if (row)
305
+ changed.set(s.id, row); }));
306
+ else
307
+ own.push(s);
308
+ }
309
+ // Declared out here and filled inside the `try` so a throw partway
310
+ // through the claim loop still reaches the `finally` that settles it.
311
+ // A claimed key with no resolver would wedge every later pass over
312
+ // that row on a promise that never settles. (`const settle =` inside
313
+ // the `try` cannot work — the `finally` would read it in its TDZ.)
314
+ const settle = new Map();
315
+ try {
316
+ claimInFlightChecks(own, settle);
317
+ await Promise.all([runOwnParseChecks(own, changed), ...joined]);
318
+ }
319
+ finally {
320
+ // Resolve with the row as it now stands so a joiner gets what a
321
+ // caller of its own would have got — including the refusal cases,
322
+ // where the row we hand back is someone else's write rather than
323
+ // ours. A throw resolves them with the input row, which is what
324
+ // "nothing was recorded" looks like to every reader.
325
+ for (const [key, entry] of settle) {
326
+ entry.resolve(changed.get(entry.id) ?? null);
327
+ inFlightChecks.delete(key);
328
+ }
329
+ }
330
+ return changed;
331
+ }
332
+ // The compile-and-store half of `verifyParseChecks`, for the rows this
333
+ // call owns. Fills `changed` in place.
334
+ async function runOwnParseChecks(own, changed) {
335
+ if (own.length === 0)
336
+ return;
337
+ // `prior` is deliberately undefined: the short-circuit it exists for
338
+ // is the one the staleness filter already applied.
339
+ //
340
+ // Capped rather than `Promise.all`: `own` is normally empty or tiny,
341
+ // but the first sync after an extension update finds the whole
342
+ // library stale at once (the version is part of the key), and each
343
+ // compile composes a whole program in memory.
344
+ const checked = await mapWithConcurrency(own, PARSE_RECHECK_CONCURRENCY, (s) => applyParseCheck({ ...s }, undefined));
345
+ const verdicts = new Map();
346
+ for (const next of checked) {
347
+ // Nothing to record when the check couldn't decide — no verdict,
348
+ // no fingerprint, and the row is untouched.
349
+ if (next.status === 'ok' && !next.parseCheckedHash)
350
+ continue;
351
+ verdicts.set(next.id, next);
352
+ }
353
+ if (verdicts.size === 0)
354
+ return;
355
+ // Rows this pass wrote and that can no longer run. Collected inside
356
+ // the lock, reported outside it — a Log append is not storage-row
357
+ // work and has its own queue.
358
+ const flipped = [];
359
+ await mutate(async () => {
360
+ const keys = [...verdicts.keys()].map(scriptKey);
361
+ const got = await getMany(keys);
362
+ const toSet = {};
363
+ for (const [id, next] of verdicts) {
364
+ // Re-read rather than trusting the copy we checked: the check is
365
+ // a round trip to the offscreen document, and a refresh could have
366
+ // rewritten the row while it was out. A row whose fingerprint
367
+ // inputs moved is one our verdict is no longer about.
368
+ const current = got[scriptKey(id)];
369
+ // The row was deleted while the check was out. Nothing to write
370
+ // and nothing to hand back — a row that isn't stored is not a row
371
+ // the caller can be given a fresher copy of.
372
+ if (!current)
373
+ continue;
374
+ if (parseCheckKey(current) !== parseCheckKey(next)) {
375
+ // Refused, not dropped: the caller is holding a copy from
376
+ // before this row moved, and letting it fall back to that is
377
+ // how an unchecked body gets registered.
378
+ changed.set(id, current);
379
+ continue;
380
+ }
381
+ // The verdict was formed against a row that was `ok`. A row that
382
+ // has since left that state left it for a reason this check knows
383
+ // nothing about — a read failed, a `@require` stopped loading —
384
+ // and writing our `status` over it would resurrect a state that
385
+ // is no longer true. The fingerprint alone doesn't catch this:
386
+ // `applyFetchError` preserves the content the fingerprint is made
387
+ // of.
388
+ if (current.status !== 'ok') {
389
+ changed.set(id, current);
390
+ continue;
391
+ }
392
+ // Merge the verdict ONTO the row we just read, rather than
393
+ // writing the copy we checked. Same fingerprint doesn't mean same
394
+ // row: `enabled`, `versionProbes` and the rest can have moved
395
+ // while the check was out, and writing our copy would roll them
396
+ // back.
397
+ const merged = applyVerdictFields(current, next);
398
+ changed.set(id, merged);
399
+ toSet[scriptKey(id)] = merged;
400
+ if (merged.status !== 'ok')
401
+ flipped.push(merged);
402
+ }
403
+ if (Object.keys(toSet).length > 0)
404
+ await writeLocal(toSet);
405
+ });
406
+ // Say so in the Log. This lives here rather than at a caller because
407
+ // whichever pass wins the write is the one that knows — the deferred
408
+ // check, the sync's gate before registration, or a later sync after
409
+ // the worker was evicted mid-check. Keyed off what was WRITTEN, so a
410
+ // refused verdict stays quiet rather than announcing a state it
411
+ // didn't put there.
412
+ for (const row of flipped) {
413
+ const entry = loadStateLogEntry(row);
414
+ if (entry)
415
+ void appendScriptLog(row.id, { ...entry, source: 'parser' });
416
+ }
417
+ }
418
+ // Save as `invalid` the rows among `ids` that point at a blob that isn't
419
+ // there, and say so in each one's Log. The registration sync calls this
420
+ // for the candidates it found that way: it's the one body reader that
421
+ // runs on its own, and without the write the pages, which read only the
422
+ // rows, would show the script as fine while it no longer runs.
423
+ //
424
+ // Decided again inside the lock from the rows and blobs stored then, so
425
+ // a refresh that repaired a row since isn't undone. A row already
426
+ // `invalid` is left alone.
427
+ export async function recordMissingBlobs(ids) {
428
+ if (ids.length === 0)
429
+ return;
430
+ const flipped = [];
431
+ await mutate(async () => {
432
+ const rows = await getRows(ids.map(scriptKey));
433
+ const keys = [...new Set(Object.values(rows).flatMap(blobKeysOf))];
434
+ const blobs = keys.length > 0 ? await chrome.storage.local.get(keys) : {};
435
+ const toSet = {};
436
+ for (const [key, value] of Object.entries(rows)) {
437
+ const row = value;
438
+ if (row.status !== 'ok')
439
+ continue;
440
+ const marked = markMissingBlobs(row, blobs);
441
+ if (!marked)
442
+ continue;
443
+ toSet[key] = marked;
444
+ flipped.push(marked);
445
+ }
446
+ if (flipped.length > 0)
447
+ await writeLocal(toSet);
448
+ });
449
+ for (const row of flipped) {
450
+ const entry = loadStateLogEntry(row);
451
+ if (entry)
452
+ void appendScriptLog(row.id, { ...entry, source: 'storage' });
453
+ }
454
+ }
455
+ const inFlightChecks = new Map();
456
+ const inFlightCheckKey = (s) => `${s.id}|${parseCheckKey(s)}`;
457
+ // Register `own` as in flight, recording into `settle` the resolvers the
458
+ // caller must settle when it is done. Claimed synchronously, before the
459
+ // first await, so a pass starting later in the same turn joins rather
460
+ // than duplicating.
461
+ //
462
+ // Fills a caller-owned map rather than returning one so a throw
463
+ // mid-loop still leaves the caller holding what was claimed so far —
464
+ // see the `finally` in `verifyParseChecks`.
465
+ function claimInFlightChecks(own, settle) {
466
+ for (const s of own) {
467
+ const key = inFlightCheckKey(s);
468
+ if (inFlightChecks.has(key))
469
+ continue; // same row twice in one call
470
+ let resolve;
471
+ inFlightChecks.set(key, new Promise((r) => { resolve = r; }));
472
+ settle.set(key, { id: s.id, resolve });
473
+ }
474
+ }
475
+ // No reset hook for `inFlightChecks`, unlike the other worker module
476
+ // state `resetAllState` clears by name: an entry only exists while a
477
+ // check is running and its `finally` removes it, so there is nothing
478
+ // for a storage wipe to strand. A wipe landing mid-check leaves at most
479
+ // one already-started compile whose write is refused when the row it
480
+ // judged is gone.
481
+ // Run the check a `ParseCheckMode: 'defer'` refresh skipped, after that
482
+ // refresh has already stored its row. Not awaited by its caller — the
483
+ // point of deferring is that nothing waits for the verdict.
484
+ //
485
+ // `verifyParseChecks` is the whole implementation: it re-reads the row
486
+ // inside the write lock, refuses to apply a verdict the row has moved
487
+ // out from under, and merges only the verdict's own fields. That is
488
+ // exactly the race a late verdict has to survive, so this deliberately
489
+ // does not open a second path to the same write.
490
+ //
491
+ // If the worker dies before the verdict lands, the row simply stays
492
+ // unchecked: it carries no `parseCheckedHash`, so the gate every
493
+ // registration sync runs (`revalidateCandidates` in injector.ts) finds
494
+ // it stale and derives the verdict before Chrome is offered the body.
495
+ // What is lost is timeliness, not the check.
496
+ export function checkParsesInBackground(script) {
497
+ if (script.status !== 'ok')
498
+ return;
499
+ if (script.parseCheckedHash === parseCheckKey(script))
500
+ return;
501
+ void verifyParseChecks([script]).catch((err) => {
502
+ // Same failing-open contract as the pipeline check: the row stays
503
+ // unchecked, and the gate before registration asks again.
504
+ dlog(`[SourceMonkey] deferred parse check failed for ${script.relativePath}: ${describeError(err)}`);
505
+ });
506
+ }
507
+ // Does an `invalid` parse of this entry keep the PRIOR copy running,
508
+ // rather than becoming the row's new state?
509
+ //
510
+ // True for an http(s) entry with a prior `ok` copy: a 200 that doesn't
511
+ // parse is usually a login or error page served in place of the script,
512
+ // so it is a *refresh* failure (`refreshError`, still injecting the
513
+ // cached body) and not a content replacement. A `file://` read returns
514
+ // the file's real bytes, so an `invalid` parse there is a genuine local
515
+ // edit the user wants flagged immediately.
516
+ //
517
+ // One predicate, two readers: the rule itself in `rebuildCollection`,
518
+ // and the decision about whether that entry's parse check may be
519
+ // deferred. Deferring is only safe where the verdict lands on the row
520
+ // directly, so the two have to answer the same question — hence one
521
+ // function rather than two spellings of it 60 lines apart.
522
+ function keepsPriorOnInvalid(fileUrl, prior) {
523
+ return isHttpUrl(fileUrl) && prior?.status === 'ok';
524
+ }
525
+ // Pure orchestrator: given the prior state for a collection plus a
526
+ // fresh scan and the per-file fetch results, return the new list of
527
+ // `StoredScript`s in canonical order (scan order). Stable IDs come
528
+ // from looking up by relativePath in `existing`. No I/O — easy to test.
529
+ //
530
+ // Vanished-entry handling — an entry the scan no longer reports is
531
+ // dropped entirely (the `refreshCollection` caller then deletes the
532
+ // storage row), because a successful scan is the collection telling us
533
+ // what it holds now: a renamed or deleted file would otherwise linger
534
+ // as a row the user has to clear by hand. That holds for a scan that
535
+ // found nothing at all, too — an empty directory, or one with no
536
+ // `.user.js` files left in it, is as definite as an empty manifest.
537
+ // What it does NOT hold for is a scan that failed, and that never
538
+ // reaches here: `refreshCollection` records the `scanError` and leaves
539
+ // every row alone. The listing reader is what keeps the two apart
540
+ // (`isDirectoryListing` in directories.ts).
541
+ export async function rebuildCollection(args) {
542
+ const newId = args.newId ?? (() => crypto.randomUUID());
543
+ const recheckFiles = args.recheckFiles ?? 'all';
544
+ const fileFetchCache = args.fileFetchCache ?? new Map();
545
+ const localFiles = args.localFiles ?? { enabled: false };
546
+ const parseCheck = args.parseCheck ?? 'await';
547
+ // Deferring is only safe where the deferred verdict does exactly what
548
+ // an awaited one would have done — and where `keepsPriorOnInvalid`
549
+ // holds it would not, because that rule reads the status the pipeline
550
+ // hands back and a verdict arriving after it has run would replace a
551
+ // working script with the garbage instead of preserving it. Same
552
+ // predicate as the rule itself, so the two cannot drift apart.
553
+ const parseCheckFor = (fileUrl, prior) => parseCheck === 'defer' && !keepsPriorOnInvalid(fileUrl, prior) ? 'defer' : 'await';
554
+ const existingByPath = new Map();
555
+ for (const e of args.existing)
556
+ existingByPath.set(e.relativePath, e);
557
+ // Each scanned entry resolves to one StoredScript. Most paths are
558
+ // synchronous (unchanged, error, defensive fallbacks); the `ok`
559
+ // path goes through buildScriptFromText which is async because the
560
+ // content-hash uses SubtleCrypto, and applyFiles which fetches this
561
+ // entry's `@require` / `@resource` bodies. We collect every branch as
562
+ // a thunk and run them with bounded concurrency at the end, so the
563
+ // async work overlaps without putting every entry's dependency
564
+ // payloads in flight at once, and the output order matches scan order.
565
+ const entryTasks = [];
566
+ for (const found of args.scan) {
567
+ const prior = existingByPath.get(found.relativePath);
568
+ const id = prior?.id ?? newId();
569
+ const fileUrl = scriptFileUrl(args.collectionPath, found.relativePath);
570
+ const fr = args.fetchResults.get(found.relativePath);
571
+ if (fr && 'ok' in fr && fr.ok) {
572
+ entryTasks.push(async () => {
573
+ const built = await timePhase('build', () => buildScriptFromText({
574
+ id,
575
+ collectionId: args.collectionId,
576
+ relativePath: found.relativePath,
577
+ fileUrl,
578
+ text: fr.text,
579
+ now: args.now,
580
+ // Pass prior so buildScriptFromText can preserve loadedAt when
581
+ // the freshly fetched bytes hash to the same value — the
582
+ // post-fetch leg of the unified change-detection path.
583
+ prior,
584
+ httpEtag: fr.httpEtag,
585
+ httpLastModified: fr.httpLastModified,
586
+ }));
587
+ // Preserve the user's per-script enabled choice across refreshes.
588
+ // `versionProbes` is deliberately NOT carried here — it's
589
+ // re-applied from the persisted row by preserveConcurrentEdits
590
+ // inside the write lock, which is strictly more current than
591
+ // this pre-fetch snapshot.
592
+ if (prior)
593
+ built.enabled = prior.enabled;
594
+ // Record the listing's mtime so the next refresh can decide to
595
+ // skip a refetch.
596
+ if (found.mtime !== undefined)
597
+ built.fsMtime = found.mtime;
598
+ const withFiles = await applyFiles(built, prior, recheckFiles, fileFetchCache, args.now, localFiles, parseCheckFor(fileUrl, prior), await pushedFilesOf(fileUrl));
599
+ // Soft-failure preservation — see `keepsPriorOnInvalid`. Keep
600
+ // the prior good body running and annotate `refreshError`
601
+ // instead of clobbering it with what came back.
602
+ if (withFiles.status === 'invalid' && keepsPriorOnInvalid(fileUrl, prior)) {
603
+ return {
604
+ ...prior,
605
+ // Described, not bare: `refreshError` is a plain string
606
+ // with no location field of its own, so flattening the
607
+ // location into the text is the only way "where" survives
608
+ // — and where in the fetched copy it went wrong is the
609
+ // useful half of this message.
610
+ refreshError: `Fetched copy is not a valid userscript (${withFiles.errorMessage
611
+ ? describeDiagnostic(withFiles.errorMessage, withFiles.errorLocation)
612
+ // Capitalized to match the `describeDiagnostic` string
613
+ // it stands in for — the two share this slot, so a
614
+ // lowercase fallback would read as a different kind of
615
+ // thing depending on which branch ran.
616
+ : 'Parse failed'})`,
617
+ };
618
+ }
619
+ return withFiles;
620
+ });
621
+ }
622
+ else if (fr && 'unchanged' in fr) {
623
+ // Optimization path: the listing's size+mtime matched the prior
624
+ // entry, so we never fetched. Reuse the prior entry verbatim.
625
+ // (Defensive fallback if there's somehow no prior: treat as a
626
+ // fresh-fetch failure — shouldn't happen because the orchestrator
627
+ // only emits `unchanged` when prior exists.)
628
+ if (prior) {
629
+ // Recompute fileUrl unconditionally — if the collection's
630
+ // path/URL ever changes (e.g. a future edit-collection UI), the
631
+ // prior's fileUrl would silently lie. Cheap to recompute here.
632
+ const next = { ...prior, fileUrl };
633
+ if (found.mtime !== undefined)
634
+ next.fsMtime = found.mtime;
635
+ if (found.size !== undefined)
636
+ next.size = found.size;
637
+ // A proven-unchanged refetch (HTTP 304 or matching size+mtime)
638
+ // means the collection is reachable and serving the same good
639
+ // bytes again — clear any stale "refresh failed" annotation. If
640
+ // a dep recheck below fails again, applyFiles re-sets
641
+ // refreshError.
642
+ delete next.refreshError;
643
+ // Even when the body is unchanged, explicit Refresh still
644
+ // rechecks @require + @resource bodies (the user may want to
645
+ // pick up remote dep updates without touching the main
646
+ // script). The opportunistic path passes 'preserve' which
647
+ // short-circuits.
648
+ entryTasks.push(async () => applyFiles(next, prior, recheckFiles, fileFetchCache, args.now, localFiles, parseCheckFor(fileUrl, prior), await pushedFilesOf(fileUrl)));
649
+ }
650
+ else {
651
+ entryTasks.push(() => Promise.resolve(buildInitialInvalidEntry({
652
+ id,
653
+ collectionId: args.collectionId,
654
+ relativePath: found.relativePath,
655
+ fileUrl,
656
+ // Prefixed as internal because it is: the orchestrator told us
657
+ // "unchanged" for a file we have never held. Nothing the user
658
+ // can do about it, and saying so beats an accusation.
659
+ errorMessage: 'Internal error: unchanged marker with no prior entry',
660
+ now: args.now,
661
+ })));
662
+ }
663
+ }
664
+ else if (fr) {
665
+ // Failed fetch. If we had prior content, keep it.
666
+ const errorMessage = 'errorMessage' in fr ? fr.errorMessage : 'Could not be read (no reason reported)';
667
+ if (prior) {
668
+ entryTasks.push(() => Promise.resolve(applyFetchError(prior, errorMessage)));
669
+ }
670
+ else {
671
+ entryTasks.push(() => Promise.resolve(buildInitialInvalidEntry({
672
+ id,
673
+ collectionId: args.collectionId,
674
+ relativePath: found.relativePath,
675
+ fileUrl,
676
+ errorMessage,
677
+ now: args.now,
678
+ })));
679
+ }
680
+ }
681
+ else {
682
+ // No fetch attempted at all. Treat like a fetch error with a
683
+ // generic message; this branch is mostly defensive — the
684
+ // orchestrator should always supply a result.
685
+ entryTasks.push(() => Promise.resolve(prior
686
+ ? applyFetchError(prior, NOT_READ)
687
+ : buildInitialInvalidEntry({
688
+ id, collectionId: args.collectionId, relativePath: found.relativePath,
689
+ fileUrl, errorMessage: NOT_READ, now: args.now,
690
+ })));
691
+ }
692
+ }
693
+ const out = await mapWithConcurrency(entryTasks, FETCH_SCRIPTS_PER_COLLECTION_CONCURRENCY, (task) => task());
694
+ // Anything in `existing` that the scan didn't see is dropped: it is
695
+ // simply not in `out`. See the header comment.
696
+ return out;
697
+ }
698
+ // ---------- chrome.storage.local I/O ----------
699
+ // Serialize every read-modify-write of the structural rows (the
700
+ // collection index, collection rows, script rows). chrome.storage.local
701
+ // has no compare-and-swap, so two operations that each read a row,
702
+ // derive a new value, and write it back would otherwise drop one
703
+ // another's change — e.g. a user's enable toggle reverted by a refresh
704
+ // that captured the old flag before the fetch, or an addCollection
705
+ // racing a removeCollection on the shared index. Callers run their read
706
+ // AND write inside one `mutate()` so the pair is atomic with respect to
707
+ // other mutators.
708
+ //
709
+ // The critical sections here are short (no network) — slow fetches stay
710
+ // OUTSIDE the lock (refresh fetches, then re-reads + writes inside it),
711
+ // so this doesn't serialize I/O. IMPORTANT: a `mutate()` block must
712
+ // never call another mutator (refreshCollection / addCollection / a set*),
713
+ // since this mutex is non-reentrant — keep orchestrator calls outside.
714
+ //
715
+ // SCOPE: `mutateChain` is module state, so the lock covers one JS
716
+ // context. Only the service worker writes (docs/worker-writes.md): pages
717
+ // ask it, and `writeLocal` throws in a page. So every mutator is under
718
+ // this one lock. Two rules still hold:
719
+ //
720
+ // - Re-read inside the lock and merge, rather than writing values
721
+ // captured before it: a refresh fetches outside the lock, and an
722
+ // edit made meanwhile has to survive its write. See
723
+ // `preserveConcurrentEdits` and the joined/departed reconcile in
724
+ // `writeRefreshedCollection`.
725
+ // - Keep sections short: every write in the worker waits on them.
726
+ //
727
+ // See docs/storage.md#one-lock-in-the-worker.
728
+ // A section's writes must be awaited before it returns: the blob cleanup
729
+ // relies on no script row landing outside a section
730
+ // (`checkScriptRowWrite`), and a write left running would land after.
731
+ let mutateChain = Promise.resolve();
732
+ // Whether a section is running: what `checkScriptRowWrite` asks.
733
+ let sectionsRunning = 0;
734
+ function mutate(fn) {
735
+ const section = async () => {
736
+ sectionsRunning++;
737
+ try {
738
+ return await fn();
739
+ }
740
+ finally {
741
+ sectionsRunning--;
742
+ }
743
+ };
744
+ // Run after the prior section settles, regardless of its outcome.
745
+ const run = mutateChain.then(section, section);
746
+ // Advance the chain, swallowing the result so one section's rejection
747
+ // doesn't reject the next waiter's gate (the rejection still
748
+ // propagates to THIS caller via `run`).
749
+ mutateChain = run.then(() => undefined, () => undefined);
750
+ return run;
751
+ }
752
+ // Set to `true` the first time the SW seeds the default collections, so
753
+ // the seeding never runs again even after the user removes those
754
+ // collections.
755
+ const FIRST_RUN_DONE_KEY = 'firstRunComplete';
756
+ // Collections seeded on the very first run (by the collections page —
757
+ // see `seedDefaultCollectionsViaUi` in collections.ts). Each is fed
758
+ // through the normal add → Refresh flow, so any input shape
759
+ // `addCollection` accepts (github-dir / http(s) `.user.js` / manifest
760
+ // URL, or an absolute directory path) works here. `addCollection`
761
+ // dedupes by canonical collection path, so any accidental repeat
762
+ // collapses to one collection.
763
+ export const DEFAULT_COLLECTIONS = [
764
+ 'https://github.com/jshute96/SourceMonkey/tree/main/tests/examples',
765
+ ];
766
+ // ---------- Legacy key migration (temporary — see TODO.md) ----------
767
+ //
768
+ // Collections were called "sources" before the August 2026 rename, and
769
+ // a profile written then holds a `"sources"` index, `source:<id>` rows,
770
+ // and a `sourceId` back-reference on every script. Without this, an
771
+ // upgrade would read as a wiped profile: every collection gone, every
772
+ // script unregistered. Rewrite the rows once, on the first storage read
773
+ // in each JS context, and drop the old keys.
774
+ const LEGACY_INDEX_KEY = 'sources';
775
+ const LEGACY_COLLECTION_PREFIX = 'source:';
776
+ let migrationPromise = null;
777
+ // Idempotent, memoized per context. Awaited by every read path in this
778
+ // module, and exported for the two SW modules that read the structural
779
+ // rows straight out of `chrome.storage.local` (`match-index.ts` and
780
+ // `injector.ts`) rather than through here. `script-log-id.ts` reads
781
+ // `script:*` rows directly too and deliberately does NOT await: script
782
+ // keys don't move, and it reads only `relativePath`.
783
+ //
784
+ // Only the worker writes (docs/worker-writes.md): a page that finds the
785
+ // old rows asks it to move them, and waits.
786
+ export function ensureCollectionKeysMigrated() {
787
+ const migrate = isPageContext() ? migrateViaWorker : () => asMigration(migrateLegacyCollectionKeys);
788
+ migrationPromise ??= migrate().catch((err) => {
789
+ // Never let a failed migration wedge every read behind it: clear the
790
+ // memo so the next reader retries, and leave the old rows in place.
791
+ console.info('[SourceMonkey] collection-key migration failed', err);
792
+ migrationPromise = null;
793
+ });
794
+ return migrationPromise;
795
+ }
796
+ // Forget that this context has already checked. Only for the test
797
+ // surface's `resetAllState`, which wipes storage — the memo describes a
798
+ // profile that no longer exists, and a test seeding pre-rename rows
799
+ // afterwards would otherwise never see them migrated.
800
+ //
801
+ // Scope is the calling context only: `resetAllState` runs in the service
802
+ // worker, so an extension page already open keeps its own resolved memo.
803
+ // A test that seeds pre-rename rows has to open the page afterwards.
804
+ export function resetCollectionKeyMigrationForTests() {
805
+ migrationPromise = null;
806
+ }
807
+ async function migrateViaWorker() {
808
+ if (await hasLegacyRows())
809
+ await callWorker('migrateCollectionKeys');
810
+ }
811
+ async function hasLegacyRows() {
812
+ return LEGACY_INDEX_KEY in await chrome.storage.local.get(LEGACY_INDEX_KEY);
813
+ }
814
+ // The worker's answer to a page that found the old rows: the
815
+ // migration, then a failure if the rows are still there, so the page
816
+ // forgets its own memo and asks again on its next read.
817
+ export async function migrateCollectionKeysForPage() {
818
+ const first = ensureCollectionKeysMigrated();
819
+ await first;
820
+ // This worker's memo may predate the rows (old data restored since it
821
+ // checked): look again, once. Only the first page to ask resets it;
822
+ // another one asking meanwhile shares the new run.
823
+ if (await hasLegacyRows()) {
824
+ if (migrationPromise === first)
825
+ migrationPromise = null;
826
+ await ensureCollectionKeysMigrated();
827
+ }
828
+ if (await hasLegacyRows())
829
+ throw new Error('Internal error: Could not move the collections to their current storage keys');
830
+ }
831
+ async function migrateLegacyCollectionKeys() {
832
+ // Cheap gate first. A profile with any collection at all has the old
833
+ // index row, so its absence means there is nothing to move. The memo
834
+ // means this runs at most once per worker wake (a page's request can
835
+ // re-run it), and the full scan below reads every row.
836
+ const probe = await chrome.storage.local.get(LEGACY_INDEX_KEY);
837
+ if (!(LEGACY_INDEX_KEY in probe))
838
+ return;
839
+ const all = await scanAllStorage('key migration');
840
+ const toSet = {};
841
+ const toRemove = [LEGACY_INDEX_KEY];
842
+ // Seed the index ONLY when the new spelling isn't there yet. This runs
843
+ // outside `mutate()`, so a run that read `all` before an earlier run
844
+ // finished migrating would otherwise write the stale legacy id list
845
+ // over an index a later add had already extended — orphaning that
846
+ // collection's row from the index.
847
+ if (!(COLLECTIONS_INDEX_KEY in all)) {
848
+ const ids = all[LEGACY_INDEX_KEY];
849
+ toSet[COLLECTIONS_INDEX_KEY] = Array.isArray(ids) ? ids : [];
850
+ }
851
+ let scriptsMoved = 0;
852
+ for (const [key, value] of Object.entries(all)) {
853
+ if (key.startsWith(LEGACY_COLLECTION_PREFIX)) {
854
+ toSet[collectionKey(key.slice(LEGACY_COLLECTION_PREFIX.length))] = value;
855
+ toRemove.push(key);
856
+ continue;
857
+ }
858
+ if (!key.startsWith('script:'))
859
+ continue;
860
+ const row = value;
861
+ if (row?.sourceId === undefined)
862
+ continue;
863
+ const { sourceId, ...rest } = row;
864
+ toSet[key] = { ...rest, collectionId: rest.collectionId ?? sourceId };
865
+ scriptsMoved++;
866
+ }
867
+ // Write the new rows before dropping the old ones: an interrupted
868
+ // migration then leaves both spellings present, and the re-run is a
869
+ // no-op over the rows it already moved.
870
+ await setLocal(toSet);
871
+ await chrome.storage.local.remove(toRemove);
872
+ console.log(`[SourceMonkey] migrated ${toRemove.length - 1} collections and `
873
+ + `${scriptsMoved} scripts to the collection:* key spelling`);
874
+ }
875
+ // Script rows come back as full scripts, their bodies read from their
876
+ // blobs (script-blobs.ts). `getRows` reads them as stored, without the
877
+ // bodies (`ScriptMeta`), for code that doesn't need them.
878
+ async function getRaw(key) {
879
+ return (await getMany([key]))[key];
880
+ }
881
+ async function getMany(keys) {
882
+ return await hydrateScriptRows(await getRows(keys));
883
+ }
884
+ // Rows as stored. A script row written back from here with a field
885
+ // changed keeps its bodies (its blob refs) as they are, and the write
886
+ // hashes nothing.
887
+ //
888
+ // In the worker, from its row cache when that is loaded (row-cache.ts).
889
+ async function getRows(keys) {
890
+ if (keys.length === 0)
891
+ return {};
892
+ await ensureCollectionKeysMigrated();
893
+ const cached = cachedRows(keys);
894
+ if (!cached)
895
+ return await chrome.storage.local.get(keys);
896
+ if (cached.rest.length === 0)
897
+ return cached.found;
898
+ return { ...await chrome.storage.local.get(cached.rest), ...cached.found };
899
+ }
900
+ async function getRow(key) {
901
+ return (await getRows([key]))[key];
902
+ }
903
+ // ---------- Storage format ----------
904
+ //
905
+ // Format 2 keeps bodies out of the script rows (docs/storage.md#content-blobs).
906
+ // The service worker moves format 1 rows over once, at startup
907
+ // (`migrateToBlobs`). Reads take either format, so only writes wait for
908
+ // it; only the worker writes.
909
+ let storeFormatPromise = null;
910
+ // Resolves once script rows may be written. Never rejects, so a failure
911
+ // can't wedge every write behind it: a failed migration is logged and
912
+ // tried again in the next browser session (`migrateToBlobs`). Writes
913
+ // carry on meanwhile, in format 2, as they always do.
914
+ export function storeFormatReady() {
915
+ storeFormatPromise ??= migrateStore().catch((err) => {
916
+ console.info('[SourceMonkey] storage format migration failed:', describeError(err));
917
+ });
918
+ return storeFormatPromise;
919
+ }
920
+ async function migrateStore() {
921
+ // The old key spelling first: it moves rows the blob migration reads.
922
+ await ensureCollectionKeysMigrated();
923
+ await asMigration(migrateToBlobs);
924
+ }
925
+ // One unused-blob check (`sweepUnusedBlobs`), inside the write lock. A
926
+ // write of a script row can't land between the check reading the rows
927
+ // and deleting what they don't use: every one takes the lock too
928
+ // (`checkScriptRowWrite`). One that found a blob still stored, and so
929
+ // didn't write it again, would otherwise be left pointing at nothing.
930
+ //
931
+ // `started`: called once the check holds the lock; false skips the check
932
+ // (null result).
933
+ export function sweepUnusedBlobsLocked(started) {
934
+ return mutate(async () => {
935
+ if (started && !started())
936
+ return null;
937
+ await storeFormatReady();
938
+ return await sweepUnusedBlobs((await cachedScriptRows()) ?? undefined);
939
+ });
940
+ }
941
+ // For the test surface: rows a test writes straight into storage, under
942
+ // the lock like every other write of a script row
943
+ // (`checkScriptRowWrite`).
944
+ export function seedStorageForTests(items) {
945
+ return mutate(() => chrome.storage.local.set(items));
946
+ }
947
+ // The migrations write script rows outside the lock: they run before
948
+ // any write that takes it (`storeFormatReady`), and the cleanup waits
949
+ // for them the same way. While one runs, every script-row write is let
950
+ // through, so a legacy-key migration started mid-session (a page found
951
+ // old rows) widens the check for its length. Rare: old rows restored
952
+ // into a running profile.
953
+ //
954
+ // That mid-session run is also unguarded: it writes the script rows as
955
+ // its scan read them, so a `removeScript` landing between the scan and
956
+ // the write gets its row written back (its blobs possibly already
957
+ // swept). It isn't moved under the lock because reads inside the lock
958
+ // await this migration, so a section waiting on it would deadlock.
959
+ let migrationsRunning = 0;
960
+ async function asMigration(run) {
961
+ migrationsRunning++;
962
+ try {
963
+ return await run();
964
+ }
965
+ finally {
966
+ migrationsRunning--;
967
+ }
968
+ }
969
+ // The worker's wrapped write calls this before every write (row-cache.ts):
970
+ // a script row written outside the lock could race the blob cleanup.
971
+ // A bug, so it throws. It sees whether SOME section is running, not
972
+ // whether this write is in it: a write outside the lock made while a
973
+ // section runs passes, so this catches most mistakes, not all.
974
+ export function checkScriptRowWrite(keys) {
975
+ if (sectionsRunning > 0 || migrationsRunning > 0)
976
+ return;
977
+ const row = keys.find((k) => k.startsWith(SCRIPT_PREFIX));
978
+ if (row)
979
+ throw new Error(`Internal error: ${row} was written outside the store's write lock`);
980
+ }
981
+ // Test-only: run the migration again, as a worker start would. For
982
+ // tests that seed format 1 rows.
983
+ export async function rerunStoreMigrationForTests() {
984
+ await forgetStoreMigrationForTests();
985
+ await storeFormatReady();
986
+ }
987
+ // Test-only: as a worker just started, before anything asked for the
988
+ // format. The next write here runs the migration.
989
+ export async function forgetStoreMigrationForTests() {
990
+ storeFormatPromise = null;
991
+ await clearMigrationFailureForTests();
992
+ }
993
+ // Only for the test surface's `resetAllState`, which wipes storage: the
994
+ // worker records the empty profile as format 2 again, as a fresh
995
+ // profile's migration would, so a worker started later in the test
996
+ // doesn't scan for format 1 rows.
997
+ export async function markStoreFormatForTests() {
998
+ await chrome.storage.local.set({ [STORE_FORMAT_KEY]: STORE_FORMAT });
999
+ }
1000
+ async function readCollectionIndex() {
1001
+ return (await getRaw(COLLECTIONS_INDEX_KEY)) ?? [];
1002
+ }
1003
+ // Test-only: while set, EVERY collection-refresh write throws this
1004
+ // message instead of touching storage.
1005
+ //
1006
+ // Module state, so it only arms the context that set it. The service
1007
+ // worker and each extension page run their own copy of this module, and
1008
+ // the collections page does its own storage work — so a test arming
1009
+ // this over the SW test surface does NOT affect an add or refresh the
1010
+ // page performs itself. Sticky until cleared with null —
1011
+ // the out-of-quota case it stands in for fails every collection, not
1012
+ // just the next one, and a test covering that needs one arming call.
1013
+ //
1014
+ // Exists because the failure it stands in for — an exhausted
1015
+ // `chrome.storage.local` quota — is impractical to produce honestly in a
1016
+ // test. Filling 10 MB means writing ~10 MB of userscripts through the
1017
+ // real pipeline on every run, which is slow, and it leaves the profile
1018
+ // wedged for whatever runs next. The reporting path is the same either
1019
+ // way: the write throws, and everything downstream reacts to that.
1020
+ let collectionWriteFailure = null;
1021
+ export function _setCollectionWriteFailureForTests(message) {
1022
+ collectionWriteFailure = message;
1023
+ }
1024
+ // How full `chrome.storage.local` is, or null when the browser won't
1025
+ // say. `quota` is Chrome's own `QUOTA_BYTES` — 10 MB shared by the whole
1026
+ // extension, because the manifest does not take `unlimitedStorage`.
1027
+ //
1028
+ // That coupling is load-bearing and unenforced: `QUOTA_BYTES` reads the
1029
+ // same 10485760 whether or not the permission is held, so taking
1030
+ // `unlimitedStorage` (still an open option in TODO.md) would leave this
1031
+ // reporting a ceiling that no longer applies, and the collections page
1032
+ // calling a healthy profile full. Revisit here if that permission lands.
1033
+ //
1034
+ // Read live rather than tracked, so it needs nothing persisted and is
1035
+ // correct after a write from any context. The collections page uses it
1036
+ // to explain a refresh that couldn't be saved: "the write failed" is a
1037
+ // symptom, "storage is full" is the cause and the thing the user can
1038
+ // act on.
1039
+ export async function readLocalStorageUsage() {
1040
+ try {
1041
+ const quota = chrome.storage.local.QUOTA_BYTES;
1042
+ if (typeof quota !== 'number' || quota <= 0)
1043
+ return null;
1044
+ return { used: await chrome.storage.local.getBytesInUse(null), quota };
1045
+ }
1046
+ catch {
1047
+ // Purely advisory — a page that can't measure simply doesn't show
1048
+ // the banner.
1049
+ return null;
1050
+ }
1051
+ }
1052
+ // Fraction of the quota at which the page starts warning. Below this a
1053
+ // refresh that failed to save had some other cause, and pointing at
1054
+ // storage would send the reader somewhere useless.
1055
+ export const STORAGE_NEARLY_FULL = 0.9;
1056
+ // Read every registered collection's metadata (without scripts). Order
1057
+ // preserved from the index.
1058
+ export async function getCollections() {
1059
+ const ids = await readCollectionIndex();
1060
+ const got = await getMany(ids.map(collectionKey));
1061
+ return ids
1062
+ .map((id) => got[collectionKey(id)])
1063
+ .filter((s) => s != null);
1064
+ }
1065
+ // Read a collection plus all its scripts in declared order.
1066
+ export async function getCollectionWithScripts(id) {
1067
+ const collection = await getRaw(collectionKey(id));
1068
+ if (!collection)
1069
+ return null;
1070
+ const got = await getMany(collection.scriptIds.map(scriptKey));
1071
+ const scripts = collection.scriptIds
1072
+ .map((sid) => got[scriptKey(sid)])
1073
+ .filter((s) => s != null);
1074
+ return { collection, scripts };
1075
+ }
1076
+ // Same as `getCollectionWithScripts`, without the scripts' bodies.
1077
+ export async function getCollectionWithScriptMeta(id) {
1078
+ const collection = await getRow(collectionKey(id));
1079
+ if (!collection)
1080
+ return null;
1081
+ return { collection, scripts: await getScriptMetas(collection.scriptIds) };
1082
+ }
1083
+ export async function getScript(scriptId) {
1084
+ return await getRaw(scriptKey(scriptId));
1085
+ }
1086
+ // A full script as its row would read without the bodies, for a caller
1087
+ // that holds one and needs only the rest (an answer sent to a page).
1088
+ export function withoutBodies(script) {
1089
+ const { bodyText, requires, resources, ...rest } = script;
1090
+ return {
1091
+ ...rest,
1092
+ ...(requires ? { requires: requires.map(({ source, ...r }) => r) } : {}),
1093
+ ...(resources ? { resources: resources.map(({ bytesBase64, ...r }) => r) } : {}),
1094
+ };
1095
+ }
1096
+ export async function getScriptMeta(scriptId) {
1097
+ return await getRow(scriptKey(scriptId));
1098
+ }
1099
+ // Read several scripts' rows, without their bodies, in one storage
1100
+ // call. Missing ids are left out.
1101
+ export async function getScriptMetas(scriptIds) {
1102
+ const got = await getRows(scriptIds.map(scriptKey));
1103
+ return scriptIds
1104
+ .map((id) => got[scriptKey(id)])
1105
+ .filter((s) => s != null);
1106
+ }
1107
+ // Every stored script without its bodies (across all collections,
1108
+ // regardless of status): only the script rows are read.
1109
+ //
1110
+ // In the worker, from its row cache, which this loads (row-cache.ts).
1111
+ export async function getAllScriptMetas(reason = 'all script rows') {
1112
+ await ensureCollectionKeysMigrated();
1113
+ const rows = await cachedScriptRows()
1114
+ ?? await scanStorageRows(reason, (k) => k.startsWith(SCRIPT_PREFIX));
1115
+ return Object.values(rows);
1116
+ }
1117
+ // Read every stored script (across all collections, regardless of
1118
+ // status), bodies included. One `get(null)` scan rather than N
1119
+ // per-collection reads. Prefer `getAllScriptMetas` unless the bodies
1120
+ // are needed.
1121
+ export async function getAllScripts() {
1122
+ await ensureCollectionKeysMigrated();
1123
+ const all = await scanAllStorage('all scripts');
1124
+ const rows = [];
1125
+ for (const [key, value] of Object.entries(all)) {
1126
+ if (key.startsWith('script:'))
1127
+ rows.push(value);
1128
+ }
1129
+ return await fromStorage(rows, all);
1130
+ }
1131
+ // Several collection rows in one storage call. Missing ids are left out.
1132
+ export async function getCollectionsById(ids) {
1133
+ const got = await getRows(ids.map(collectionKey));
1134
+ return ids
1135
+ .map((id) => got[collectionKey(id)])
1136
+ .filter((c) => c != null);
1137
+ }
1138
+ export async function getCollection(id) {
1139
+ return await getRaw(collectionKey(id));
1140
+ }
1141
+ // Messages shared by the store's collection-shape checks and the
1142
+ // collection dialog's live validation, so the dialog can't say one
1143
+ // thing and the store another.
1144
+ export const COLLECTION_ALREADY_REGISTERED = 'This collection is already registered';
1145
+ export const COLLECTION_NAME_REQUIRED = 'Name is required for scripts collections';
1146
+ export const COLLECTION_GONE = 'This collection no longer exists';
1147
+ export function collectionNameInUseMessage(name) {
1148
+ return `Collection named "${name.trim()}" already exists`;
1149
+ }
1150
+ // Register a new collection from a raw user input — the Add field's
1151
+ // one-string entry point. Dispatches by input shape:
1152
+ // - single-file `.user.js` URL/path → installed into the built-in
1153
+ // default container for its scheme (created on demand). The script
1154
+ // is added to the container's `scriptIds` and its initial fetch
1155
+ // runs immediately so the table populates without a manual
1156
+ // Refresh.
1157
+ // - everything else (directory, manifest, github-dir URL) → a new
1158
+ // location-backed collection, as `createCollection` would make.
1159
+ //
1160
+ // `normalizeCollectionInput` runs first so a github.com
1161
+ // `/blob/.../foo.user.js` (or `/blob/.../script_manifest.json`) URL
1162
+ // gets rewritten to its raw counterpart before we hit the duplicate
1163
+ // check. The github blob URL serves HTML, not raw bytes, so storing it
1164
+ // verbatim would silently load the page chrome instead of the script —
1165
+ // see [docs/http-collections.md].
1166
+ export async function addCollection(rawPath) {
1167
+ const path = await canonicalLocation(rawPath);
1168
+ if (isSingleFileInput(path)) {
1169
+ return await addScriptToContainer(path, { schemeDefault: true });
1170
+ }
1171
+ return await createLocationCollection(path, {});
1172
+ }
1173
+ // Two canonicalizations, in order: provider rewriting (github blob →
1174
+ // raw), then the one-spelling-per-location rule. The second is what
1175
+ // keeps `/x/a.user.js` typed into the collections page and
1176
+ // `file:///x/a.user.js` arriving from a captured navigation from
1177
+ // becoming two rows for one file — see `canonicalCollectionInput`.
1178
+ export async function canonicalLocation(rawPath) {
1179
+ return canonicalCollectionInput(await normalizeCollectionInput(rawPath));
1180
+ }
1181
+ // Create a collection the collection dialog described. A scripts
1182
+ // collection is an empty container row with the user's name; a
1183
+ // location collection is what `addCollection` makes, plus the name and
1184
+ // local-files choice from the dialog.
1185
+ export async function createCollection(spec) {
1186
+ if (spec.kind === 'scripts') {
1187
+ const name = spec.name.trim();
1188
+ if (!name)
1189
+ throw new Error(COLLECTION_NAME_REQUIRED);
1190
+ const created = await mutate(async () => {
1191
+ const collections = await getCollections();
1192
+ if (!mayUseReservedName(name, null))
1193
+ throw new Error(RESERVED_COLLECTION_NAME);
1194
+ if (collectionNameInUse(name, collections)) {
1195
+ throw new Error(collectionNameInUseMessage(name));
1196
+ }
1197
+ const id = crypto.randomUUID();
1198
+ const seed = {
1199
+ id,
1200
+ path: newContainerPath(),
1201
+ lastRefreshedAt: 0,
1202
+ scriptIds: [],
1203
+ enabled: true,
1204
+ displayName: name,
1205
+ };
1206
+ if (spec.localFiles === false)
1207
+ seed.localFiles = false;
1208
+ await writeLocal({
1209
+ [COLLECTIONS_INDEX_KEY]: [...collections.map((s) => s.id), id],
1210
+ [collectionKey(id)]: seed,
1211
+ });
1212
+ return seed;
1213
+ });
1214
+ return { kind: 'newCollection', collection: created };
1215
+ }
1216
+ const path = await canonicalLocation(spec.path);
1217
+ if (isSingleFileInput(path))
1218
+ throw new Error(SINGLE_SCRIPT_USE_ADD_FIELD);
1219
+ return await createLocationCollection(path, spec);
1220
+ }
1221
+ // What the collection dialog runs before creating a location-backed
1222
+ // collection: the same listing / manifest fetch / GitHub lookup a
1223
+ // refresh does, so a location that can't be read is refused in the
1224
+ // dialog instead of landing as a collection stuck in a scan error.
1225
+ // Nothing is written. The count lets the dialog warn about an empty
1226
+ // directory without refusing it — a directory can fill up later.
1227
+ export async function probeCollectionLocation(rawPath) {
1228
+ const path = await canonicalLocation(rawPath);
1229
+ if (isSingleFileInput(path))
1230
+ throw new Error(SINGLE_SCRIPT_USE_ADD_FIELD);
1231
+ const found = await scanForUserScripts(path);
1232
+ return { path, scriptCount: found.length };
1233
+ }
1234
+ // Mint a UUID, persist an empty collection row for an already-canonical
1235
+ // location, then refresh to populate the scripts.
1236
+ async function createLocationCollection(path, opts) {
1237
+ // Only a name the user actually chose is held to the uniqueness rule.
1238
+ // Inferred names can legitimately collide — two refs of one GitHub
1239
+ // tree both read as `<owner>/<repo>/<path>` — and refusing those
1240
+ // would block adding a real, distinct collection.
1241
+ const inferred = inferDisplayName(path);
1242
+ const chosen = opts.name?.trim() || '';
1243
+ const explicitName = chosen && chosen !== inferred ? chosen : '';
1244
+ // Dedup-and-register atomically so two concurrent adds of the same
1245
+ // path can't both create a collection (and so this index write can't
1246
+ // lose a race with another index mutator). The refresh runs OUTSIDE
1247
+ // the lock (it acquires the lock itself for its own write).
1248
+ const created = await mutate(async () => {
1249
+ const collections = await getCollections();
1250
+ // Canonicalize the stored side too, for the same reason the
1251
+ // container check does: a collection row written before
1252
+ // `addCollection` collapsed `file://` inputs still carries the URL
1253
+ // spelling.
1254
+ if (collections.some((s) => canonicalCollectionInput(s.path) === path))
1255
+ return null;
1256
+ if (explicitName) {
1257
+ if (!mayUseReservedName(explicitName, null))
1258
+ throw new Error(RESERVED_COLLECTION_NAME);
1259
+ if (collectionNameInUse(explicitName, collections)) {
1260
+ throw new Error(collectionNameInUseMessage(explicitName));
1261
+ }
1262
+ }
1263
+ const id = crypto.randomUUID();
1264
+ const seed = {
1265
+ id,
1266
+ path,
1267
+ lastRefreshedAt: 0,
1268
+ scriptIds: [],
1269
+ enabled: true,
1270
+ // Seed a displayName at add-time so a viewer opened mid-add (or
1271
+ // any other reader) doesn't briefly see a row whose name only
1272
+ // materialises on the next backfill pass.
1273
+ displayName: explicitName || inferred,
1274
+ };
1275
+ if (opts.localFiles === false)
1276
+ seed.localFiles = false;
1277
+ const nextIndex = [...collections.map((s) => s.id), id];
1278
+ await writeLocal({
1279
+ [COLLECTIONS_INDEX_KEY]: nextIndex,
1280
+ [collectionKey(id)]: seed,
1281
+ });
1282
+ return { id, seed };
1283
+ });
1284
+ if (!created)
1285
+ return null;
1286
+ const { id, seed } = created;
1287
+ const refreshed = await refreshCollection(id, { adding: true });
1288
+ // The updater-driven refresh paths write their own per-script Log
1289
+ // entries. This initial-add path bypasses the updater, so each new
1290
+ // script's install line, failure and warnings are written here (see
1291
+ // `emitInitialLoadLog`). Awaited so the loglevel sentinel write
1292
+ // lands before the caller's `getAllMaxLogLevels` read — otherwise
1293
+ // the first paint of the row would briefly show OK and only flip on
1294
+ // the storage-change listener.
1295
+ if (refreshed) {
1296
+ await Promise.all(refreshed.scripts.map((s) => emitInitialLoadLog(s, true)));
1297
+ }
1298
+ return { kind: 'newCollection', collection: refreshed?.collection ?? seed };
1299
+ }
1300
+ // First-run gate. Seeding the default collections is driven from the
1301
+ // collections page (so the user watches the normal add → Refresh flow,
1302
+ // rather than scripts appearing silently from the SW); the SW only
1303
+ // opens that page on install. These two helpers are the persisted flag
1304
+ // the page and SW share.
1305
+ export async function isFirstRunComplete() {
1306
+ return (await getRaw(FIRST_RUN_DONE_KEY)) === true;
1307
+ }
1308
+ // Record that first-run handling is done so the SW stops opening the
1309
+ // seeding page and the collections page stops re-seeding — including
1310
+ // after the user deletes the seeded collections. Also called by the
1311
+ // test-surface reset so a cleared store isn't re-seeded.
1312
+ export async function markFirstRunComplete() {
1313
+ await writeLocal({ [FIRST_RUN_DONE_KEY]: true });
1314
+ }
1315
+ // Install one `.user.js` location into a container collection. Returns
1316
+ // null if the location is already a script in THAT container — the
1317
+ // same location may be installed into several containers, which is
1318
+ // what the "Other versions" panel and its exclusive-enable rule exist
1319
+ // for. The fetch runs as part of `refreshCollection` so the new row
1320
+ // lands populated.
1321
+ export async function installScript(rawLocation, target) {
1322
+ const path = await canonicalLocation(rawLocation);
1323
+ if (!isSingleFileInput(path)) {
1324
+ throw new Error('Internal error: only a .user.js location can be installed into a collection');
1325
+ }
1326
+ return await addScriptToContainer(path, target);
1327
+ }
1328
+ // ---------- Scratch scripts ----------
1329
+ //
1330
+ // Scripts whose only copy is their text in storage (see directories.ts).
1331
+ // Only sm-dev writes them (docs/sm-dev-design.md). Each sm-dev run
1332
+ // gets its own collection of them, its test collection, shown on the
1333
+ // collections page like any other; their text is a storage row the
1334
+ // fetcher reads back, so every refresh path treats them as ordinary
1335
+ // scripts.
1336
+ // The bodies pushed with a scratch script, or undefined when none were
1337
+ // (or the script isn't one).
1338
+ async function pushedFilesOf(url) {
1339
+ if (!isScratchUrl(url))
1340
+ return undefined;
1341
+ const files = await getRaw(scratchFilesKey(url));
1342
+ return files && Object.keys(files).length > 0 ? files : undefined;
1343
+ }
1344
+ // The name a run's test collection is shown under.
1345
+ export function smDevCollectionName(run) {
1346
+ return `sm-dev test collection (run ${run})`;
1347
+ }
1348
+ // Install or replace one of run `run`'s scripts, in its test collection.
1349
+ // The name (the `@name`, or one the caller gives) is the identity:
1350
+ // writing it again replaces the earlier text in place, keeping the row's
1351
+ // id and its GM values, which is what lets a page reload hot-swap the
1352
+ // new version. A new script that doesn't parse is reported and not
1353
+ // kept, so the collection holds only scripts the run has.
1354
+ export async function writeRunScript(args) {
1355
+ // Its scripts' URLs carry it as is, and '' would be no run's.
1356
+ if (!/^[A-Za-z0-9._-]+$/.test(args.run))
1357
+ throw new Error(`Internal error: ${JSON.stringify(args.run)} is not an sm-dev run id`);
1358
+ const header = parseHeader(args.text);
1359
+ const name = args.name?.trim() || (header ? getValue(header, 'name')?.trim() : '') || 'script';
1360
+ const url = scratchUrl(args.run, name);
1361
+ // The text and its files in one write, so no refresh reads one
1362
+ // without the other. Replaced whole, so a body the script no longer
1363
+ // reads from disk goes.
1364
+ const hasFiles = args.files !== undefined && Object.keys(args.files).length > 0;
1365
+ const source = args.source ? { fileUrl: args.source, given: name } : undefined;
1366
+ await writeLocal({
1367
+ [scratchTextKey(url)]: args.text,
1368
+ ...(hasFiles ? { [scratchFilesKey(url)]: args.files } : {}),
1369
+ ...(source ? { [scratchSourceKey(url)]: source } : {}),
1370
+ });
1371
+ const stale = [...(hasFiles ? [] : [scratchFilesKey(url)]), ...(source ? [] : [scratchSourceKey(url)])];
1372
+ if (stale.length > 0)
1373
+ await removeLocal(stale);
1374
+ const container = (await getCollections()).find((c) => c.path === smDevContainerPath(args.run));
1375
+ let scriptId;
1376
+ let collectionId;
1377
+ let replaced = false;
1378
+ if (container) {
1379
+ const rows = await getRows(container.scriptIds.map(scriptKey));
1380
+ for (const sid of container.scriptIds) {
1381
+ const row = rows[scriptKey(sid)];
1382
+ if (row?.relativePath === url) {
1383
+ scriptId = sid;
1384
+ collectionId = container.id;
1385
+ replaced = true;
1386
+ }
1387
+ }
1388
+ }
1389
+ if (scriptId === undefined) {
1390
+ let result;
1391
+ try {
1392
+ result = await installScript(url, { smDevRun: args.run });
1393
+ }
1394
+ catch (err) {
1395
+ // The install took its seed back out; the text rows go with it.
1396
+ await removeLocal([scratchTextKey(url), scratchFilesKey(url), scratchSourceKey(url)]);
1397
+ throw err;
1398
+ }
1399
+ if (!result)
1400
+ throw new Error('Internal error: the scratch script was neither installed nor found');
1401
+ collectionId = result.collection.id;
1402
+ scriptId = result.kind === 'existingContainer'
1403
+ ? result.scriptId
1404
+ : result.collection.scriptIds[result.collection.scriptIds.length - 1];
1405
+ }
1406
+ else {
1407
+ // Re-read the text; the scratch fetcher has no size or mtime to skip
1408
+ // on, so the new content lands and the hash says whether it changed.
1409
+ await refreshScript(scriptId);
1410
+ }
1411
+ const row = await getScript(scriptId);
1412
+ if (!row)
1413
+ throw new Error('Internal error: the scratch script vanished during install');
1414
+ const out = { scriptId, collectionId: collectionId, status: row.status, replaced };
1415
+ if (row.errorMessage)
1416
+ out.errorMessage = row.errorMessage;
1417
+ if (row.errorLocation)
1418
+ out.errorLocation = row.errorLocation;
1419
+ if (!replaced && row.status !== 'ok')
1420
+ await removeScript(scriptId);
1421
+ return out;
1422
+ }
1423
+ // Remove run `run`'s test collection, its rows, their GM values, and the
1424
+ // text rows behind them. Returns what went, for the caller's
1425
+ // registration sync. `removeCollection` deletes the text rows, keeping
1426
+ // any that a copy moved into another collection still reads.
1427
+ export async function removeSmDevCollection(run) {
1428
+ const container = (await getCollections()).find((c) => smDevRunOfContainer(c.path) === run);
1429
+ if (!container)
1430
+ return { scriptIds: [], collectionId: null };
1431
+ for (const sid of container.scriptIds)
1432
+ await wipeValues(sid);
1433
+ await removeCollection(container.id);
1434
+ return { scriptIds: container.scriptIds, collectionId: container.id };
1435
+ }
1436
+ // The runs that have a test collection.
1437
+ export async function smDevCollectionRuns() {
1438
+ return (await getCollections()).map((c) => smDevRunOfContainer(c.path)).filter((run) => run !== null);
1439
+ }
1440
+ // Does this container already hold `canonicalPath`?
1441
+ //
1442
+ // A container holds one row per location, and both the install and the
1443
+ // move path have to agree on what "the same location" means — so the
1444
+ // rule lives here rather than being spelled twice. The STORED value is
1445
+ // canonicalized too: rows written before `addCollection` started
1446
+ // collapsing `file://` inputs still carry the URL spelling, and
1447
+ // comparing raw would offer a second copy of a file we already have.
1448
+ //
1449
+ // `canonicalPath` must already be through `canonicalCollectionInput`.
1450
+ // Caller-side, because both callers have it in hand.
1451
+ async function containerHoldsLocation(container, canonicalPath) {
1452
+ const rows = await getRows(container.scriptIds.map(scriptKey));
1453
+ for (const sid of container.scriptIds) {
1454
+ const s = rows[scriptKey(sid)];
1455
+ if (s && canonicalCollectionInput(s.relativePath) === canonicalPath)
1456
+ return true;
1457
+ }
1458
+ return false;
1459
+ }
1460
+ // A brand-new container row. One spelling of the default field values,
1461
+ // so an install-created container and a move-created one can't drift
1462
+ // apart on `enabled`, the inferred name, or the never-refreshed marker.
1463
+ function newContainerRow(id, path, scriptIds) {
1464
+ return {
1465
+ id,
1466
+ path,
1467
+ lastRefreshedAt: 0,
1468
+ scriptIds,
1469
+ enabled: true,
1470
+ displayName: inferDisplayName(path),
1471
+ };
1472
+ }
1473
+ async function addScriptToContainer(path, target) {
1474
+ // Dedup-check and seed the container + script atomically so a
1475
+ // concurrent install (or index mutator) can't lose this scriptIds/index
1476
+ // write. The refresh runs OUTSIDE the lock. Returns null on duplicate.
1477
+ const seeded = await mutate(async () => {
1478
+ const collections = await getCollections();
1479
+ let existingContainer;
1480
+ let cPath;
1481
+ if ('collectionId' in target) {
1482
+ existingContainer = collections.find((s) => s.id === target.collectionId);
1483
+ // The dropdown was built from a snapshot; the row can have been
1484
+ // removed from another tab since.
1485
+ if (!existingContainer)
1486
+ throw new Error(COLLECTION_GONE);
1487
+ if (!isContainerCollection(existingContainer.path)) {
1488
+ throw new Error('Internal error: scripts can only be installed into a scripts collection');
1489
+ }
1490
+ if (isSmDevCollection(existingContainer.path))
1491
+ throw new Error(SM_DEV_COLLECTION_LOCKED);
1492
+ cPath = existingContainer.path;
1493
+ }
1494
+ else if ('smDevRun' in target) {
1495
+ cPath = smDevContainerPath(target.smDevRun);
1496
+ existingContainer = collections.find((s) => s.path === cPath);
1497
+ }
1498
+ else {
1499
+ cPath = containerPath(containerKindForSingleFileInput(path));
1500
+ existingContainer = collections.find((s) => s.path === cPath);
1501
+ }
1502
+ const isNew = !existingContainer;
1503
+ // Each container script's `relativePath` is the full URL/path the
1504
+ // user added; any prior row with the same value is a duplicate.
1505
+ // `path` arrived canonical (see `installScript`).
1506
+ if (existingContainer && await containerHoldsLocation(existingContainer, path))
1507
+ return null;
1508
+ const now = Date.now();
1509
+ const containerId = existingContainer?.id ?? crypto.randomUUID();
1510
+ const scriptId = crypto.randomUUID();
1511
+ const priorScriptIds = existingContainer?.scriptIds ?? [];
1512
+ const nextContainer = existingContainer
1513
+ ? { ...existingContainer, scriptIds: [...priorScriptIds, scriptId] }
1514
+ : newContainerRow(containerId, cPath, [scriptId]);
1515
+ if (isNew && 'smDevRun' in target)
1516
+ nextContainer.displayName = smDevCollectionName(target.smDevRun);
1517
+ const seedScript = buildInitialInvalidEntry({
1518
+ id: scriptId,
1519
+ collectionId: containerId,
1520
+ relativePath: path,
1521
+ fileUrl: scriptFileUrl(cPath, path),
1522
+ errorMessage: NOT_LOADED_YET,
1523
+ now,
1524
+ });
1525
+ // Single set call so a SW restart between writes can never leave a
1526
+ // half-populated container — either the new script lands with its
1527
+ // container's scriptIds pointing at it (and, for a new container,
1528
+ // the collections index pointing at the container), or nothing does.
1529
+ const toSet = {
1530
+ [collectionKey(containerId)]: nextContainer,
1531
+ [scriptKey(scriptId)]: seedScript,
1532
+ };
1533
+ if (isNew) {
1534
+ toSet[COLLECTIONS_INDEX_KEY] = [...collections.map((s) => s.id), containerId];
1535
+ }
1536
+ await writeLocal(toSet);
1537
+ return { isNew, containerId, scriptId, nextContainer };
1538
+ });
1539
+ if (!seeded)
1540
+ return null;
1541
+ const { isNew, containerId, scriptId, nextContainer } = seeded;
1542
+ let refreshed;
1543
+ try {
1544
+ refreshed = await refreshCollection(containerId);
1545
+ }
1546
+ catch (err) {
1547
+ // The seed above is a placeholder with no content and no header —
1548
+ // it only becomes a script once this refresh writes the fetched
1549
+ // body. When that write is refused (an exhausted storage quota is
1550
+ // the realistic case — though anything the refresh throws lands
1551
+ // here), leaving the seed behind installs a row that
1552
+ // matches nothing, runs nothing, and shows "Not loaded yet" until
1553
+ // the user notices and refreshes it by hand. Nothing else about the
1554
+ // install has happened yet, so take the seed back out and let the
1555
+ // install fail as the failure it is. A fetch that fails is
1556
+ // different: the refresh then WRITES the row with its error, and
1557
+ // that row is the honest record of an install that landed.
1558
+ await unseedScript(scriptId, containerId, isNew);
1559
+ throw err;
1560
+ }
1561
+ if (refreshed) {
1562
+ await Promise.all(refreshed.scripts.map((s) => emitInitialLoadLog(s, s.id === scriptId)));
1563
+ }
1564
+ const finalCollection = refreshed?.collection ?? nextContainer;
1565
+ if (isNew)
1566
+ return { kind: 'newCollection', collection: finalCollection };
1567
+ return { kind: 'existingContainer', collection: finalCollection, scriptId };
1568
+ }
1569
+ // Undo `addScriptToContainer`'s seed after its refresh could not be
1570
+ // saved: drop the placeholder row and its slot in the container, and
1571
+ // the container itself (plus its index entry) when this install created
1572
+ // it. Re-read under the lock rather than replayed from the seed: another
1573
+ // tab can have installed into, or removed, the same container since.
1574
+ //
1575
+ // Every write here makes storage smaller — a `remove`, or a `set` of a
1576
+ // row with one id fewer — which Chrome accepts on a full quota, so this
1577
+ // works in exactly the state that made it necessary. Best effort all the
1578
+ // same: the caller is already reporting a failure, and a rollback that
1579
+ // can't land must not replace that report with its own.
1580
+ async function unseedScript(scriptId, containerId, createdContainer) {
1581
+ try {
1582
+ await mutate(async () => {
1583
+ const container = await getRaw(collectionKey(containerId));
1584
+ const remaining = container?.scriptIds.filter((id) => id !== scriptId) ?? [];
1585
+ const dropContainer = createdContainer && remaining.length === 0;
1586
+ const toRemove = [scriptKey(scriptId)];
1587
+ const toSet = {};
1588
+ if (container) {
1589
+ if (dropContainer) {
1590
+ toRemove.push(collectionKey(containerId));
1591
+ const index = await readCollectionIndex();
1592
+ toSet[COLLECTIONS_INDEX_KEY] = index.filter((id) => id !== containerId);
1593
+ }
1594
+ else if (remaining.length !== container.scriptIds.length) {
1595
+ toSet[collectionKey(containerId)] = { ...container, scriptIds: remaining };
1596
+ }
1597
+ }
1598
+ // The `set` first: it is the only step Chrome can refuse, so if
1599
+ // it does, nothing has changed. A `remove` can't be refused, so
1600
+ // the pair only splits if the worker dies between them — leaving
1601
+ // an unlisted row, which `reconcileScriptMembership` re-lists.
1602
+ if (Object.keys(toSet).length > 0)
1603
+ await writeLocal(toSet);
1604
+ await removeLocal(toRemove);
1605
+ if (dropContainer)
1606
+ await setRefreshError(containerId, null);
1607
+ });
1608
+ }
1609
+ catch (err) {
1610
+ // Not debug-gated: the state this leaves (a "Not loaded yet" row
1611
+ // nothing will explain) is otherwise invisible.
1612
+ console.info(`[SourceMonkey] could not undo a failed install: ${describeError(err)}`);
1613
+ }
1614
+ }
1615
+ export const SCRIPT_GONE = 'This script no longer exists';
1616
+ // The one thing both `installScript` and `moveScript` decline: the
1617
+ // target container already holds this location. Shared, so the install
1618
+ // callout and the viewer's Move picker can't word the same refusal two
1619
+ // ways.
1620
+ export const ALREADY_INSTALLED_IN_TARGET = 'This script is already installed there';
1621
+ // Both ends of a move have to be container collections. A directory or
1622
+ // manifest script's `relativePath` is relative to its collection root,
1623
+ // so moving it would mean rewriting it to an absolute location — a
1624
+ // different feature. The UI never offers the button in that case, so
1625
+ // this is a wiring error, not something the user can act on.
1626
+ export const NOT_A_MOVABLE_SCRIPT = 'Internal error: only a script in an individual scripts collection can be moved';
1627
+ // The pages never offer it (see `installOptionsFor`); said if another
1628
+ // path tries.
1629
+ export const SM_DEV_COLLECTION_LOCKED = 'Scripts can\'t be moved into or out of an sm-dev test collection, or installed into one, since only its sm-dev run changes it';
1630
+ // Move one already-installed script from its container collection into
1631
+ // another. The stored row keeps its `id`, so its Chrome registration,
1632
+ // its `gmval:` rows, and its Log all follow it untouched — only the
1633
+ // `collectionId` and the two `scriptIds` lists change.
1634
+ //
1635
+ // Returns null when the destination already holds this location (the
1636
+ // same case `installScript` returns null for) or is the collection the
1637
+ // script is already in.
1638
+ //
1639
+ // What DOES change with the parent: whether the script runs at all
1640
+ // (`isRegistrationCandidate` requires the collection to be enabled
1641
+ // too), and the local-stand-in policy — so a move between collections
1642
+ // that disagree about `localFiles` ends with a refresh, because the
1643
+ // `@require` / `@resource` bodies were resolved under the old rule.
1644
+ export async function moveScript(scriptId, target) {
1645
+ const moved = await mutate(async () => {
1646
+ const script = await getRow(scriptKey(scriptId));
1647
+ if (!script)
1648
+ throw new Error(SCRIPT_GONE);
1649
+ const source = await getRaw(collectionKey(script.collectionId));
1650
+ if (!source || !isContainerCollection(source.path))
1651
+ throw new Error(NOT_A_MOVABLE_SCRIPT);
1652
+ if (isSmDevCollection(source.path))
1653
+ throw new Error(SM_DEV_COLLECTION_LOCKED);
1654
+ const collections = await getCollections();
1655
+ let dest;
1656
+ let destPath;
1657
+ if ('collectionId' in target) {
1658
+ dest = collections.find((c) => c.id === target.collectionId);
1659
+ // The dropdown was built from a snapshot; the row can have been
1660
+ // removed from another tab since.
1661
+ if (!dest)
1662
+ throw new Error(COLLECTION_GONE);
1663
+ if (!isContainerCollection(dest.path))
1664
+ throw new Error(NOT_A_MOVABLE_SCRIPT);
1665
+ if (isSmDevCollection(dest.path))
1666
+ throw new Error(SM_DEV_COLLECTION_LOCKED);
1667
+ destPath = dest.path;
1668
+ }
1669
+ else {
1670
+ destPath = containerPath(containerKindForSingleFileInput(script.relativePath));
1671
+ dest = collections.find((c) => c.path === destPath);
1672
+ }
1673
+ if (dest?.id === source.id)
1674
+ return null;
1675
+ // Same dedup rule as an install, via the same helper — the stored
1676
+ // `relativePath` needs canonicalizing here because it may predate
1677
+ // the store doing so on the way in.
1678
+ const here = canonicalCollectionInput(script.relativePath);
1679
+ if (dest && await containerHoldsLocation(dest, here))
1680
+ return null;
1681
+ const destId = dest?.id ?? crypto.randomUUID();
1682
+ const nextDest = dest
1683
+ ? { ...dest, scriptIds: [...dest.scriptIds, scriptId] }
1684
+ : newContainerRow(destId, destPath, [scriptId]);
1685
+ const nextScript = { ...script, collectionId: destId };
1686
+ // One set call: a SW restart between writes must never leave the
1687
+ // row listed by both collections, or by neither.
1688
+ const toSet = {
1689
+ [collectionKey(source.id)]: {
1690
+ ...source,
1691
+ scriptIds: source.scriptIds.filter((id) => id !== scriptId),
1692
+ },
1693
+ [collectionKey(destId)]: nextDest,
1694
+ [scriptKey(scriptId)]: nextScript,
1695
+ };
1696
+ if (!dest)
1697
+ toSet[COLLECTIONS_INDEX_KEY] = [...collections.map((c) => c.id), destId];
1698
+ await writeLocal(toSet);
1699
+ return {
1700
+ script: nextScript,
1701
+ localFilesChanged: collectionUsesLocalFiles(source) !== collectionUsesLocalFiles(nextDest),
1702
+ };
1703
+ });
1704
+ if (!moved)
1705
+ return null;
1706
+ if (!moved.localFilesChanged)
1707
+ return moved.script;
1708
+ try {
1709
+ // Outside the lock: `refreshScript` takes it itself.
1710
+ return (await refreshScript(scriptId)) ?? moved.script;
1711
+ }
1712
+ catch (err) {
1713
+ // The move itself landed and is what the caller asked for; only the
1714
+ // re-resolve of the declared files failed. Reporting it as a failed
1715
+ // move would be wrong, and the user can click Refresh.
1716
+ dlog(`[SourceMonkey] refresh after move failed: ${describeError(err)}`);
1717
+ return moved.script;
1718
+ }
1719
+ }
1720
+ // Write a freshly-added script's first Log lines: the install itself,
1721
+ // any local stand-in it picked up, its load failure, and its parse-time
1722
+ // warnings.
1723
+ //
1724
+ // The updater-driven refresh paths do this themselves; the add path
1725
+ // bypasses the updater, so without this a newly installed script would
1726
+ // start with an empty Log: no record of the install, a red badge with
1727
+ // nothing to click through to, and header warnings that only the page
1728
+ // console ever showed.
1729
+ //
1730
+ // Adding a single file to an *existing* container refreshes every
1731
+ // sibling, and we run over all of them with `installed` false for the
1732
+ // siblings. Those get only the stand-in and failure lines: a sibling
1733
+ // that is still broken logs that again. It was genuinely re-read, so
1734
+ // saying so is honest, and the add path is a deliberate user action
1735
+ // rather than something that fires on its own. They weren't installed
1736
+ // now, and their content didn't change, so they get no install line or
1737
+ // parser restatement.
1738
+ export async function emitInitialLoadLog(script, installed) {
1739
+ const failure = loadStateLogEntry(script);
1740
+ if (installed) {
1741
+ // No size when nothing was fetched: such a row's `size` is null.
1742
+ const size = script.size != null ? ` (${formatBytes(script.size)})` : '';
1743
+ // `state: 'ok'` only when it loaded: an invalid row's failure line
1744
+ // below carries its state, and an OK beside it would contradict it.
1745
+ await appendScriptLog(script.id, {
1746
+ level: 'info',
1747
+ ...(failure ? {} : { state: 'ok' }),
1748
+ source: 'add',
1749
+ message: `Installed${size}`,
1750
+ });
1751
+ }
1752
+ // `undefined` prior: everything this row loaded is news at add time.
1753
+ for (const entry of localFileLogMessages(undefined, script)) {
1754
+ await appendScriptLog(script.id, { ...entry, source: 'add' });
1755
+ }
1756
+ if (failure)
1757
+ await appendScriptLog(script.id, { ...failure, source: 'add' });
1758
+ // After the failure, unlike a refresh (which stops there): a row that
1759
+ // is invalid on a failed `@require` still has a header whose warnings
1760
+ // hold, and at install time they are part of what the user is asking
1761
+ // about.
1762
+ if (installed) {
1763
+ for (const entry of script.startupLogs ?? []) {
1764
+ await appendScriptLog(script.id, startupLogEntry(entry));
1765
+ }
1766
+ }
1767
+ }
1768
+ // A row with no name that one can be inferred for: a user-created
1769
+ // container infers none, so is left as it is.
1770
+ export function needsDisplayName(collection) {
1771
+ return !collection.displayName?.trim() && inferDisplayName(collection.path) !== '';
1772
+ }
1773
+ // Fill in `displayName` on any collection row that doesn't have one
1774
+ // yet. The collections page asks for it at boot when a row needs one.
1775
+ // Cheap: one read of the collection index plus one bulk write iff
1776
+ // anything was missing. The pure-inference rules live in
1777
+ // `collection-display.ts`; that module only imports a TYPE from this
1778
+ // file so the cycle is compile-time only.
1779
+ export async function backfillCollectionDisplayNames() {
1780
+ // Read and written under the lock: a removal or refresh landing in
1781
+ // between would otherwise be written over with the rows read before.
1782
+ await mutate(async () => {
1783
+ const collections = await getCollections();
1784
+ const toSet = {};
1785
+ for (const s of collections) {
1786
+ if (!needsDisplayName(s))
1787
+ continue;
1788
+ const next = { ...s, displayName: inferDisplayName(s.path) };
1789
+ toSet[collectionKey(s.id)] = next;
1790
+ }
1791
+ if (Object.keys(toSet).length > 0) {
1792
+ await writeLocal(toSet);
1793
+ }
1794
+ });
1795
+ }
1796
+ // One-time pass over stored script rows to fill `usesUnsafeWindow` for
1797
+ // rows written before the field existed. Returns how many rows changed.
1798
+ //
1799
+ // Why it can't wait for a refresh: `buildScriptFromText` derives the flag
1800
+ // on every load, but a row is only rebuilt when a fetch actually happens.
1801
+ // The cheap-skip paths (unchanged size+mtime on disk, a conditional GET
1802
+ // answered 304) keep an untouched script's row exactly as it was, so a
1803
+ // script that uses `unsafeWindow` ungranted could sit broken
1804
+ // indefinitely after the upgrade that would have fixed it.
1805
+ //
1806
+ // Idempotent and cheap to repeat: every row whose body doesn't contain
1807
+ // the name at all costs one `indexOf`. Rows that already carry the flag,
1808
+ // and error rows (which never run), are skipped. `startupLogs` is
1809
+ // re-derived for the rows that change so the row stays self-consistent
1810
+ // with what the injector will do with it.
1811
+ //
1812
+ // The backfills read and write inside the write lock, as every write of
1813
+ // a script row must (`checkScriptRowWrite`): a row removed between the
1814
+ // read and the write would otherwise come back, pointing at blobs the
1815
+ // cleanup has deleted. They run once per extension update, so holding
1816
+ // the lock for a full read costs nothing that matters.
1817
+ export async function backfillUsesUnsafeWindow() {
1818
+ return mutate(async () => {
1819
+ const scripts = await getAllScripts();
1820
+ const toSet = {};
1821
+ for (const script of scripts) {
1822
+ if (script.status !== 'ok' || script.usesUnsafeWindow === true)
1823
+ continue;
1824
+ if (!sourceUsesUnsafeWindow(script.bodyText))
1825
+ continue;
1826
+ const next = { ...script, usesUnsafeWindow: true };
1827
+ const logs = rederivedStartupLogs(script, true);
1828
+ if (logs.length > 0)
1829
+ next.startupLogs = logs;
1830
+ else
1831
+ delete next.startupLogs;
1832
+ toSet[scriptKey(script.id)] = next;
1833
+ }
1834
+ const changed = Object.keys(toSet).length;
1835
+ if (changed > 0)
1836
+ await writeLocal(toSet);
1837
+ return changed;
1838
+ });
1839
+ }
1840
+ // Re-derive `startupLogs` on stored rows, so a new parse-time check
1841
+ // (e.g. the no-`@connect` warning) reaches scripts already installed.
1842
+ // Same reason as `backfillUsesUnsafeWindow`: an unchanged script's row
1843
+ // is never rebuilt. Runs after that backfill, so `usesUnsafeWindow` is
1844
+ // current. Writes only rows whose logs changed; returns how many.
1845
+ export async function backfillStartupLogs() {
1846
+ return mutate(async () => {
1847
+ const scripts = await getAllScriptMetas('startup log backfill');
1848
+ const toSet = {};
1849
+ for (const script of scripts) {
1850
+ if (script.status !== 'ok')
1851
+ continue;
1852
+ const logs = rederivedStartupLogs(script, script.usesUnsafeWindow === true);
1853
+ if (JSON.stringify(logs) === JSON.stringify(script.startupLogs ?? []))
1854
+ continue;
1855
+ const next = { ...script };
1856
+ if (logs.length > 0)
1857
+ next.startupLogs = logs;
1858
+ else
1859
+ delete next.startupLogs;
1860
+ toSet[scriptKey(script.id)] = next;
1861
+ }
1862
+ const changed = Object.keys(toSet).length;
1863
+ if (changed > 0)
1864
+ await writeLocal(toSet);
1865
+ return changed;
1866
+ });
1867
+ }
1868
+ // Re-run all of `validateHeader` on stored `ok` rows, so a header
1869
+ // check added in an update stops scripts already installed rather than
1870
+ // only new or changed ones. Same reason as the backfills above: an
1871
+ // unchanged script's row is never rebuilt. Writes only rows that now
1872
+ // fail; returns how many.
1873
+ //
1874
+ // Each flipped row gets its error in the Log here: the boot replay
1875
+ // runs on the same wake and may have read the row while it was still
1876
+ // `ok`, which would leave an INVALID badge over a Log that says nothing.
1877
+ export async function backfillHeaderProblems() {
1878
+ const flipped = await mutate(async () => {
1879
+ const scripts = await getAllScriptMetas('header check backfill');
1880
+ const toSet = {};
1881
+ for (const script of scripts) {
1882
+ if (script.status !== 'ok')
1883
+ continue;
1884
+ const problem = validateHeader(script.header);
1885
+ if (!problem)
1886
+ continue;
1887
+ const next = { ...script, status: 'invalid' };
1888
+ setScriptError(next, problem.message, locationIn(script.relativePath, problem.line));
1889
+ // Same shape as a freshly built invalid row (see
1890
+ // `buildScriptFromText`): no startup logs, parse verdict or world
1891
+ // flag, and no stale refresh failure competing with the error.
1892
+ delete next.startupLogs;
1893
+ delete next.parseCheckedHash;
1894
+ delete next.usesUnsafeWindow;
1895
+ delete next.refreshError;
1896
+ toSet[scriptKey(script.id)] = next;
1897
+ }
1898
+ const rows = Object.values(toSet);
1899
+ if (rows.length > 0)
1900
+ await writeLocal(toSet);
1901
+ return rows;
1902
+ });
1903
+ // Outside the lock: the Log has its own queue.
1904
+ for (const row of flipped) {
1905
+ const entry = loadStateLogEntry(row);
1906
+ if (entry)
1907
+ await appendScriptLog(row.id, { ...entry, source: 'extension-update' });
1908
+ }
1909
+ return flipped.length;
1910
+ }
1911
+ // The opposite of `backfillHeaderProblems`: ids of rows an older build
1912
+ // refused with a check that no longer exists (`@run-at context-menu`,
1913
+ // before manual scripts). The caller refreshes them; a refresh always
1914
+ // refetches an invalid row (see `fetchOrSkipInParallel`), and the
1915
+ // rebuild then also loads the `@require` / `@resource` files an invalid
1916
+ // row never got. Flipping the row to `ok` in place would skip those.
1917
+ export async function retiredHeaderProblemIds() {
1918
+ const scripts = await getAllScriptMetas('retired header check');
1919
+ return scripts
1920
+ .filter((s) => s.status === 'invalid' && s.errorMessage === RETIRED_CONTEXT_MENU_UNSUPPORTED)
1921
+ .map((s) => s.id);
1922
+ }
1923
+ // Reorder the collections index by moving the given collection one
1924
+ // position up or down. No-op if the collection is already at the
1925
+ // boundary in the requested direction (caller-side Up/Down buttons
1926
+ // disable themselves at the ends, but the function tolerates a
1927
+ // redundant call). Only the index row is rewritten; per-collection rows
1928
+ // are untouched.
1929
+ export async function moveCollection(id, direction) {
1930
+ await mutate(async () => {
1931
+ const ids = await readCollectionIndex();
1932
+ const idx = ids.indexOf(id);
1933
+ if (idx < 0)
1934
+ return;
1935
+ const swapWith = direction === 'up' ? idx - 1 : idx + 1;
1936
+ if (swapWith < 0 || swapWith >= ids.length)
1937
+ return;
1938
+ const next = ids.slice();
1939
+ [next[idx], next[swapWith]] = [next[swapWith], next[idx]];
1940
+ await writeLocal({ [COLLECTIONS_INDEX_KEY]: next });
1941
+ });
1942
+ }
1943
+ export async function removeCollection(id) {
1944
+ const nextIndex = await mutate(async () => {
1945
+ const collection = await getRaw(collectionKey(id));
1946
+ const ids = await readCollectionIndex();
1947
+ const next = ids.filter((sid) => sid !== id);
1948
+ const scriptIds = collection?.scriptIds ?? [];
1949
+ const rows = await getRows(scriptIds.map(scriptKey));
1950
+ const removed = scriptIds.map((sid) => rows[scriptKey(sid)])
1951
+ .filter((r) => r !== undefined);
1952
+ const removeKeys = [
1953
+ collectionKey(id),
1954
+ ...scriptIds.map(scriptKey),
1955
+ ...scriptIds.map(connectGrantsKey),
1956
+ ...await unreferencedScratchKeys(removed),
1957
+ ];
1958
+ await writeLocal({ [COLLECTIONS_INDEX_KEY]: next, ...await withoutTimes(scriptIds) });
1959
+ await removeLocal(removeKeys);
1960
+ return next;
1961
+ });
1962
+ // Session-storage bookkeeping, so a removed collection can't leave a
1963
+ // banner counting a section that is no longer on the page. After the
1964
+ // write, not inside it: the local write is the one that has to be
1965
+ // atomic, and a failure here must not undo a removal that succeeded.
1966
+ await pruneRefreshErrors(nextIndex);
1967
+ }
1968
+ // Drop a single script row from storage and remove its ID from the
1969
+ // parent collection's scriptIds list. Used by the collections-page
1970
+ // trash-cans (container rows, `missing` rows from older versions), the
1971
+ // delete-local-file dialog, and `refreshScript` when a directory no
1972
+ // longer lists the file — refreshCollection won't recreate the row
1973
+ // because it only adds entries the scan currently reports.
1974
+ export async function removeScript(id) {
1975
+ await mutate(async () => {
1976
+ const script = await getRow(scriptKey(id));
1977
+ if (!script)
1978
+ return;
1979
+ const collection = await getRaw(collectionKey(script.collectionId));
1980
+ const toSet = await withoutTimes([id]);
1981
+ if (collection) {
1982
+ const nextIds = collection.scriptIds.filter((sid) => sid !== id);
1983
+ if (nextIds.length !== collection.scriptIds.length) {
1984
+ toSet[collectionKey(collection.id)] = { ...collection, scriptIds: nextIds };
1985
+ }
1986
+ }
1987
+ if (Object.keys(toSet).length > 0)
1988
+ await writeLocal(toSet);
1989
+ await removeLocal([
1990
+ scriptKey(id), connectGrantsKey(id), ...await unreferencedScratchKeys([script]),
1991
+ ]);
1992
+ });
1993
+ // After the write, not inside it: the local write is the one that has
1994
+ // to be atomic, and a failure here must not undo a removal that
1995
+ // succeeded. The refresh path drops rows the same way and clears their
1996
+ // logs the same way.
1997
+ await forgetScriptLogs([id]);
1998
+ }
1999
+ // The text rows (and pushed-file rows) of the scratch scripts among
2000
+ // `removed` that no other script row still reads. A scratch script moved into another collection
2001
+ // keeps reading its text row, so a URL some remaining row names keeps
2002
+ // its text. Reads nothing more when no scratch script is among those
2003
+ // removed, and otherwise only the container collections' rows: a
2004
+ // scratch script can only be moved into another container. Call
2005
+ // inside `mutate`, before the rows are deleted.
2006
+ async function unreferencedScratchKeys(removed) {
2007
+ const urls = new Set(removed.map((r) => r.relativePath).filter(isScratchUrl));
2008
+ if (urls.size === 0)
2009
+ return [];
2010
+ const removedIds = new Set(removed.map((r) => r.id));
2011
+ const collectionRows = await getMany((await readCollectionIndex()).map(collectionKey));
2012
+ const others = [];
2013
+ for (const value of Object.values(collectionRows)) {
2014
+ const c = value;
2015
+ if (!c || !isContainerCollection(c.path))
2016
+ continue;
2017
+ for (const sid of c.scriptIds)
2018
+ if (!removedIds.has(sid))
2019
+ others.push(sid);
2020
+ }
2021
+ const rows = await getRows(others.map(scriptKey));
2022
+ for (const value of Object.values(rows)) {
2023
+ urls.delete(value.relativePath);
2024
+ }
2025
+ return [...urls].flatMap((url) => [scratchTextKey(url), scratchFilesKey(url), scratchSourceKey(url)]);
2026
+ }
2027
+ // ---------- Membership reconciliation ----------
2028
+ // The rows `reconcileScriptMembership` needs when the caller hasn't
2029
+ // already read them: every script row, and every collection row the
2030
+ // INDEX still lists.
2031
+ //
2032
+ // Index-filtered so this agrees with the collections page, which passes
2033
+ // what `getCollections` gave it. A `collection:` row the index has lost
2034
+ // is not somewhere a script can be put back — nothing renders or
2035
+ // refreshes it — so both callers have to treat scripts naming one as
2036
+ // collectionless rather than re-link them into a collection no surface
2037
+ // shows.
2038
+ //
2039
+ // The filter also means a de-indexed row's stale cross-listing is left
2040
+ // alone rather than dropped. That listing is invisible either way, and
2041
+ // leaving one row of garbage beats writing to a collection we've just
2042
+ // decided isn't real.
2043
+ async function scanMembershipRows() {
2044
+ const all = await allCachedRows() ?? await scanStorageRows('membership reconcile', (k) => k === COLLECTIONS_INDEX_KEY || k.startsWith(COLLECTION_PREFIX) || k.startsWith(SCRIPT_PREFIX));
2045
+ // Out of the scan rather than a second read, so the index and the rows
2046
+ // it filters are one snapshot. `ensureCollectionKeysMigrated` already
2047
+ // ran, so this is the post-rename spelling.
2048
+ const index = all[COLLECTIONS_INDEX_KEY];
2049
+ const indexed = new Set(Array.isArray(index) ? index : []);
2050
+ const collections = [];
2051
+ const scripts = [];
2052
+ for (const [key, value] of Object.entries(all)) {
2053
+ if (key.startsWith(COLLECTION_PREFIX)) {
2054
+ if (indexed.has(key.slice(COLLECTION_PREFIX.length))) {
2055
+ collections.push(value);
2056
+ }
2057
+ }
2058
+ else if (key.startsWith(SCRIPT_PREFIX)) {
2059
+ scripts.push(value);
2060
+ }
2061
+ }
2062
+ return { collections, scripts };
2063
+ }
2064
+ //
2065
+ // Make `collection.scriptIds` agree with the `collectionId` on each
2066
+ // script row. Every mutator writes both together, so a disagreement is
2067
+ // a bug state rather than a shape the store means to represent — but a
2068
+ // refresh that raced an install or a move used to overwrite the list
2069
+ // from its pre-fetch snapshot and leave one behind (both directions
2070
+ // now handled in `writeRefreshedCollection`).
2071
+ //
2072
+ // This heals profiles that already hit it. The row's own `collectionId`
2073
+ // is the authority, because that is what the injector and the viewer
2074
+ // read — a row unlisted by it is invisible on the collections page and
2075
+ // never refreshed again, and one listed by a collection it doesn't name
2076
+ // shows up under a collection it doesn't belong to.
2077
+ //
2078
+ // Finding a row nothing points at needs every row, so this does not run
2079
+ // on a schedule of its own. The collections page (the surface where an
2080
+ // unlisted row is missing, and the one the user is looking at when they
2081
+ // notice) asks the worker for it on every full render, which reads the
2082
+ // rows from its row cache.
2083
+ //
2084
+ // A row whose collection is GONE is left alone: `removeCollection`
2085
+ // drops both together so it shouldn't arise, there is nowhere to put
2086
+ // such a row back, and quietly deleting a user's script on a hunch is
2087
+ // worse than leaving an inert row.
2088
+ export async function reconcileScriptMembership() {
2089
+ await ensureCollectionKeysMigrated();
2090
+ const rows = await scanMembershipRows();
2091
+ // {script id → the collections currently listing it}. Built over
2092
+ // every collection row rather than a flat "is listed" set, so a row
2093
+ // listed by the WRONG collection is caught too.
2094
+ const listedBy = new Map();
2095
+ const known = new Set();
2096
+ for (const collection of rows.collections) {
2097
+ known.add(collection.id);
2098
+ for (const sid of collection.scriptIds ?? []) {
2099
+ const owners = listedBy.get(sid);
2100
+ if (owners)
2101
+ owners.push(collection.id);
2102
+ else
2103
+ listedBy.set(sid, [collection.id]);
2104
+ }
2105
+ }
2106
+ // Per collection: ids it should list but doesn't, and ids it lists
2107
+ // that name somewhere else.
2108
+ const toAdd = new Map();
2109
+ const toDrop = new Map();
2110
+ const push = (map, cid, sid) => {
2111
+ const ids = map.get(cid);
2112
+ if (ids)
2113
+ ids.push(sid);
2114
+ else
2115
+ map.set(cid, [sid]);
2116
+ };
2117
+ let collectionless = 0;
2118
+ for (const script of rows.scripts) {
2119
+ const owners = listedBy.get(script.id) ?? [];
2120
+ const cid = script.collectionId;
2121
+ if (cid === undefined || !known.has(cid)) {
2122
+ collectionless++;
2123
+ continue;
2124
+ }
2125
+ if (!owners.includes(cid))
2126
+ push(toAdd, cid, script.id);
2127
+ for (const other of owners)
2128
+ if (other !== cid)
2129
+ push(toDrop, other, script.id);
2130
+ }
2131
+ if (collectionless > 0) {
2132
+ console.log(`[SourceMonkey] membership: ${collectionless} script row(s) name no `
2133
+ + 'existing collection; left in place');
2134
+ }
2135
+ await pruneEnablementTimes(new Set(rows.scripts.map((sc) => sc.id)));
2136
+ if (toAdd.size === 0 && toDrop.size === 0)
2137
+ return 0;
2138
+ // Re-read inside the lock: the scan is stale by now, and a concurrent
2139
+ // refresh or move may already have adopted (or legitimately removed)
2140
+ // one of these rows. Read every row this pass touches in two batched
2141
+ // gets rather than one per id — the mutex is held throughout, and the
2142
+ // rest of the module keeps its critical sections short.
2143
+ return await mutate(async () => {
2144
+ const cids = new Set([...toAdd.keys(), ...toDrop.keys()]);
2145
+ const sids = new Set([...toAdd.values(), ...toDrop.values()].flat());
2146
+ const collections = await getMany([...cids].map(collectionKey));
2147
+ const scripts = await getRows([...sids].map(scriptKey));
2148
+ const ownerOf = (sid) => scripts[scriptKey(sid)]?.collectionId;
2149
+ const toSet = {};
2150
+ let moved = 0;
2151
+ for (const cid of cids) {
2152
+ const collection = collections[collectionKey(cid)];
2153
+ if (!collection)
2154
+ continue;
2155
+ const before = collection.scriptIds ?? [];
2156
+ const has = new Set(before);
2157
+ // Re-checked against the row as it stands now, so a move that
2158
+ // landed since the scan isn't undone by acting on stale reads.
2159
+ const add = (toAdd.get(cid) ?? []).filter((sid) => !has.has(sid) && ownerOf(sid) === cid);
2160
+ const drop = new Set((toDrop.get(cid) ?? []).filter((sid) => ownerOf(sid) !== cid));
2161
+ const after = [...before.filter((sid) => !drop.has(sid)), ...add];
2162
+ if (after.length === before.length && add.length === 0)
2163
+ continue;
2164
+ toSet[collectionKey(cid)] = { ...collection, scriptIds: after };
2165
+ moved += add.length + drop.size;
2166
+ }
2167
+ if (moved === 0)
2168
+ return 0;
2169
+ await writeLocal(toSet);
2170
+ console.log(`[SourceMonkey] membership: reconciled ${moved} script listing(s) across `
2171
+ + `${Object.keys(toSet).length} collection(s)`);
2172
+ return moved;
2173
+ });
2174
+ }
2175
+ export async function refreshCollection(id, opts = {}) {
2176
+ const timer = startPhaseTimer('collection refresh');
2177
+ const prior = await getCollectionWithScripts(id);
2178
+ if (!prior)
2179
+ return null;
2180
+ const { collection, scripts: existingScripts } = prior;
2181
+ timer.mark('read');
2182
+ timer.note(collection.path);
2183
+ let scan = [];
2184
+ let scanError;
2185
+ let manifestStat = null;
2186
+ if (isContainerCollection(collection.path)) {
2187
+ // Container collections have no scan step — their child scripts are
2188
+ // explicit. Build a synthetic scan from the existing rows so the
2189
+ // rest of the pipeline can refetch each URL/path. A `file://` child
2190
+ // gets the same listing stat a directory scan would give it (one
2191
+ // listing per folder), so an unchanged one is skipped and a page
2192
+ // can tell an edited one; HTTP children skip via conditional GET.
2193
+ const listings = new Map();
2194
+ scan = await Promise.all(existingScripts.map(async (s) => {
2195
+ const entry = { relativePath: s.relativePath };
2196
+ if (isFileUrl(s.fileUrl)) {
2197
+ const stat = await statFileByListing(locationFsPath(s.fileUrl), listings);
2198
+ if (stat?.size !== undefined)
2199
+ entry.size = stat.size;
2200
+ if (stat?.mtime !== undefined)
2201
+ entry.mtime = stat.mtime;
2202
+ }
2203
+ return entry;
2204
+ }));
2205
+ }
2206
+ else {
2207
+ // A `file://` manifest's own stat, recorded beside the scan it
2208
+ // produced so the collections page can tell the manifest was
2209
+ // edited since. Taken BEFORE the read: an edit landing between the
2210
+ // two then shows as a change rather than being silently folded
2211
+ // into this refresh. The listing is shared with the scan, whose
2212
+ // entries usually sit in the same folder.
2213
+ const listings = new Map();
2214
+ if (isManifestCollection(collection.path) && isFileUrl(collectionCanonicalUrl(collection.path))) {
2215
+ manifestStat = await statFileByListing(locationFsPath(collection.path), listings);
2216
+ }
2217
+ try {
2218
+ scan = await scanForUserScripts(collection.path, 0, '', listings);
2219
+ }
2220
+ catch (err) {
2221
+ scanError = describeError(err);
2222
+ }
2223
+ }
2224
+ timer.mark('scan');
2225
+ const now = Date.now();
2226
+ // Scan failure (unreadable manifest, unreadable top-level directory,
2227
+ // bad URL shape) is treated as "we don't actually know what's there
2228
+ // right now" — keep every existing script row, its content, and its
2229
+ // enabled state untouched. The collection row records the scanError so
2230
+ // the collections page can surface it. Without this guard, the
2231
+ // rebuildCollection path below would drop every script — silently
2232
+ // taking the scripts away. That's the wrong call when the collection might
2233
+ // just be a remote mount with timed-out credentials, a flaky network,
2234
+ // etc.; the user wants the scripts to stay enabled until the collection
2235
+ // is actually proven gone.
2236
+ if (scanError) {
2237
+ return await mutate(async () => {
2238
+ // Re-read inside the lock so a concurrent setCollectionEnabled
2239
+ // (or displayName backfill) that landed during the scan isn't
2240
+ // reverted — we only own lastRefreshedAt + scanError here.
2241
+ const curCollection = await getRaw(collectionKey(id)) ?? collection;
2242
+ const nextCollection = {
2243
+ ...curCollection,
2244
+ lastRefreshedAt: now,
2245
+ scanError,
2246
+ };
2247
+ await writeLocal({ [collectionKey(id)]: nextCollection });
2248
+ return { collection: nextCollection, scripts: existingScripts };
2249
+ });
2250
+ }
2251
+ // Fetch every scanned file — with bounded concurrency — except those
2252
+ // whose size+mtime are already current.
2253
+ const fetchResults = await fetchOrSkipInParallel(collection.path, scan, existingScripts);
2254
+ // Marked here so the breakdown says whether the time went to reading
2255
+ // files or to everything downstream of having read them (dependency
2256
+ // fetches, hashing, the parse check).
2257
+ timer.mark('fetch');
2258
+ const scripts = await rebuildCollection({
2259
+ collectionId: collection.id,
2260
+ collectionPath: collection.path,
2261
+ existing: existingScripts,
2262
+ scan,
2263
+ fetchResults,
2264
+ now,
2265
+ recheckFiles: opts.recheckFiles,
2266
+ fileFetchCache: opts.fileFetchCache,
2267
+ localFiles: localFileScope(collection),
2268
+ });
2269
+ timer.mark('rebuild');
2270
+ // Thunked: counting the skips walks every fetch result, which a
2271
+ // disabled timer must not pay for.
2272
+ timer.note(() => {
2273
+ const skipped = [...fetchResults.values()].filter((r) => 'unchanged' in r).length;
2274
+ return `${scripts.length} scripts, ${skipped} unchanged`;
2275
+ });
2276
+ // Re-read the current rows INSIDE the lock and write atomically, so a
2277
+ // concurrent enable toggle (setCollectionEnabled / setScriptEnabled)
2278
+ // that landed during the fetch isn't reverted by stale values
2279
+ // captured before it. Content (and any deliberate refresh-owned
2280
+ // `enabled` change) is kept; we only re-apply the user's toggle when the
2281
+ // persisted `enabled` actually changed vs the snapshot this refresh
2282
+ // started from — that's the signal a setScriptEnabled landed
2283
+ // mid-fetch.
2284
+ const priorById = new Map(existingScripts.map((s) => [s.id, s]));
2285
+ // The write is the last thing that can fail, and it is the one failure
2286
+ // `chrome.storage.local` can't record — an exhausted quota rejects the
2287
+ // error row for the same reason it rejected the refresh. The reason
2288
+ // goes to `chrome.storage.session` instead, where the collections page
2289
+ // reads it. Every refresh path funnels through here, including the one
2290
+ // `addCollection` runs directly, so this is the only place that needs
2291
+ // to know. See src/extension/refresh-error.ts.
2292
+ // Copies elsewhere that a new copy might meet (`settleNewCopies`):
2293
+ // found with a full scan here, before the lock, so the write's window
2294
+ // stays one round-trip. Inside the lock only these rows are re-read.
2295
+ const scannedNew = scripts.filter((s) => !priorById.has(s.id));
2296
+ const rivalIds = scannedNew.length > 0 ? await possibleRivals(id, scannedNew) : [];
2297
+ try {
2298
+ const written = await writeRefreshedCollection();
2299
+ timer.mark('write');
2300
+ timer.done();
2301
+ // Clears on the way out, so a collection that recovers stops
2302
+ // reporting a failure the next refresh already fixed.
2303
+ await setRefreshError(id, null);
2304
+ if (!written)
2305
+ return null;
2306
+ // After the write, not inside it — same rule `removeScript` follows:
2307
+ // the storage.local write is the one that has to be atomic, and a
2308
+ // log we failed to clear must not undo a refresh that landed.
2309
+ await forgetScriptLogs(written.droppedIds);
2310
+ return { collection: written.collection, scripts: written.scripts };
2311
+ }
2312
+ catch (err) {
2313
+ await setRefreshError(id, describeError(err));
2314
+ throw err;
2315
+ }
2316
+ // Reports `droppedIds` alongside the written state so the caller can
2317
+ // clear those scripts' logs outside the lock.
2318
+ async function writeRefreshedCollection() {
2319
+ return await mutate(async () => {
2320
+ const curCollection = await getRaw(collectionKey(id));
2321
+ if (!curCollection) {
2322
+ // Collection was removed during the refresh — abandon the write
2323
+ // rather than resurrect a deleted collection row.
2324
+ return null;
2325
+ }
2326
+ // Without their bodies: see `changedScriptRows`.
2327
+ const curScripts = await getRows(scripts.map((s) => scriptKey(s.id)));
2328
+ const priorIds = new Set(existingScripts.map((s) => s.id));
2329
+ // Membership moves under us in both directions, and the freshly
2330
+ // re-read row is the authority on both. `scripts` was rebuilt
2331
+ // from a snapshot taken before the fetch (for a container, that
2332
+ // snapshot IS the synthetic scan), so it describes a membership
2333
+ // that may be minutes stale.
2334
+ //
2335
+ // LEFT during the fetch — the row is gone (`removeScript`) or now
2336
+ // names another collection (`moveScript`). Writing the rebuilt
2337
+ // row back would resurrect a deleted script, or revert the move
2338
+ // AND leave the destination still listing it, so the same script
2339
+ // shows under two collections. `preserveConcurrentEdits` can't
2340
+ // rescue this: it re-applies fields onto the rebuilt row, and
2341
+ // what's wrong here is that the row belongs to someone else now.
2342
+ const departed = new Set();
2343
+ for (const s of scripts) {
2344
+ const cur = curScripts[scriptKey(s.id)];
2345
+ // A row the scan found for the first time has no stored
2346
+ // counterpart yet — that's a new script, not a departure.
2347
+ if (!cur) {
2348
+ if (priorIds.has(s.id))
2349
+ departed.add(s.id);
2350
+ }
2351
+ else if (cur.collectionId !== id) {
2352
+ departed.add(s.id);
2353
+ }
2354
+ }
2355
+ const kept = scripts.filter((s) => !departed.has(s.id));
2356
+ const finalScripts = kept.map((s) => preserveConcurrentEdits(s, priorById.get(s.id), curScripts[scriptKey(s.id)]));
2357
+ // JOINED during the fetch — an `installScript` / `moveScript`
2358
+ // into this collection that the snapshot never saw. Writing the
2359
+ // rebuilt list verbatim would erase its id while leaving the
2360
+ // `script:` row in storage — unlisted: still pointing here via
2361
+ // `collectionId`, still injected, but shown by no collection and
2362
+ // never refreshed again.
2363
+ const rebuiltIds = finalScripts.map((s) => s.id);
2364
+ const rebuilt = new Set(rebuiltIds);
2365
+ const joinedDuringRefresh = curCollection.scriptIds.filter((sid) => !rebuilt.has(sid) && !priorIds.has(sid) && !departed.has(sid));
2366
+ const nextCollection = {
2367
+ ...curCollection,
2368
+ lastRefreshedAt: now,
2369
+ scriptIds: [...rebuiltIds, ...joinedDuringRefresh],
2370
+ };
2371
+ // Clear any scanError left by a previous failed refresh — control
2372
+ // only reaches here when the scan succeeded (the early-return above
2373
+ // handles the failure case).
2374
+ delete nextCollection.scanError;
2375
+ // Both or neither, like a script row's size + fsMtime: a lone
2376
+ // value can't be compared against a listing.
2377
+ if (manifestStat?.size !== undefined && manifestStat.mtime !== undefined) {
2378
+ nextCollection.manifestSize = manifestStat.size;
2379
+ nextCollection.manifestMtime = manifestStat.mtime;
2380
+ }
2381
+ else {
2382
+ delete nextCollection.manifestSize;
2383
+ delete nextCollection.manifestMtime;
2384
+ }
2385
+ // Drop rows whose IDs are no longer referenced — an entry that
2386
+ // fell off the listing, or a manifest entry the user removed. Of
2387
+ // the ids the snapshot had, since a row that joined mid-refresh
2388
+ // was never reconciled against the scan; and never a departed
2389
+ // one, whose row is either already deleted or now owned by the
2390
+ // collection it moved to.
2391
+ const droppedIds = [...priorIds]
2392
+ .filter((sid) => !rebuilt.has(sid) && !departed.has(sid));
2393
+ // The collection row always goes: `lastRefreshedAt` moved, and
2394
+ // that's the record of the check having happened at all. The
2395
+ // script rows only go if they actually changed — see
2396
+ // `changedScriptRows`.
2397
+ const toSet = { [collectionKey(id)]: nextCollection };
2398
+ // Scripts the scan found for the first time: one active copy per
2399
+ // script, and their active times. Also drops the times of rows
2400
+ // dropped below.
2401
+ const newIds = new Set(finalScripts
2402
+ .filter((s) => !priorIds.has(s.id) && !curScripts[scriptKey(s.id)])
2403
+ .map((s) => s.id));
2404
+ if (newIds.size > 0 || droppedIds.length > 0) {
2405
+ Object.assign(toSet, await settleNewCopies(nextCollection, finalScripts, newIds, droppedIds, rivalIds, opts.adding === true));
2406
+ }
2407
+ const changed = await changedScriptRows(finalScripts, curScripts, (sid) => priorById.get(sid));
2408
+ Object.assign(toSet, changed.items);
2409
+ timer.note(`rows written: ${changed.count}`);
2410
+ // Placed where a real quota rejection lands — after everything was
2411
+ // fetched and reconciled, with nothing written — so the test
2412
+ // exercises the same state the production failure produces.
2413
+ if (collectionWriteFailure !== null) {
2414
+ throw translateStorageError(new Error(collectionWriteFailure));
2415
+ }
2416
+ await writeLocal(toSet);
2417
+ if (droppedIds.length > 0) {
2418
+ await removeLocal([...droppedIds.map(scriptKey), ...droppedIds.map(connectGrantsKey)]);
2419
+ }
2420
+ return { collection: nextCollection, scripts: finalScripts, droppedIds };
2421
+ });
2422
+ }
2423
+ }
2424
+ // The ids of stored rows in other collections that are copies of one of
2425
+ // `newRows` (`sameScript`). One full scan, run before the refresh's lock.
2426
+ async function possibleRivals(collectionId, newRows) {
2427
+ const all = await scanStorageRows('new copies', (k) => k.startsWith(SCRIPT_PREFIX));
2428
+ const out = [];
2429
+ for (const value of Object.values(all)) {
2430
+ const row = value;
2431
+ if (row.collectionId !== collectionId && newRows.some((n) => sameScript(n, row)))
2432
+ out.push(row.id);
2433
+ }
2434
+ return out;
2435
+ }
2436
+ // What a refresh does about copies it finds for the first time, inside
2437
+ // its write lock (docs/script-enablement.md#new-copies). Edits the new
2438
+ // rows in `finalScripts` in place, and returns the other writes: rows
2439
+ // it turned off elsewhere, and the active-times map.
2440
+ //
2441
+ // - Two new copies of one script: only the first by
2442
+ // `compareInCollection` (highest `@version`, then location) stays
2443
+ // ticked.
2444
+ // - `adding` (the collection was just added): it counts as turning
2445
+ // the collection on, so each new active copy turns off the other
2446
+ // active copies of its script elsewhere.
2447
+ // - Otherwise (a later refresh found a new file): a new copy whose
2448
+ // script already has an active copy lands unticked, and the active
2449
+ // one stays active.
2450
+ //
2451
+ // `rivalIds` are the rows elsewhere that might be copies
2452
+ // (`possibleRivals`); only they are re-read here. An sm-dev run's
2453
+ // collection is left out both ways, as everywhere.
2454
+ async function settleNewCopies(collection, finalScripts, newIds, droppedIds, rivalIds, adding) {
2455
+ const out = {};
2456
+ const got = await getRows([...rivalIds.map(scriptKey), ENABLEMENT_TIMES_KEY]);
2457
+ const times = { ...got[ENABLEMENT_TIMES_KEY] };
2458
+ let timesChanged = false;
2459
+ for (const sid of droppedIds) {
2460
+ if (times[sid]) {
2461
+ delete times[sid];
2462
+ timesChanged = true;
2463
+ }
2464
+ }
2465
+ // Re-read inside the lock: a rival moved into this collection since
2466
+ // the scan is one of `finalScripts` now, or not listed at all.
2467
+ const others = rivalIds
2468
+ .map((sid) => got[scriptKey(sid)])
2469
+ .filter((r) => r !== undefined && r.collectionId !== collection.id);
2470
+ const collIds = [...new Set(others.map((r) => r.collectionId))];
2471
+ const gotColls = await getMany(collIds.map(collectionKey));
2472
+ const collections = new Map([[collection.id, collection]]);
2473
+ for (const cid of collIds) {
2474
+ const c = gotColls[collectionKey(cid)];
2475
+ if (c)
2476
+ collections.set(cid, c);
2477
+ }
2478
+ const counts = (c) => !c || !isSmDevCollection(c.path);
2479
+ const isActive = (row) => {
2480
+ const c = collections.get(row.collectionId);
2481
+ return isScriptEnabled(row) && (c ? isCollectionEnabled(c) : true);
2482
+ };
2483
+ const newRows = finalScripts.filter((s) => newIds.has(s.id));
2484
+ // Rows of this collection that were already there count as "already
2485
+ // active" copies too, as their refreshed rows say.
2486
+ const existing = finalScripts.filter((s) => !newIds.has(s.id));
2487
+ const at = nextEnablementStamp();
2488
+ if (counts(collection)) {
2489
+ // One ticked copy among the new ones of each script.
2490
+ const facts = (row) => ({
2491
+ enabled: true, collectionOn: true, collectionName: '',
2492
+ version: (row.header ? getValue(row.header, 'version')?.trim() : '') || null,
2493
+ location: row.fileUrl,
2494
+ });
2495
+ for (const row of newRows) {
2496
+ if (!isScriptEnabled(row))
2497
+ continue;
2498
+ const group = newRows.filter((o) => isScriptEnabled(o) && sameScript(o, row));
2499
+ if (preferredCopy(group, facts, compareInCollection) !== row)
2500
+ row.enabled = false;
2501
+ }
2502
+ for (const row of newRows) {
2503
+ if (!isScriptEnabled(row))
2504
+ continue;
2505
+ const rivals = [...others, ...existing].filter((o) => isActive(o) && counts(collections.get(o.collectionId)) && sameScript(o, row));
2506
+ if (rivals.length === 0)
2507
+ continue;
2508
+ if (!adding) {
2509
+ row.enabled = false;
2510
+ continue;
2511
+ }
2512
+ // Only a copy that is itself active displaces: one under a
2513
+ // collection turned off mid-add doesn't.
2514
+ if (!isActive(row))
2515
+ continue;
2516
+ for (const o of rivals) {
2517
+ if (o.collectionId === collection.id)
2518
+ continue;
2519
+ out[scriptKey(o.id)] = { ...o, enabled: false };
2520
+ if (stampTimes(times, o.id, true, false, at))
2521
+ timesChanged = true;
2522
+ }
2523
+ }
2524
+ }
2525
+ for (const row of newRows) {
2526
+ if (isActive(row) && stampTimes(times, row.id, false, true, at))
2527
+ timesChanged = true;
2528
+ }
2529
+ if (timesChanged)
2530
+ out[ENABLEMENT_TIMES_KEY] = times;
2531
+ return out;
2532
+ }
2533
+ export function createRefreshBatch(localLookup = 'search') {
2534
+ const listings = new Map();
2535
+ const fileFetchCache = new Map();
2536
+ bindListingCache(fileFetchCache, listings);
2537
+ return { collectionRows: new Map(), listings, fileFetchCache, localLookup };
2538
+ }
2539
+ // How many collection rows `refreshScript` has actually read from
2540
+ // storage since the last reset — batch hits excluded. The other half of
2541
+ // what a `RefreshBatch` shares, and the phase (`read-collection`) that
2542
+ // motivated it, so it gets the same kind of counter the listings do:
2543
+ // without one, a change that stopped threading the batch into the row
2544
+ // read would leave every test green. Same scoping caveats as
2545
+ // `getDirectoryListingFetches` — per JS context, reset around the window
2546
+ // under test.
2547
+ let collectionRowReads = 0;
2548
+ export function getCollectionRowReads() {
2549
+ return collectionRowReads;
2550
+ }
2551
+ export function resetCollectionRowReads() {
2552
+ collectionRowReads = 0;
2553
+ }
2554
+ // The batch's copy of a collection row, or a fresh read when there is no
2555
+ // batch. Returns whether it was shared so the caller's phase timer can
2556
+ // say so rather than just reporting a suspiciously fast phase.
2557
+ async function batchCollectionRow(batch, collectionId) {
2558
+ const hit = batch?.collectionRows.get(collectionId);
2559
+ if (hit)
2560
+ return { row: await hit, shared: true, ownReadMs: 0 };
2561
+ collectionRowReads += 1;
2562
+ const started = timingNow();
2563
+ const finish = async (p) => ({ row: await p, shared: false, ownReadMs: timingNow() - started });
2564
+ if (!batch)
2565
+ return finish(getRaw(collectionKey(collectionId)));
2566
+ const pending = getRaw(collectionKey(collectionId));
2567
+ batch.collectionRows.set(collectionId, pending);
2568
+ // A failed read must not be remembered. A cached rejection is rethrown
2569
+ // into every later sibling, so each one throws out of `refreshScript`
2570
+ // and `runScriptCheck` writes it a `Could not check for updates` Log
2571
+ // row — one transient storage failure reported once per script on the
2572
+ // page, with nothing ever retrying it. Dropping the entry restores the
2573
+ // pre-batch blast radius: the read that failed fails, its siblings try
2574
+ // again. The identity check keeps a late rejection from evicting a
2575
+ // newer entry that has already replaced it.
2576
+ void pending.catch(() => {
2577
+ if (batch.collectionRows.get(collectionId) === pending) {
2578
+ batch.collectionRows.delete(collectionId);
2579
+ }
2580
+ });
2581
+ return finish(pending);
2582
+ }
2583
+ // Refetch a single script. Goes through the same per-file pipeline as
2584
+ // the directory-level Refresh (fetchOrSkipInParallel →
2585
+ // rebuildCollection) so the two paths can't drift — same skip rules,
2586
+ // same fsMtime handling, same enabled-preservation, same error shape.
2587
+ // The single-file path additionally gets the size+mtime the skip check
2588
+ // needs: by listing the file's parent dir, or, on a page-load check
2589
+ // (`localLookup: 'known'`), from a direct read of the file, listing only
2590
+ // if that read fails.
2591
+ //
2592
+ // A local-directory script whose file the parent listing no longer
2593
+ // shows, and whose read then fails, is dropped here, the same as the
2594
+ // directory-level Refresh would drop it: the listing is the directory
2595
+ // saying what it holds now, and this path lists it for the size+mtime
2596
+ // skip (a page-load check only once its direct read has failed). Only a
2597
+ // listing that was read — a failed listing leaves the scan bare and the
2598
+ // row alone — and only in a directory
2599
+ // collection: a manifest entry or a single file is listed for the
2600
+ // skip too, but its row is the manifest's or the user's to drop, so a
2601
+ // vanished one fails its fetch and keeps running from the cached body
2602
+ // with a `refreshError`. The read is required too because this path
2603
+ // runs on every navigation and tab focus, so it can catch a file in
2604
+ // the gap of an unlink-and-rename save; one that reads fine is not
2605
+ // gone.
2606
+ //
2607
+ // Timed, because this is what the reload fast-path races against a
2608
+ // 500 ms window (docs/injection.md#reload-fast-path) and "it didn't
2609
+ // make it" was previously unattributable. The phases mirror the
2610
+ // collection-refresh timer's so the two lines read the same way.
2611
+ export async function refreshScript(scriptId, opts = {}) {
2612
+ const timer = startPhaseTimer('script check');
2613
+ // The script's row (from the worker's row cache, so usually no
2614
+ // storage call) and its collection's row, necessarily in series: the
2615
+ // collection key comes out of the script row. The script's stored
2616
+ // code is read separately, alongside the file reads below
2617
+ // (`bodies` in the `gets:` note).
2618
+ //
2619
+ // History: these were two storage reads, measured at ~150-190ms
2620
+ // combined on the reload fast path — by far its largest fixed cost,
2621
+ // and paid by unchanged scripts too — so they are marked separately.
2622
+ // The script row then carried the whole source; the collection row is
2623
+ // a few fields, and it used to read SLOWER — which was never about
2624
+ // either row. A
2625
+ // full-namespace storage scan elsewhere in the worker was holding
2626
+ // the only thread, so the read's callback couldn't run. Neither
2627
+ // payload nor the batch's duplicate reads explained it; both were
2628
+ // guessed first and both were wrong.
2629
+ //
2630
+ // The `gets:` note is what settled it, and is why it stays: it
2631
+ // measures the read itself, next to a phase that measures the read
2632
+ // PLUS whoever else owns the thread. When those two disagree, the
2633
+ // phase is not describing this code. See
2634
+ // docs/diagnostics.md#read-collection-is-still-slow.
2635
+ //
2636
+ // "The read itself" is really `getRaw`, which gates on
2637
+ // `ensureCollectionKeysMigrated`. Memoised, so free after the first
2638
+ // call in a worker generation — but the first check after a wake
2639
+ // attributes the migration probe to `gets: script`, and on an
2640
+ // unmigrated profile the whole migration scan with it.
2641
+ const scriptReadStarted = timingNow();
2642
+ const meta = await getScriptMeta(scriptId);
2643
+ const scriptReadMs = timingNow() - scriptReadStarted;
2644
+ if (!meta)
2645
+ return null;
2646
+ timer.mark('read-script');
2647
+ // The bodies (one storage call for the script's blobs), in flight
2648
+ // while the collection row and the files are read. Nothing before
2649
+ // `read-bodies` below needs them.
2650
+ let bodiesReadMs = 0;
2651
+ const priorRead = (async () => {
2652
+ const started = timingNow();
2653
+ try {
2654
+ return await getScript(scriptId);
2655
+ }
2656
+ finally {
2657
+ bodiesReadMs = timingNow() - started;
2658
+ }
2659
+ })();
2660
+ // Settled below in every path; this keeps an early return from
2661
+ // leaving a rejection unhandled.
2662
+ priorRead.catch(() => { });
2663
+ // Shared across the batch when there is one: the scripts a page
2664
+ // matches are usually all in the same collection, so this row is read
2665
+ // once for the group instead of once per script.
2666
+ const collection = await batchCollectionRow(opts.batch, meta.collectionId);
2667
+ const collectionRow = collection.row;
2668
+ if (!collectionRow)
2669
+ return null;
2670
+ timer.mark('read-collection');
2671
+ if (collection.shared)
2672
+ timer.note('collection row shared with batch');
2673
+ else
2674
+ timer.note(() => `gets: script ${formatMs(scriptReadMs)}, collection ${formatMs(collection.ownReadMs)}`);
2675
+ timer.note(meta.relativePath);
2676
+ timer.note(() => `${Math.round((meta.size ?? 0) / 1024)} KB row`);
2677
+ // Build a single-entry scan. Any `file://` script gets the size+mtime
2678
+ // its collection's refresh would give it, from a listing of its
2679
+ // folder or a direct read; HTTP entries rely on the conditional GET
2680
+ // inside fetchOrSkipInParallel.
2681
+ const scan = [{ relativePath: meta.relativePath }];
2682
+ const fileUrl = scriptFileUrl(collectionRow.path, meta.relativePath);
2683
+ const usesListingStat = isFileUrl(fileUrl);
2684
+ const knownPaths = opts.batch?.localLookup === 'known';
2685
+ // The parent listing was read and does not show the file.
2686
+ let unlisted = false;
2687
+ const localFiles = {
2688
+ ...localFileScope(collectionRow),
2689
+ ...(knownPaths ? { lookup: 'known' } : {}),
2690
+ };
2691
+ if (knownPaths && opts.batch && usesListingStat && localFiles.enabled) {
2692
+ // Read in parallel with the script file below, rather than after
2693
+ // it: which files to load comes from the script's header, but the
2694
+ // stand-ins the last check found are the likely answer. Only a
2695
+ // `file://` script with local files on can have stand-ins; a
2696
+ // scratch script's `localUrl`s name files sm-dev pushed, which are
2697
+ // read from storage instead.
2698
+ prefetchKnownStandIns(opts.fileFetchCache ?? opts.batch.fileFetchCache, meta);
2699
+ }
2700
+ // A page-load check reads the file itself rather than listing its
2701
+ // folder: the read reports the same size and modified time, and the
2702
+ // body comes with it. Only a read that fails lists the folder, below,
2703
+ // to tell a deleted file from one that wouldn't read.
2704
+ if (usesListingStat && knownPaths) {
2705
+ try {
2706
+ const read = await fetchScriptFileWithStat(fileUrl);
2707
+ scan[0].size = read.size;
2708
+ if (read.fsMtime !== undefined)
2709
+ scan[0].mtime = read.fsMtime;
2710
+ scan[0].text = read.text;
2711
+ timer.note('read directly');
2712
+ }
2713
+ catch {
2714
+ timer.note('direct read failed; listed the folder');
2715
+ }
2716
+ }
2717
+ if (usesListingStat && scan[0].text === undefined) {
2718
+ const fsPath = locationFsPath(fileUrl);
2719
+ const dirAbs = parentDir(fsPath);
2720
+ // Read BEFORE the call, which is what populates the cache.
2721
+ const sharedListing = hasCachedListing(opts.batch?.listings, dirAbs);
2722
+ let stat;
2723
+ try {
2724
+ stat = await statFromListing(dirAbs, baseName(fsPath), opts.batch?.listings);
2725
+ }
2726
+ catch {
2727
+ /* listing failed; scan stays bare → always-fetch */
2728
+ }
2729
+ if (sharedListing)
2730
+ timer.note('listing shared with batch');
2731
+ // Listed, and not there. Not dropped yet: the fetch below runs
2732
+ // anyway (a bare scan means always-fetch), and a file that is back
2733
+ // by the time its body is read is a file mid-save — an editor that
2734
+ // writes by unlink + rename — not a deleted one. Only a listing
2735
+ // that lacked it AND a read that failed drops the row.
2736
+ unlisted = stat === null;
2737
+ if (stat) {
2738
+ if (stat.size !== undefined)
2739
+ scan[0].size = stat.size;
2740
+ if (stat.mtime !== undefined)
2741
+ scan[0].mtime = stat.mtime;
2742
+ }
2743
+ }
2744
+ // A direct read on a page-load check, else one `file://` directory
2745
+ // listing, shared across the batch when there is one — so N scripts
2746
+ // in the same folder list it once between them rather than N times.
2747
+ // Unbatched callers still list per script.
2748
+ timer.mark('stat');
2749
+ const prior = await priorRead;
2750
+ if (!prior)
2751
+ return null;
2752
+ // Moved to another file or collection between the two reads: the
2753
+ // file read above was of where it used to be. Rare (a move takes the
2754
+ // store's lock); start again from where it is now.
2755
+ if (prior.relativePath !== meta.relativePath || prior.collectionId !== meta.collectionId) {
2756
+ timer.note('moved during the check; checking again');
2757
+ timer.done();
2758
+ return await refreshScript(scriptId, opts);
2759
+ }
2760
+ // Usually 0: the bodies arrived while the file was read.
2761
+ timer.mark('read-bodies');
2762
+ timer.note(() => `bodies ${formatMs(bodiesReadMs)}`);
2763
+ const fetchResults = await fetchOrSkipInParallel(collectionRow.path, scan, [prior]);
2764
+ // Cheap when size+mtime match the stored row (no body read at all),
2765
+ // expensive when they don't. The `skipped` note is what separates
2766
+ // "the file really changed" from "we re-read it for no reason".
2767
+ timer.mark('fetch');
2768
+ timer.note(() => {
2769
+ const one = [...fetchResults.values()][0];
2770
+ return one && 'unchanged' in one ? 'unchanged' : 'refetched';
2771
+ });
2772
+ const fetched = fetchResults.get(prior.relativePath);
2773
+ // Only a directory collection mirrors the disk. A manifest entry
2774
+ // whose file is gone is still the manifest's to keep or drop, so it
2775
+ // stays — running from its cached body with a `refreshError`.
2776
+ if (unlisted && isLocalDirectoryCollection(collectionRow.path)
2777
+ && fetched && 'ok' in fetched && !fetched.ok) {
2778
+ // The directory doesn't list it and it can't be read: the file is
2779
+ // gone. Dropped the way the collection-level refresh drops it
2780
+ // (row, storage key, logs) — see the header comment. Callers
2781
+ // already handle a null result as "the row went away during the
2782
+ // check".
2783
+ console.info(`[SourceMonkey] ${prior.relativePath}: file no longer in its directory; dropping the script`);
2784
+ timer.note('file not in listing and unreadable; dropping');
2785
+ await removeScript(scriptId);
2786
+ timer.mark('drop');
2787
+ timer.done();
2788
+ return null;
2789
+ }
2790
+ const result = await rebuildCollection({
2791
+ collectionId: prior.collectionId,
2792
+ collectionPath: collectionRow.path,
2793
+ existing: [prior],
2794
+ scan,
2795
+ fetchResults,
2796
+ now: Date.now(),
2797
+ recheckFiles: opts.recheckFiles,
2798
+ // The batch's, when there is one — so two scripts on the same page
2799
+ // `@require`ing one library fetch it once between them. The `??`
2800
+ // only reflects that `fileFetchCache` is part of the inherited
2801
+ // `RefreshOptions`; no caller of `refreshScript` sets it today.
2802
+ fileFetchCache: opts.fileFetchCache ?? opts.batch?.fileFetchCache,
2803
+ localFiles,
2804
+ parseCheck: opts.parseCheck,
2805
+ });
2806
+ const next = result[0];
2807
+ if (!next)
2808
+ return null;
2809
+ // Covers the header parse, the `@require`/`@resource` pass (which
2810
+ // reaches the network in `recheckFiles: 'all'` mode), the source
2811
+ // hash, and the parse check.
2812
+ timer.mark('rebuild');
2813
+ // Nothing to write, the common case: answered from the worker's copy
2814
+ // of the row, without the store's lock or a storage call. Only when
2815
+ // nothing would be written, so it's no write outside the lock.
2816
+ const unchanged = await unchangedAsStored(scriptId, next, prior);
2817
+ if (unchanged) {
2818
+ timer.note('nothing to write');
2819
+ timer.mark('write');
2820
+ timer.done();
2821
+ if (opts.parseCheck === 'defer')
2822
+ checkParsesInBackground(unchanged);
2823
+ return unchanged;
2824
+ }
2825
+ // Re-read inside the lock and write atomically so edits that landed
2826
+ // during the fetch (an enable toggle, an update probe) aren't
2827
+ // reverted by values captured before them.
2828
+ const written = await mutate(async () => {
2829
+ // Without its bodies: see `changedScriptRows`.
2830
+ const cur = await getRow(scriptKey(scriptId));
2831
+ // The row is gone. We read it at the top of this function, so its
2832
+ // absence now means something deleted it while we were fetching —
2833
+ // `removeCollection` (which drops the collection row and every
2834
+ // script under it) or a per-script removal. Writing would resurrect
2835
+ // an orphan: a script row no collection's `scriptIds` references,
2836
+ // invisible to the UI and never refreshed again.
2837
+ //
2838
+ // The window pre-dates the batch — the collection row was always
2839
+ // read long before this write, with the fetch and rebuild in
2840
+ // between — but a shared row widens it, because a sibling that
2841
+ // starts after the removal now takes the cached collection row
2842
+ // instead of reading `undefined` and bailing early. Checked here
2843
+ // rather than by re-reading the collection row: this covers both
2844
+ // removal paths, and costs nothing since we needed `cur` anyway.
2845
+ if (!cur)
2846
+ return null;
2847
+ const merged = preserveConcurrentEdits(next, prior, cur);
2848
+ // Same rule as the collection path: a check that found nothing new
2849
+ // doesn't rewrite the row. This one matters most — it's the
2850
+ // opportunistic path, so it runs on navigations rather than clicks.
2851
+ // There's no collection row here to carry a "we checked" timestamp,
2852
+ // and that's fine: nothing reads a per-script one.
2853
+ const changed = await changedScriptRows([merged], { [scriptKey(scriptId)]: cur }, () => prior);
2854
+ if (changed.count > 0)
2855
+ await writeLocal(changed.items);
2856
+ return merged;
2857
+ });
2858
+ timer.mark('write');
2859
+ timer.done();
2860
+ // Deferred check, started AFTER the write and deliberately not
2861
+ // awaited: the caller that asked to defer is one a page is waiting
2862
+ // on. Started from the row as written, so the verdict is judged
2863
+ // against what is actually stored.
2864
+ //
2865
+ // Asks about the row rather than about the mode the ENTRY ran in, so
2866
+ // an http(s) entry that `rebuildCollection` downgraded to `'await'`
2867
+ // reaches here too. It is a no-op unless that awaited check failed
2868
+ // open, in which case this retries it — one extra compile the pure-
2869
+ // await path wouldn't have made, on a row that genuinely has no
2870
+ // verdict. Not worth plumbing the per-entry decision back out for.
2871
+ // `null` when the row was deleted mid-refresh (see the write above).
2872
+ // Callers already have a story for it: `runScriptCheck` logs "row
2873
+ // vanished" to the SW console, and nothing to the script's Log, which
2874
+ // went with the row.
2875
+ if (!written)
2876
+ return null;
2877
+ if (opts.parseCheck === 'defer')
2878
+ checkParsesInBackground(written);
2879
+ return written;
2880
+ }
2881
+ // A refresh's result merged onto the stored row, when writing it would
2882
+ // change nothing; else null, and the caller takes the lock and writes.
2883
+ //
2884
+ // Checked outside the lock against the row as stored now (the row
2885
+ // cache, so no storage call in the worker), as `changedScriptRows`
2886
+ // would inside it. A write by someone else after this doesn't make it
2887
+ // wrong: not writing leaves their row, as a later write of ours would
2888
+ // have been compared against it anyway.
2889
+ //
2890
+ // Only when the bodies the row points at are known to be there: the
2891
+ // read of `prior` found them all (a missing one makes it a
2892
+ // `MISSING_BLOB_MESSAGE` row). The locked path's key listing is what
2893
+ // restores a missing one, so that case goes there.
2894
+ async function unchangedAsStored(scriptId, next, prior) {
2895
+ if (prior.errorMessage === MISSING_BLOB_MESSAGE)
2896
+ return null;
2897
+ const cur = await getRow(scriptKey(scriptId));
2898
+ if (!cur)
2899
+ return null;
2900
+ // New content always moves one of these, so a write is known without
2901
+ // hashing the bodies here, which the locked path would do again.
2902
+ if (next.loadedAt !== cur.loadedAt || next.contentHash !== cur.contentHash)
2903
+ return null;
2904
+ const merged = preserveConcurrentEdits(next, prior, cur);
2905
+ carryBodyRef(prior, merged);
2906
+ const form = await storedForm(merged);
2907
+ return sameStoredValue(form.row, cur) ? merged : null;
2908
+ }
2909
+ // Re-apply the parts of a script row that a refresh doesn't own, onto
2910
+ // the row it just built. Called inside the write lock by both refresh
2911
+ // entry points, against the row as it stands right now (`cur`) and the
2912
+ // snapshot the refresh started from (`prior`).
2913
+ //
2914
+ // - `enabled` — only when it actually changed under us, which is the
2915
+ // signal that a setScriptEnabled landed mid-fetch. Otherwise a
2916
+ // deliberate refresh-owned change has to win.
2917
+ // - `versionProbes` — always, because no refresh path ever produces
2918
+ // them. They arrive only from setVersionProbes, so whatever is
2919
+ // persisted now is by definition newer than what the refresh
2920
+ // started with.
2921
+ // - `collectionId` — always, for the same reason: only `moveScript`
2922
+ // writes it, so the stored value is a move that landed during the
2923
+ // fetch. A refresh rebuilds the row under the collection it was
2924
+ // asked about, and writing that back would silently revert the
2925
+ // move and leave the two collections disagreeing about who owns
2926
+ // the row. (The collection path drops such a row before it gets
2927
+ // here — see `departed` in `writeRefreshedCollection`; this is
2928
+ // what covers the single-script path.)
2929
+ function preserveConcurrentEdits(next, prior, cur) {
2930
+ if (!cur)
2931
+ return next;
2932
+ const out = { ...next };
2933
+ if (prior && cur.enabled !== prior.enabled)
2934
+ out.enabled = cur.enabled;
2935
+ out.collectionId = cur.collectionId;
2936
+ if (cur.versionProbes)
2937
+ out.versionProbes = cur.versionProbes;
2938
+ else
2939
+ delete out.versionProbes;
2940
+ return out;
2941
+ }
2942
+ // The writes for those of `finalScripts` that actually differ from
2943
+ // what's already stored, or point at a blob that isn't stored: each
2944
+ // one's small row, plus the blobs not yet stored.
2945
+ //
2946
+ // Every refresh path rebuilds a full row per script — including the ones
2947
+ // that took the size+mtime / 304 skip and came back byte-identical — so
2948
+ // without this a refresh of N scripts writes N rows to answer a question
2949
+ // about one.
2950
+ //
2951
+ // Compared in stored form (`storedForm`): the rebuilt script's small row
2952
+ // against the stored row, read without its bodies. A body that didn't
2953
+ // change has the same hash, so the comparison needs no blob read, and
2954
+ // the write that follows doesn't hash the body again. A format 1 row
2955
+ // (bodies inline) never compares equal, so it's rewritten in format 2.
2956
+ //
2957
+ // Chrome already declines to fire `storage.onChanged` for a `set` whose
2958
+ // value matches what's stored (the behavior `retryRejectedRegistrations`
2959
+ // in injector.ts is built around), so this changes no downstream
2960
+ // listener — it drops the serialize-and-store cost that happens before
2961
+ // Chrome gets far enough to compare.
2962
+ //
2963
+ // Callers must still return the FULL list to their own callers: what
2964
+ // gets written narrows, what a refresh reports does not.
2965
+ //
2966
+ // Takes the rows as read inside the write lock, never the pre-fetch
2967
+ // snapshot — comparing against a stale read would skip a write that a
2968
+ // concurrent edit had made necessary. (`refreshScript` asks first,
2969
+ // outside the lock, against the row as stored now: `unchangedAsStored`.)
2970
+ //
2971
+ // `priorOf` gives the copy each refresh started from, read with its
2972
+ // bodies: a body unchanged from it reuses its hash (`carryBodyRef`).
2973
+ async function changedScriptRows(finalScripts, curByKey, priorOf) {
2974
+ if (finalScripts.length === 0)
2975
+ return { count: 0, items: {} };
2976
+ const stored = new Set(await chrome.storage.local.getKeys());
2977
+ const items = {};
2978
+ let count = 0;
2979
+ for (const s of finalScripts) {
2980
+ carryBodyRef(priorOf(s.id), s);
2981
+ const form = await storedForm(s);
2982
+ const absent = newBlobItems(form.blobs, stored);
2983
+ // An equal row whose blob is gone still gets written: the refresh
2984
+ // that just fetched the body again is what puts the blob back.
2985
+ if (Object.keys(absent).length === 0 && sameStoredValue(form.row, curByKey[scriptKey(s.id)]))
2986
+ continue;
2987
+ Object.assign(items, { [scriptKey(s.id)]: form.row }, absent);
2988
+ count++;
2989
+ }
2990
+ return { count, items };
2991
+ }
2992
+ // The fields a `script:*` write can move without touching anything
2993
+ // downstream of the match index's change listener: the registration,
2994
+ // the match index, the tab badges. See `onlyAnnotationsChanged`.
2995
+ // - `versionProbes`: an update check's result (`setVersionProbes`).
2996
+ // - `parseCheckedHash`: the parse check's verdict, when it passed. It
2997
+ // lands just after the change it checked, whose own write started
2998
+ // a sync then; the registration built from the row is the same
2999
+ // either way. A failed one changes `status` too, which does count.
3000
+ const ANNOTATION_FIELDS = ['versionProbes', 'parseCheckedHash'];
3001
+ // A row without its annotations, for comparing two rows apart from them.
3002
+ // `sameStoredValue` treats an undefined-valued key as absent, so that's
3003
+ // how a field is left out.
3004
+ export function withoutAnnotations(row) {
3005
+ const out = { ...row };
3006
+ for (const field of ANNOTATION_FIELDS)
3007
+ out[field] = undefined;
3008
+ return out;
3009
+ }
3010
+ // Structural equality over the JSON shapes `chrome.storage.local` round-
3011
+ // trips: objects, arrays, and primitives.
3012
+ //
3013
+ // Key ORDER is deliberately not significant. A stored row's order comes
3014
+ // from however it was last serialized, while a rebuilt one's comes from
3015
+ // the spread-and-delete sequence that built it (`{...prior}` then
3016
+ // `delete next.refreshError`, say) — so two rows that mean the same
3017
+ // thing routinely disagree on order, and a `JSON.stringify` comparison
3018
+ // would call every one of them changed and quietly do nothing.
3019
+ //
3020
+ // `undefined`-valued keys are treated as absent, matching what a storage
3021
+ // round-trip does to them: a freshly built row carrying `foo: undefined`
3022
+ // and a stored row with no `foo` at all are the same row.
3023
+ //
3024
+ // Exported for unit tests.
3025
+ export function sameStoredValue(a, b) {
3026
+ if (a === b)
3027
+ return true;
3028
+ if (a === null || b === null)
3029
+ return false;
3030
+ if (typeof a !== 'object' || typeof b !== 'object')
3031
+ return false;
3032
+ if (Array.isArray(a) || Array.isArray(b)) {
3033
+ if (!Array.isArray(a) || !Array.isArray(b))
3034
+ return false;
3035
+ if (a.length !== b.length)
3036
+ return false;
3037
+ return a.every((v, i) => sameStoredValue(v, b[i]));
3038
+ }
3039
+ const ao = a;
3040
+ const bo = b;
3041
+ const aKeys = Object.keys(ao).filter((k) => ao[k] !== undefined);
3042
+ const bKeys = Object.keys(bo).filter((k) => bo[k] !== undefined);
3043
+ if (aKeys.length !== bKeys.length)
3044
+ return false;
3045
+ // `hasOwnProperty`, not `k in bo`: a row that came back through
3046
+ // `JSON.parse` can carry an own `__proto__`, and `in` would answer for
3047
+ // the prototype chain — comparing a stored value against an inherited
3048
+ // function. That direction only ever yields "changed" (a redundant
3049
+ // write), but own-keys-only is what this means, so it says so.
3050
+ return aKeys.every((k) => Object.prototype.hasOwnProperty.call(bo, k) && sameStoredValue(ao[k], bo[k]));
3051
+ }
3052
+ // Fetch each scanned file, at most
3053
+ // FETCH_SCRIPTS_PER_COLLECTION_CONCURRENCY at a time.
3054
+ //
3055
+ // Dispatch is per *entry URL*, not per collection: a manifest
3056
+ // collection may list http(s):// entries, file:// entries, or a mix, so
3057
+ // each entry picks its fetcher and skip strategy based on the resolved
3058
+ // URL scheme.
3059
+ //
3060
+ // Scheme behaviors:
3061
+ // - http(s)://: conditional GET. We send the prior row's ETag /
3062
+ // Last-Modified as If-None-Match / If-Modified-Since; a server 304
3063
+ // becomes `unchanged`, a 200 carries new validators back through
3064
+ // FetchResult.ok for rebuildCollection to persist.
3065
+ // - file://: direct fetch. If the scan supplied size+mtime (every
3066
+ // scan does for a local file whose folder listed), we apply the
3067
+ // pre-fetch listing skip when those match the prior row.
3068
+ //
3069
+ // In both modes a prior error/missing entry always refetches so it has
3070
+ // a chance to recover.
3071
+ export async function fetchOrSkipInParallel(collectionPath, scan, existing) {
3072
+ const byPath = new Map(existing.map((s) => [s.relativePath, s]));
3073
+ // Bounded, not `Promise.all`: an unbounded fan-out over a large
3074
+ // collection holds every fetched body live at once. See
3075
+ // src/extension/concurrency.ts.
3076
+ const results = await mapWithConcurrency(scan, FETCH_SCRIPTS_PER_COLLECTION_CONCURRENCY, async (found) => {
3077
+ const prior = byPath.get(found.relativePath);
3078
+ const url = scriptFileUrl(collectionPath, found.relativePath);
3079
+ if (isHttpUrl(url)) {
3080
+ try {
3081
+ const fetched = await fetchHttpText(url, prior?.status === 'ok' ? {
3082
+ etag: prior.httpEtag,
3083
+ lastModified: prior.httpLastModified,
3084
+ } : {});
3085
+ if (fetched.kind === 'unchanged') {
3086
+ return [found.relativePath, { unchanged: true }];
3087
+ }
3088
+ const ok = { ok: true, text: fetched.text };
3089
+ if (fetched.etag)
3090
+ ok.httpEtag = fetched.etag;
3091
+ if (fetched.lastModified)
3092
+ ok.httpLastModified = fetched.lastModified;
3093
+ return [found.relativePath, ok];
3094
+ }
3095
+ catch (err) {
3096
+ return [found.relativePath, { ok: false, errorMessage: describeError(err) }];
3097
+ }
3098
+ }
3099
+ // file:// (or any other non-HTTP URL the file fetcher can handle).
3100
+ const canSkip = prior != null &&
3101
+ prior.status === 'ok' &&
3102
+ found.size !== undefined && found.mtime !== undefined &&
3103
+ prior.size === found.size &&
3104
+ prior.fsMtime === found.mtime;
3105
+ if (canSkip) {
3106
+ return [found.relativePath, { unchanged: true }];
3107
+ }
3108
+ if (found.text !== undefined)
3109
+ return [found.relativePath, { ok: true, text: found.text }];
3110
+ try {
3111
+ const text = await fetchScriptFileUrlText(url);
3112
+ return [found.relativePath, { ok: true, text }];
3113
+ }
3114
+ catch (err) {
3115
+ return [found.relativePath, { ok: false, errorMessage: describeError(err) }];
3116
+ }
3117
+ });
3118
+ return new Map(results);
3119
+ }
3120
+ // Refresh every registered collection, a bounded few at a time. Returns
3121
+ // the new state per collection so callers can compute "what changed"
3122
+ // without a second read.
3123
+ //
3124
+ // One collection failing never takes the pass down, and never costs the
3125
+ // others their results — its slot in the result map is simply absent.
3126
+ // Both failure kinds have a durable home to be read from afterwards, so
3127
+ // dropping the throw loses nothing:
3128
+ // - a scan failure → the collection row's `scanError`
3129
+ // - a write failure → the session-storage map in src/extension/refresh-error.ts
3130
+ //
3131
+ // Rethrowing instead would reject the caller at the first failure while
3132
+ // the remaining collections were still being written, so their outcomes
3133
+ // could never be reported — and on an exhausted quota EVERY collection
3134
+ // fails, which made that the normal case rather than the rare one.
3135
+ export async function refreshAllCollections(opts = {}) {
3136
+ const collections = await getCollections();
3137
+ // One shared file-fetch cache across every collection in this pass so
3138
+ // two scripts (across two collections) referencing the same canonical
3139
+ // URL share one fetch. Caller can override by passing their own.
3140
+ const fileFetchCache = opts.fileFetchCache ?? new Map();
3141
+ // Bounded like the per-file fan-out inside each collection: without a
3142
+ // cap here, N collections would each run their own in-flight set at
3143
+ // the same time and the per-collection bound wouldn't cap anything.
3144
+ // Deliberately the smaller limit of the pair, because each slot here
3145
+ // expands into a whole collection's worth of fetches — and because a
3146
+ // profile with many collections is the rarer case. See
3147
+ // src/extension/concurrency.ts.
3148
+ const entries = await mapWithConcurrency(collections, FETCH_COLLECTIONS_CONCURRENCY, async (s) => {
3149
+ try {
3150
+ const r = await refreshCollection(s.id, { ...opts, fileFetchCache });
3151
+ return [s.id, r];
3152
+ }
3153
+ catch (err) {
3154
+ // `refreshCollection` already recorded the reason where the UI
3155
+ // reads it; all this has to do is keep the sibling collections
3156
+ // running. The console line is for the SW log, not the user —
3157
+ // named, because the motivating case fails every collection at
3158
+ // once and an unattributed line would be noise. Says "failed"
3159
+ // rather than naming a cause: most throws here are the write,
3160
+ // but this catch doesn't verify that.
3161
+ console.info(`[SourceMonkey] collection refresh failed: ${s.path}: ${describeError(err)}`);
3162
+ return [s.id, null];
3163
+ }
3164
+ });
3165
+ const out = new Map();
3166
+ for (const [id, r] of entries) {
3167
+ if (r)
3168
+ out.set(id, r);
3169
+ }
3170
+ return out;
3171
+ }
3172
+ // Chrome's own wording for a `chrome.storage.local` write that would
3173
+ // exceed the extension's quota. The text has changed across versions
3174
+ // (`QUOTA_BYTES quota exceeded`, `Resource::kQuotaBytes quota
3175
+ // exceeded`), so match the constant part.
3176
+ const CHROME_QUOTA_REJECTION = /quota exceeded/i;
3177
+ // The one message for a write that didn't fit. Names the cause and the
3178
+ // fix, and takes the place of the internal-vocabulary wording Chrome
3179
+ // gives — a user reading `Resource::kQuotaBytes quota exceeded` in a
3180
+ // Log entry has nothing to act on. Two sentences, so both are
3181
+ // punctuated (docs/diagnostics.md#wording).
3182
+ export function storageFullMessage() {
3183
+ const quota = chrome.storage.local.QUOTA_BYTES;
3184
+ const limit = typeof quota === 'number' && quota > 0
3185
+ ? ` (Chrome allows ${Math.round(quota / (1024 * 1024))} MB)`
3186
+ : '';
3187
+ // "Out of storage space" is also the collections banner's heading
3188
+ // (`renderStorageBanner`), so the two read as one condition.
3189
+ return `SourceMonkey is out of storage space${limit}. `
3190
+ + 'Remove a script or collection you no longer need to free space.';
3191
+ }
3192
+ // Every `chrome.storage.local.set` in this module goes through here, so
3193
+ // an out-of-quota rejection reads the same wherever it surfaces — the
3194
+ // install callout, a Log entry, the collections banner. Any other
3195
+ // rejection is passed through as Chrome worded it.
3196
+ //
3197
+ // Script rows are written in format 2: a full script splits into its
3198
+ // small row plus the blobs it points at, in this same `set`.
3199
+ async function writeLocal(items) {
3200
+ assertWorkerContext('write storage');
3201
+ await storeFormatReady();
3202
+ await setLocal(await withBlobs(items));
3203
+ }
3204
+ // `writeLocal` without the format handling, for the key-rename
3205
+ // migration, which runs before the format migration and writes rows as
3206
+ // it read them.
3207
+ async function setLocal(items) {
3208
+ assertWorkerContext('write storage');
3209
+ try {
3210
+ await chrome.storage.local.set(items);
3211
+ }
3212
+ catch (err) {
3213
+ throw translateStorageError(err);
3214
+ }
3215
+ }
3216
+ // Every script-row removal in this module, so none lands while the
3217
+ // worker is still converting rows (a converted copy would bring the row
3218
+ // back). The blobs stay; see docs/storage.md#writing-and-cleanup.
3219
+ async function removeLocal(keys) {
3220
+ assertWorkerContext('write storage');
3221
+ await storeFormatReady();
3222
+ await chrome.storage.local.remove(keys);
3223
+ }
3224
+ // Chrome's error rides along as `cause` for DevTools; `describeError`
3225
+ // reads only `.message`, so it never reaches a user-facing string.
3226
+ function translateStorageError(err) {
3227
+ return CHROME_QUOTA_REJECTION.test(describeError(err))
3228
+ ? new Error(storageFullMessage(), { cause: err })
3229
+ : err;
3230
+ }
3231
+ // Flip a collection's enabled flag. The match-index listens for
3232
+ // `collection:<id>` changes and re-evaluates the effective enabled state
3233
+ // of every script under this collection. Turning it on or off doesn't
3234
+ // touch other collections; `setCollectionEnabledExclusive`
3235
+ // (script-enablement.ts) is the toggle that keeps one copy active.
3236
+ export async function setCollectionEnabled(id, enabled) {
3237
+ return (await writeEnablement({ collection: { id, enabled } })).collection;
3238
+ }
3239
+ // When each copy last became active and last went inactive, in epoch
3240
+ // ms. Active means its own flag and its collection's are both on, so it
3241
+ // fires on the pages it matches. One storage key holding every script's
3242
+ // times, rather than fields on the script rows: a collection toggle
3243
+ // changes the times of every copy under it, and stamping them on the
3244
+ // rows would rewrite each full row (bodies and all). See
3245
+ // [docs/script-enablement.md](../../docs/script-enablement.md#active-times).
3246
+ export { ENABLEMENT_TIMES_KEY };
3247
+ export async function getEnablementTimes() {
3248
+ return (await getRaw(ENABLEMENT_TIMES_KEY)) ?? {};
3249
+ }
3250
+ // The time for one enable change. Strictly later than this context's
3251
+ // previous one, so two writes in one millisecond can't look like one
3252
+ // change: the restore on collection-off (script-enablement.ts) matches
3253
+ // times exactly.
3254
+ let lastEnablementStamp = 0;
3255
+ function nextEnablementStamp() {
3256
+ lastEnablementStamp = Math.max(Date.now(), lastEnablementStamp + 1);
3257
+ return lastEnablementStamp;
3258
+ }
3259
+ // Record one copy's change in `times` (a copy the caller owns): became
3260
+ // active, went inactive, or (`restarted`) stays active but is stamped as
3261
+ // becoming active now. Returns whether anything changed.
3262
+ function stampTimes(times, id, wasActive, nowActive, at, restarted = false) {
3263
+ if (nowActive && (!wasActive || restarted)) {
3264
+ times[id] = { ...times[id], activeSince: at };
3265
+ return true;
3266
+ }
3267
+ if (wasActive && !nowActive) {
3268
+ times[id] = { ...times[id], inactiveSince: at };
3269
+ return true;
3270
+ }
3271
+ return false;
3272
+ }
3273
+ // Drop active times kept for scripts that no longer exist. A removal
3274
+ // drops its rows' times in the same write, so these are left from
3275
+ // before only the worker wrote, when a removal in a page could race
3276
+ // another context's write of the times back in. `live` is a scan of
3277
+ // the script rows; an id missing from it is re-checked inside the lock,
3278
+ // so a row added since the scan keeps its times.
3279
+ async function pruneEnablementTimes(live) {
3280
+ const stale = Object.keys(await getEnablementTimes()).filter((id) => !live.has(id));
3281
+ if (stale.length === 0)
3282
+ return;
3283
+ await mutate(async () => {
3284
+ const got = await getRows([...stale.map(scriptKey), ENABLEMENT_TIMES_KEY]);
3285
+ const times = { ...got[ENABLEMENT_TIMES_KEY] };
3286
+ const gone = stale.filter((id) => !got[scriptKey(id)] && times[id]);
3287
+ if (gone.length === 0)
3288
+ return;
3289
+ for (const id of gone)
3290
+ delete times[id];
3291
+ await writeLocal({ [ENABLEMENT_TIMES_KEY]: times });
3292
+ });
3293
+ }
3294
+ // The active-times write that forgets `ids` (rows being removed), or
3295
+ // nothing when none of them has times. Called inside the removal's lock.
3296
+ async function withoutTimes(ids) {
3297
+ const times = await getEnablementTimes();
3298
+ if (!ids.some((id) => times[id]))
3299
+ return {};
3300
+ const next = { ...times };
3301
+ for (const id of ids)
3302
+ delete next[id];
3303
+ return { [ENABLEMENT_TIMES_KEY]: next };
3304
+ }
3305
+ // Every enable write goes through here: the flags and the active times
3306
+ // land together in ONE storage write, so one change fires one
3307
+ // `storage.onChanged` and one registration sync. Each copy whose active
3308
+ // state the change flips is stamped with the same time
3309
+ // (`nextEnablementStamp`); a copy whose state stays put is left alone.
3310
+ // Only script rows whose own flag changes are rewritten.
3311
+ //
3312
+ // Returns the collection as written (null when it's gone, or none was
3313
+ // asked for) and every script row whose flag it changed.
3314
+ //
3315
+ // `timer` is the caller's, when it timed its own steps first; the
3316
+ // write's steps are added to it and the caller prints the line.
3317
+ // Otherwise the write prints its own `enablement write` line.
3318
+ export async function writeEnablement(w, timer) {
3319
+ const t = timer ?? startPhaseTimer('enablement write');
3320
+ const result = await mutate(async () => {
3321
+ t.mark('lock wait');
3322
+ return applyEnablement(w, t);
3323
+ });
3324
+ if (!timer)
3325
+ t.done();
3326
+ return result;
3327
+ }
3328
+ // An enable write whose changes depend on the store: which other copies
3329
+ // are active, which ones a collection displaced. `plan` decides them
3330
+ // inside the lock, from `scriptIds` and `collectionIds` (plus each of
3331
+ // those scripts' collections) as they are at that moment, so a write
3332
+ // that landed while this one waited is part of what it sees. Planned
3333
+ // outside the lock, two quick clicks on two copies each saw the other
3334
+ // as off, and both stayed on.
3335
+ //
3336
+ // The caller picks the ids from a read made before the lock, and only
3337
+ // those are re-read: a full read here would hold the lock (and every
3338
+ // other write in this context) for as long as it takes. A copy that
3339
+ // appears between the two reads is missed, which needs an install to
3340
+ // land at that moment.
3341
+ //
3342
+ // `plan` returning null writes nothing.
3343
+ export async function writeEnablementFrom(scope, plan, timer) {
3344
+ const t = timer ?? startPhaseTimer('enablement write');
3345
+ const result = await mutate(async () => {
3346
+ t.mark('lock wait');
3347
+ const ids = [...new Set(scope.scriptIds)];
3348
+ const got = await getRows([...ids.map(scriptKey), ENABLEMENT_TIMES_KEY]);
3349
+ const scripts = ids
3350
+ .map((id) => got[scriptKey(id)])
3351
+ .filter((s) => s != null);
3352
+ const collIds = [...new Set([...(scope.collectionIds ?? []), ...scripts.map((s) => s.collectionId)])];
3353
+ const colls = await getRows(collIds.map(collectionKey));
3354
+ t.mark('re-read', () => storageSize({ ...got, ...colls }));
3355
+ const w = plan({
3356
+ scripts,
3357
+ collections: collIds
3358
+ .map((id) => colls[collectionKey(id)])
3359
+ .filter((c) => c != null),
3360
+ times: got[ENABLEMENT_TIMES_KEY] ?? {},
3361
+ });
3362
+ t.mark('plan');
3363
+ return w ? applyEnablement(w, t) : { collection: null, scripts: [] };
3364
+ });
3365
+ if (!timer)
3366
+ t.done();
3367
+ return result;
3368
+ }
3369
+ // The body of an enable write. Caller holds the lock.
3370
+ async function applyEnablement(w, t) {
3371
+ const at = nextEnablementStamp();
3372
+ const toSet = {};
3373
+ let collection = null;
3374
+ const scriptIds = new Set([...(w.scripts ?? []).map((u) => u.id), ...(w.started ?? [])]);
3375
+ if (w.collection) {
3376
+ collection = (await getRow(collectionKey(w.collection.id))) ?? null;
3377
+ t.mark('read collection');
3378
+ if (collection) {
3379
+ // Its scripts' active state follows its flag, so they may need
3380
+ // stamping even though none of their own flags change.
3381
+ for (const sid of collection.scriptIds)
3382
+ scriptIds.add(sid);
3383
+ collection = { ...collection, enabled: w.collection.enabled };
3384
+ toSet[collectionKey(collection.id)] = collection;
3385
+ }
3386
+ }
3387
+ // The rows as stored: an enable change rewrites a script's small row
3388
+ // and leaves its bodies alone.
3389
+ const got = await getRows([...[...scriptIds].map(scriptKey), ENABLEMENT_TIMES_KEY]);
3390
+ t.mark('read rows', () => storageSize(got));
3391
+ const scripts = [...scriptIds]
3392
+ .map((sid) => got[scriptKey(sid)])
3393
+ .filter((s) => s != null);
3394
+ const times = { ...got[ENABLEMENT_TIMES_KEY] };
3395
+ // Each script's collection, before and after this write.
3396
+ const collIds = [...new Set(scripts.map((s) => s.collectionId))];
3397
+ const colls = await getRows(collIds.map(collectionKey));
3398
+ t.mark('read collections', () => storageSize(colls));
3399
+ const collOn = (cid, after) => {
3400
+ if (after && collection && cid === collection.id)
3401
+ return isCollectionEnabled(collection);
3402
+ const c = colls[collectionKey(cid)];
3403
+ return c ? isCollectionEnabled(c) : true;
3404
+ };
3405
+ const wanted = new Map((w.scripts ?? []).map((u) => [u.id, u.enabled]));
3406
+ const started = new Set(w.started ?? []);
3407
+ const written = [];
3408
+ let timesChanged = false;
3409
+ for (const script of scripts) {
3410
+ const flag = wanted.get(script.id) ?? isScriptEnabled(script);
3411
+ const wasActive = isScriptEnabled(script) && collOn(script.collectionId, false);
3412
+ const nowActive = flag && collOn(script.collectionId, true);
3413
+ if (stampTimes(times, script.id, wasActive, nowActive, at, started.has(script.id)))
3414
+ timesChanged = true;
3415
+ if (script.enabled === flag)
3416
+ continue;
3417
+ const next = { ...script, enabled: flag };
3418
+ toSet[scriptKey(script.id)] = next;
3419
+ written.push(next);
3420
+ }
3421
+ if (timesChanged)
3422
+ toSet[ENABLEMENT_TIMES_KEY] = times;
3423
+ if (Object.keys(toSet).length > 0) {
3424
+ await writeLocal(toSet);
3425
+ t.mark('write', () => storageSize(toSet));
3426
+ }
3427
+ return { collection, scripts: written };
3428
+ }
3429
+ export async function setCollectionOptions(id, options) {
3430
+ return mutate(async () => {
3431
+ const collection = await getRaw(collectionKey(id));
3432
+ if (!collection)
3433
+ return null;
3434
+ const next = { ...collection };
3435
+ if (options.displayName !== undefined) {
3436
+ const name = options.displayName.trim();
3437
+ const inferred = inferDisplayName(collection.path);
3438
+ if (!name && !inferred)
3439
+ throw new Error(COLLECTION_NAME_REQUIRED);
3440
+ // Same rule as `createLocationCollection`: only a name the user
3441
+ // chose is held unique; the inferred one may collide.
3442
+ if (name && name !== inferred) {
3443
+ // The default that already owns a reserved name is exempt — it
3444
+ // keeps it — but nothing else may take it.
3445
+ if (!mayUseReservedName(name, collection.path))
3446
+ throw new Error(RESERVED_COLLECTION_NAME);
3447
+ const all = await getCollections();
3448
+ if (collectionNameInUse(name, all, id))
3449
+ throw new Error(collectionNameInUseMessage(name));
3450
+ }
3451
+ next.displayName = name || inferred;
3452
+ }
3453
+ if (options.localFiles !== undefined)
3454
+ next.localFiles = options.localFiles;
3455
+ await writeLocal({ [collectionKey(id)]: next });
3456
+ return next;
3457
+ });
3458
+ }
3459
+ // Test-surface convenience; the dialog goes through `setCollectionOptions`.
3460
+ export async function setCollectionLocalFiles(id, localFiles) {
3461
+ return setCollectionOptions(id, { localFiles });
3462
+ }
3463
+ // Record what a script's upstream `@updateURL` / `@downloadURL` last
3464
+ // claimed. Separate from every refresh entry point on purpose: a probe
3465
+ // annotates, it never changes what runs, so this touches exactly one
3466
+ // field and leaves `status`, `refreshError`, and the body alone.
3467
+ //
3468
+ // The caller is expected to have already decided a probe was due (see
3469
+ // `probesAreFresh` and `ProbePolicy`), so this always writes: the
3470
+ // refreshed `checkedAt` is what stops the next check from probing
3471
+ // again. Every write costs a storage.onChanged, and thus a userscript
3472
+ // re-registration pass in the background SW — so the only thing
3473
+ // keeping that off the per-navigation and per-reload paths is the
3474
+ // caller's staleness gate. Don't hand this a probe you didn't pace.
3475
+ //
3476
+ // Passing an empty list clears the field, which is how a script that
3477
+ // dropped its `@updateURL` sheds a stale annotation.
3478
+ export async function setVersionProbes(id, probes) {
3479
+ return mutate(async () => {
3480
+ const script = await getRow(scriptKey(id));
3481
+ if (!script)
3482
+ return null;
3483
+ if (probes.length === 0 && !script.versionProbes)
3484
+ return script;
3485
+ const next = { ...script };
3486
+ if (probes.length > 0)
3487
+ next.versionProbes = probes;
3488
+ else
3489
+ delete next.versionProbes;
3490
+ await writeLocal({ [scriptKey(id)]: next });
3491
+ return next;
3492
+ });
3493
+ }
3494
+ export async function setScriptEnabled(id, enabled) {
3495
+ const { scripts } = await writeEnablement({ scripts: [{ id, enabled }] });
3496
+ return scripts[0] ?? (await getScriptMeta(id)) ?? null;
3497
+ }
3498
+ // Flip many scripts' enabled flags in a single storage write. This is
3499
+ // the bulk-toggle path: it fires exactly one `storage.onChanged` event
3500
+ // (instead of N), so the background SW's syncRegistrations runs once
3501
+ // instead of once per script — turning a 15-script toggle from
3502
+ // quadratic-feeling into a single round-trip.
3503
+ export async function setScriptsEnabled(updates, timer) {
3504
+ if (updates.length === 0)
3505
+ return;
3506
+ await writeEnablement({ scripts: updates }, timer);
3507
+ }