@pkcprotocol/pkc-js 0.0.107 → 0.0.108

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 (351) hide show
  1. package/README.md +15 -0
  2. package/dist/browser/clients/base-client-manager.d.ts +1 -1
  3. package/dist/browser/clients/rpc-client/rpc-schema-util.d.ts +16 -0
  4. package/dist/browser/clients/rpc-client/schema.d.ts +16 -0
  5. package/dist/browser/community/remote-community.d.ts +1 -0
  6. package/dist/browser/community/remote-community.js +1 -0
  7. package/dist/browser/community/remote-community.js.map +1 -1
  8. package/dist/browser/community/rpc-local-community.js +13 -0
  9. package/dist/browser/community/rpc-local-community.js.map +1 -1
  10. package/dist/browser/community/schema.d.ts +1369 -1
  11. package/dist/browser/community/schema.js +90 -4
  12. package/dist/browser/community/schema.js.map +1 -1
  13. package/dist/browser/community/types.d.ts +13 -2
  14. package/dist/browser/constants.d.ts +1 -0
  15. package/dist/browser/constants.js +6 -0
  16. package/dist/browser/constants.js.map +1 -1
  17. package/dist/browser/errors.d.ts +15 -1
  18. package/dist/browser/errors.js +15 -0
  19. package/dist/browser/errors.js.map +1 -1
  20. package/dist/browser/generated-version.d.ts +1 -1
  21. package/dist/browser/generated-version.js +1 -1
  22. package/dist/browser/helia/helia-for-pkc.js +41 -4
  23. package/dist/browser/helia/helia-for-pkc.js.map +1 -1
  24. package/dist/browser/helia/util.js +4 -1
  25. package/dist/browser/helia/util.js.map +1 -1
  26. package/dist/browser/index-with-compile-cache.d.ts +559 -0
  27. package/dist/browser/index.d.ts +1120 -0
  28. package/dist/browser/index.js +5 -0
  29. package/dist/browser/index.js.map +1 -1
  30. package/dist/browser/logger.js +10 -1
  31. package/dist/browser/logger.js.map +1 -1
  32. package/dist/browser/object-key-util.d.ts +2 -0
  33. package/dist/browser/object-key-util.js +12 -0
  34. package/dist/browser/object-key-util.js.map +1 -0
  35. package/dist/browser/pages/page-sort-options.d.ts +14 -0
  36. package/dist/browser/pages/page-sort-options.js +92 -0
  37. package/dist/browser/pages/page-sort-options.js.map +1 -0
  38. package/dist/browser/pages/page-sort-scoring.d.ts +35 -0
  39. package/dist/browser/pages/page-sort-scoring.js +124 -0
  40. package/dist/browser/pages/page-sort-scoring.js.map +1 -0
  41. package/dist/browser/pages/pages-client-manager.d.ts +1 -0
  42. package/dist/browser/pages/pages-client-manager.js +16 -4
  43. package/dist/browser/pages/pages-client-manager.js.map +1 -1
  44. package/dist/browser/pages/pkc-js-page-sorts/active.d.ts +3 -0
  45. package/dist/browser/pages/pkc-js-page-sorts/active.js +15 -0
  46. package/dist/browser/pages/pkc-js-page-sorts/active.js.map +1 -0
  47. package/dist/browser/pages/pkc-js-page-sorts/best.d.ts +3 -0
  48. package/dist/browser/pages/pkc-js-page-sorts/best.js +10 -0
  49. package/dist/browser/pages/pkc-js-page-sorts/best.js.map +1 -0
  50. package/dist/browser/pages/pkc-js-page-sorts/controversial.d.ts +3 -0
  51. package/dist/browser/pages/pkc-js-page-sorts/controversial.js +10 -0
  52. package/dist/browser/pages/pkc-js-page-sorts/controversial.js.map +1 -0
  53. package/dist/browser/pages/pkc-js-page-sorts/hot.d.ts +3 -0
  54. package/dist/browser/pages/pkc-js-page-sorts/hot.js +10 -0
  55. package/dist/browser/pages/pkc-js-page-sorts/hot.js.map +1 -0
  56. package/dist/browser/pages/pkc-js-page-sorts/index.d.ts +2 -0
  57. package/dist/browser/pages/pkc-js-page-sorts/index.js +37 -0
  58. package/dist/browser/pages/pkc-js-page-sorts/index.js.map +1 -0
  59. package/dist/browser/pages/pkc-js-page-sorts/new-flat.d.ts +3 -0
  60. package/dist/browser/pages/pkc-js-page-sorts/new-flat.js +12 -0
  61. package/dist/browser/pages/pkc-js-page-sorts/new-flat.js.map +1 -0
  62. package/dist/browser/pages/pkc-js-page-sorts/new.d.ts +3 -0
  63. package/dist/browser/pages/pkc-js-page-sorts/new.js +10 -0
  64. package/dist/browser/pages/pkc-js-page-sorts/new.js.map +1 -0
  65. package/dist/browser/pages/pkc-js-page-sorts/old-flat.d.ts +3 -0
  66. package/dist/browser/pages/pkc-js-page-sorts/old-flat.js +12 -0
  67. package/dist/browser/pages/pkc-js-page-sorts/old-flat.js.map +1 -0
  68. package/dist/browser/pages/pkc-js-page-sorts/old.d.ts +3 -0
  69. package/dist/browser/pages/pkc-js-page-sorts/old.js +10 -0
  70. package/dist/browser/pages/pkc-js-page-sorts/old.js.map +1 -0
  71. package/dist/browser/pages/pkc-js-page-sorts/top-all.d.ts +3 -0
  72. package/dist/browser/pages/pkc-js-page-sorts/top-all.js +10 -0
  73. package/dist/browser/pages/pkc-js-page-sorts/top-all.js.map +1 -0
  74. package/dist/browser/pages/pkc-js-page-sorts/top-day.d.ts +3 -0
  75. package/dist/browser/pages/pkc-js-page-sorts/top-day.js +11 -0
  76. package/dist/browser/pages/pkc-js-page-sorts/top-day.js.map +1 -0
  77. package/dist/browser/pages/pkc-js-page-sorts/top-hour.d.ts +3 -0
  78. package/dist/browser/pages/pkc-js-page-sorts/top-hour.js +11 -0
  79. package/dist/browser/pages/pkc-js-page-sorts/top-hour.js.map +1 -0
  80. package/dist/browser/pages/pkc-js-page-sorts/top-month.d.ts +3 -0
  81. package/dist/browser/pages/pkc-js-page-sorts/top-month.js +11 -0
  82. package/dist/browser/pages/pkc-js-page-sorts/top-month.js.map +1 -0
  83. package/dist/browser/pages/pkc-js-page-sorts/top-week.d.ts +3 -0
  84. package/dist/browser/pages/pkc-js-page-sorts/top-week.js +11 -0
  85. package/dist/browser/pages/pkc-js-page-sorts/top-week.js.map +1 -0
  86. package/dist/browser/pages/pkc-js-page-sorts/top-year.d.ts +3 -0
  87. package/dist/browser/pages/pkc-js-page-sorts/top-year.js +11 -0
  88. package/dist/browser/pages/pkc-js-page-sorts/top-year.js.map +1 -0
  89. package/dist/browser/pages/pkc-js-page-sorts/top.d.ts +3 -0
  90. package/dist/browser/pages/pkc-js-page-sorts/top.js +12 -0
  91. package/dist/browser/pages/pkc-js-page-sorts/top.js.map +1 -0
  92. package/dist/browser/pages/pkc-js-page-sorts/util.d.ts +3 -0
  93. package/dist/browser/pages/pkc-js-page-sorts/util.js +7 -0
  94. package/dist/browser/pages/pkc-js-page-sorts/util.js.map +1 -0
  95. package/dist/browser/pages/schema.d.ts +1 -0
  96. package/dist/browser/pages/schema.js +10 -3
  97. package/dist/browser/pages/schema.js.map +1 -1
  98. package/dist/browser/pages/types.d.ts +8 -0
  99. package/dist/browser/pages/util.d.ts +2 -1
  100. package/dist/browser/pages/util.js +11 -10
  101. package/dist/browser/pages/util.js.map +1 -1
  102. package/dist/browser/pkc/pkc.d.ts +2 -1
  103. package/dist/browser/pkc/pkc.js +2 -1
  104. package/dist/browser/pkc/pkc.js.map +1 -1
  105. package/dist/browser/pkc-error.d.ts +1 -1
  106. package/dist/browser/publications/comment/comment-client-manager.d.ts +2 -2
  107. package/dist/browser/publications/comment/comment-client-manager.js +3 -1
  108. package/dist/browser/publications/comment/comment-client-manager.js.map +1 -1
  109. package/dist/browser/publications/community-edit/schema.d.ts +64 -0
  110. package/dist/browser/pubsub-messages/schema.d.ts +32 -0
  111. package/dist/browser/rpc/src/index.js +14 -1
  112. package/dist/browser/rpc/src/index.js.map +1 -1
  113. package/dist/browser/rpc/src/lib/pkc-js/index.d.ts +559 -0
  114. package/dist/browser/rpc/src/schema.d.ts +3371 -0
  115. package/dist/browser/rpc/src/schema.js +6 -2
  116. package/dist/browser/rpc/src/schema.js.map +1 -1
  117. package/dist/browser/runtime/browser/community/page-sorts/index.d.ts +1 -0
  118. package/dist/browser/runtime/browser/community/page-sorts/index.js +6 -0
  119. package/dist/browser/runtime/browser/community/page-sorts/index.js.map +1 -0
  120. package/dist/browser/runtime/node/community/db-handler.d.ts +78 -20
  121. package/dist/browser/runtime/node/community/db-handler.js +1151 -734
  122. package/dist/browser/runtime/node/community/db-handler.js.map +1 -1
  123. package/dist/browser/runtime/node/community/db-row-parser.d.ts +13 -0
  124. package/dist/browser/runtime/node/community/db-row-parser.js +58 -1
  125. package/dist/browser/runtime/node/community/db-row-parser.js.map +1 -1
  126. package/dist/browser/runtime/node/community/local-community/cleanup.js +23 -10
  127. package/dist/browser/runtime/node/community/local-community/cleanup.js.map +1 -1
  128. package/dist/browser/runtime/node/community/local-community/comment-updates.d.ts +12 -2
  129. package/dist/browser/runtime/node/community/local-community/comment-updates.js +101 -70
  130. package/dist/browser/runtime/node/community/local-community/comment-updates.js.map +1 -1
  131. package/dist/browser/runtime/node/community/local-community/db-state.js +8 -0
  132. package/dist/browser/runtime/node/community/local-community/db-state.js.map +1 -1
  133. package/dist/browser/runtime/node/community/local-community/editing.d.ts +9 -2
  134. package/dist/browser/runtime/node/community/local-community/editing.js +32 -5
  135. package/dist/browser/runtime/node/community/local-community/editing.js.map +1 -1
  136. package/dist/browser/runtime/node/community/local-community/ipns-publishing.d.ts +2 -2
  137. package/dist/browser/runtime/node/community/local-community/ipns-publishing.js +49 -27
  138. package/dist/browser/runtime/node/community/local-community/ipns-publishing.js.map +1 -1
  139. package/dist/browser/runtime/node/community/local-community/lifecycle.d.ts +1 -0
  140. package/dist/browser/runtime/node/community/local-community/lifecycle.js +49 -18
  141. package/dist/browser/runtime/node/community/local-community/lifecycle.js.map +1 -1
  142. package/dist/browser/runtime/node/community/local-community.d.ts +5 -1
  143. package/dist/browser/runtime/node/community/local-community.js +13 -1
  144. package/dist/browser/runtime/node/community/local-community.js.map +1 -1
  145. package/dist/browser/runtime/node/community/page-generator.d.ts +44 -413
  146. package/dist/browser/runtime/node/community/page-generator.js +531 -308
  147. package/dist/browser/runtime/node/community/page-generator.js.map +1 -1
  148. package/dist/browser/runtime/node/community/page-sorts/index.d.ts +64 -0
  149. package/dist/browser/runtime/node/community/page-sorts/index.js +264 -0
  150. package/dist/browser/runtime/node/community/page-sorts/index.js.map +1 -0
  151. package/dist/browser/runtime/node/util.js +1 -1
  152. package/dist/browser/runtime/node/util.js.map +1 -1
  153. package/dist/browser/schema/schema-util.d.ts +142 -1
  154. package/dist/browser/schema/schema-util.js +11 -0
  155. package/dist/browser/schema/schema-util.js.map +1 -1
  156. package/dist/browser/schema/schema.js +1 -1
  157. package/dist/browser/schema/schema.js.map +1 -1
  158. package/dist/browser/schema.d.ts +4469 -0
  159. package/dist/browser/schema.js +2 -1
  160. package/dist/browser/schema.js.map +1 -1
  161. package/dist/browser/signer/signatures.d.ts +1 -1
  162. package/dist/browser/signer/signatures.js +45 -1
  163. package/dist/browser/signer/signatures.js.map +1 -1
  164. package/dist/browser/test/test-util.d.ts +63 -0
  165. package/dist/browser/test/test-util.js +66 -11
  166. package/dist/browser/test/test-util.js.map +1 -1
  167. package/dist/browser/util.d.ts +4 -0
  168. package/dist/browser/util.js +87 -0
  169. package/dist/browser/util.js.map +1 -1
  170. package/dist/browser/version.js +1 -1
  171. package/dist/bundled/bundle-manifest.json +235 -211
  172. package/dist/bundled/chunks/{helia-for-pkc-BCVI3rjp.js → helia-for-pkc-BY1wF9W8.js} +58 -34
  173. package/dist/bundled/chunks/{local-community-t5S6o-0o.js → local-community-B6i3MUzw.js} +1 -1
  174. package/dist/bundled/chunks/{local-community-CK7TUwwd.js → local-community-Dm02mfn3.js} +6719 -6055
  175. package/dist/bundled/chunks/{node-BpoCiJjL.js → node-B8rEQ_rA.js} +1201 -15
  176. package/dist/bundled/chunks/{rpc-local-community-CR1TnLfm.js → rpc-local-community-DHS2R2Vk.js} +13631 -14131
  177. package/dist/bundled/chunks/{util-bxE0EcD4.js → util-B-wz3mKY.js} +1 -1
  178. package/dist/bundled/chunks/{schema-DWRg9_bS.js → util-C6zDjhpr.js} +751 -601
  179. package/dist/bundled/index.js +2 -2
  180. package/dist/bundled/rpc/src/index.js +22 -9
  181. package/dist/node/clients/base-client-manager.d.ts +1 -1
  182. package/dist/node/clients/rpc-client/rpc-schema-util.d.ts +16 -0
  183. package/dist/node/clients/rpc-client/schema.d.ts +16 -0
  184. package/dist/node/community/remote-community.d.ts +1 -0
  185. package/dist/node/community/remote-community.js +1 -0
  186. package/dist/node/community/remote-community.js.map +1 -1
  187. package/dist/node/community/rpc-local-community.js +13 -0
  188. package/dist/node/community/rpc-local-community.js.map +1 -1
  189. package/dist/node/community/schema.d.ts +1369 -1
  190. package/dist/node/community/schema.js +90 -4
  191. package/dist/node/community/schema.js.map +1 -1
  192. package/dist/node/community/types.d.ts +13 -2
  193. package/dist/node/constants.d.ts +1 -0
  194. package/dist/node/constants.js +6 -0
  195. package/dist/node/constants.js.map +1 -1
  196. package/dist/node/errors.d.ts +15 -1
  197. package/dist/node/errors.js +15 -0
  198. package/dist/node/errors.js.map +1 -1
  199. package/dist/node/generated-version.d.ts +1 -1
  200. package/dist/node/generated-version.js +1 -1
  201. package/dist/node/helia/helia-for-pkc.js +41 -4
  202. package/dist/node/helia/helia-for-pkc.js.map +1 -1
  203. package/dist/node/helia/util.js +4 -1
  204. package/dist/node/helia/util.js.map +1 -1
  205. package/dist/node/index-with-compile-cache.d.ts +559 -0
  206. package/dist/node/index.d.ts +1120 -0
  207. package/dist/node/index.js +5 -0
  208. package/dist/node/index.js.map +1 -1
  209. package/dist/node/logger.js +10 -1
  210. package/dist/node/logger.js.map +1 -1
  211. package/dist/node/object-key-util.d.ts +2 -0
  212. package/dist/node/object-key-util.js +12 -0
  213. package/dist/node/object-key-util.js.map +1 -0
  214. package/dist/node/pages/page-sort-options.d.ts +14 -0
  215. package/dist/node/pages/page-sort-options.js +92 -0
  216. package/dist/node/pages/page-sort-options.js.map +1 -0
  217. package/dist/node/pages/page-sort-scoring.d.ts +35 -0
  218. package/dist/node/pages/page-sort-scoring.js +124 -0
  219. package/dist/node/pages/page-sort-scoring.js.map +1 -0
  220. package/dist/node/pages/pages-client-manager.d.ts +1 -0
  221. package/dist/node/pages/pages-client-manager.js +16 -4
  222. package/dist/node/pages/pages-client-manager.js.map +1 -1
  223. package/dist/node/pages/pkc-js-page-sorts/active.d.ts +3 -0
  224. package/dist/node/pages/pkc-js-page-sorts/active.js +15 -0
  225. package/dist/node/pages/pkc-js-page-sorts/active.js.map +1 -0
  226. package/dist/node/pages/pkc-js-page-sorts/best.d.ts +3 -0
  227. package/dist/node/pages/pkc-js-page-sorts/best.js +10 -0
  228. package/dist/node/pages/pkc-js-page-sorts/best.js.map +1 -0
  229. package/dist/node/pages/pkc-js-page-sorts/controversial.d.ts +3 -0
  230. package/dist/node/pages/pkc-js-page-sorts/controversial.js +10 -0
  231. package/dist/node/pages/pkc-js-page-sorts/controversial.js.map +1 -0
  232. package/dist/node/pages/pkc-js-page-sorts/hot.d.ts +3 -0
  233. package/dist/node/pages/pkc-js-page-sorts/hot.js +10 -0
  234. package/dist/node/pages/pkc-js-page-sorts/hot.js.map +1 -0
  235. package/dist/node/pages/pkc-js-page-sorts/index.d.ts +2 -0
  236. package/dist/node/pages/pkc-js-page-sorts/index.js +37 -0
  237. package/dist/node/pages/pkc-js-page-sorts/index.js.map +1 -0
  238. package/dist/node/pages/pkc-js-page-sorts/new-flat.d.ts +3 -0
  239. package/dist/node/pages/pkc-js-page-sorts/new-flat.js +12 -0
  240. package/dist/node/pages/pkc-js-page-sorts/new-flat.js.map +1 -0
  241. package/dist/node/pages/pkc-js-page-sorts/new.d.ts +3 -0
  242. package/dist/node/pages/pkc-js-page-sorts/new.js +10 -0
  243. package/dist/node/pages/pkc-js-page-sorts/new.js.map +1 -0
  244. package/dist/node/pages/pkc-js-page-sorts/old-flat.d.ts +3 -0
  245. package/dist/node/pages/pkc-js-page-sorts/old-flat.js +12 -0
  246. package/dist/node/pages/pkc-js-page-sorts/old-flat.js.map +1 -0
  247. package/dist/node/pages/pkc-js-page-sorts/old.d.ts +3 -0
  248. package/dist/node/pages/pkc-js-page-sorts/old.js +10 -0
  249. package/dist/node/pages/pkc-js-page-sorts/old.js.map +1 -0
  250. package/dist/node/pages/pkc-js-page-sorts/top-all.d.ts +3 -0
  251. package/dist/node/pages/pkc-js-page-sorts/top-all.js +10 -0
  252. package/dist/node/pages/pkc-js-page-sorts/top-all.js.map +1 -0
  253. package/dist/node/pages/pkc-js-page-sorts/top-day.d.ts +3 -0
  254. package/dist/node/pages/pkc-js-page-sorts/top-day.js +11 -0
  255. package/dist/node/pages/pkc-js-page-sorts/top-day.js.map +1 -0
  256. package/dist/node/pages/pkc-js-page-sorts/top-hour.d.ts +3 -0
  257. package/dist/node/pages/pkc-js-page-sorts/top-hour.js +11 -0
  258. package/dist/node/pages/pkc-js-page-sorts/top-hour.js.map +1 -0
  259. package/dist/node/pages/pkc-js-page-sorts/top-month.d.ts +3 -0
  260. package/dist/node/pages/pkc-js-page-sorts/top-month.js +11 -0
  261. package/dist/node/pages/pkc-js-page-sorts/top-month.js.map +1 -0
  262. package/dist/node/pages/pkc-js-page-sorts/top-week.d.ts +3 -0
  263. package/dist/node/pages/pkc-js-page-sorts/top-week.js +11 -0
  264. package/dist/node/pages/pkc-js-page-sorts/top-week.js.map +1 -0
  265. package/dist/node/pages/pkc-js-page-sorts/top-year.d.ts +3 -0
  266. package/dist/node/pages/pkc-js-page-sorts/top-year.js +11 -0
  267. package/dist/node/pages/pkc-js-page-sorts/top-year.js.map +1 -0
  268. package/dist/node/pages/pkc-js-page-sorts/top.d.ts +3 -0
  269. package/dist/node/pages/pkc-js-page-sorts/top.js +12 -0
  270. package/dist/node/pages/pkc-js-page-sorts/top.js.map +1 -0
  271. package/dist/node/pages/pkc-js-page-sorts/util.d.ts +3 -0
  272. package/dist/node/pages/pkc-js-page-sorts/util.js +7 -0
  273. package/dist/node/pages/pkc-js-page-sorts/util.js.map +1 -0
  274. package/dist/node/pages/schema.d.ts +1 -0
  275. package/dist/node/pages/schema.js +10 -3
  276. package/dist/node/pages/schema.js.map +1 -1
  277. package/dist/node/pages/types.d.ts +8 -0
  278. package/dist/node/pages/util.d.ts +2 -1
  279. package/dist/node/pages/util.js +11 -10
  280. package/dist/node/pages/util.js.map +1 -1
  281. package/dist/node/pkc/pkc.d.ts +2 -1
  282. package/dist/node/pkc/pkc.js +2 -1
  283. package/dist/node/pkc/pkc.js.map +1 -1
  284. package/dist/node/pkc-error.d.ts +1 -1
  285. package/dist/node/publications/comment/comment-client-manager.d.ts +2 -2
  286. package/dist/node/publications/comment/comment-client-manager.js +3 -1
  287. package/dist/node/publications/comment/comment-client-manager.js.map +1 -1
  288. package/dist/node/publications/community-edit/schema.d.ts +64 -0
  289. package/dist/node/pubsub-messages/schema.d.ts +32 -0
  290. package/dist/node/rpc/src/index.js +14 -1
  291. package/dist/node/rpc/src/index.js.map +1 -1
  292. package/dist/node/rpc/src/lib/pkc-js/index.d.ts +559 -0
  293. package/dist/node/rpc/src/schema.d.ts +3371 -0
  294. package/dist/node/rpc/src/schema.js +6 -2
  295. package/dist/node/rpc/src/schema.js.map +1 -1
  296. package/dist/node/runtime/browser/community/page-sorts/index.d.ts +1 -0
  297. package/dist/node/runtime/browser/community/page-sorts/index.js +6 -0
  298. package/dist/node/runtime/browser/community/page-sorts/index.js.map +1 -0
  299. package/dist/node/runtime/node/community/db-handler.d.ts +78 -20
  300. package/dist/node/runtime/node/community/db-handler.js +1151 -734
  301. package/dist/node/runtime/node/community/db-handler.js.map +1 -1
  302. package/dist/node/runtime/node/community/db-row-parser.d.ts +13 -0
  303. package/dist/node/runtime/node/community/db-row-parser.js +58 -1
  304. package/dist/node/runtime/node/community/db-row-parser.js.map +1 -1
  305. package/dist/node/runtime/node/community/local-community/cleanup.js +23 -10
  306. package/dist/node/runtime/node/community/local-community/cleanup.js.map +1 -1
  307. package/dist/node/runtime/node/community/local-community/comment-updates.d.ts +12 -2
  308. package/dist/node/runtime/node/community/local-community/comment-updates.js +101 -70
  309. package/dist/node/runtime/node/community/local-community/comment-updates.js.map +1 -1
  310. package/dist/node/runtime/node/community/local-community/db-state.js +8 -0
  311. package/dist/node/runtime/node/community/local-community/db-state.js.map +1 -1
  312. package/dist/node/runtime/node/community/local-community/editing.d.ts +9 -2
  313. package/dist/node/runtime/node/community/local-community/editing.js +32 -5
  314. package/dist/node/runtime/node/community/local-community/editing.js.map +1 -1
  315. package/dist/node/runtime/node/community/local-community/ipns-publishing.d.ts +2 -2
  316. package/dist/node/runtime/node/community/local-community/ipns-publishing.js +49 -27
  317. package/dist/node/runtime/node/community/local-community/ipns-publishing.js.map +1 -1
  318. package/dist/node/runtime/node/community/local-community/lifecycle.d.ts +1 -0
  319. package/dist/node/runtime/node/community/local-community/lifecycle.js +49 -18
  320. package/dist/node/runtime/node/community/local-community/lifecycle.js.map +1 -1
  321. package/dist/node/runtime/node/community/local-community.d.ts +5 -1
  322. package/dist/node/runtime/node/community/local-community.js +13 -1
  323. package/dist/node/runtime/node/community/local-community.js.map +1 -1
  324. package/dist/node/runtime/node/community/page-generator.d.ts +44 -413
  325. package/dist/node/runtime/node/community/page-generator.js +531 -308
  326. package/dist/node/runtime/node/community/page-generator.js.map +1 -1
  327. package/dist/node/runtime/node/community/page-sorts/index.d.ts +64 -0
  328. package/dist/node/runtime/node/community/page-sorts/index.js +264 -0
  329. package/dist/node/runtime/node/community/page-sorts/index.js.map +1 -0
  330. package/dist/node/runtime/node/util.js +1 -1
  331. package/dist/node/runtime/node/util.js.map +1 -1
  332. package/dist/node/schema/schema-util.d.ts +142 -1
  333. package/dist/node/schema/schema-util.js +11 -0
  334. package/dist/node/schema/schema-util.js.map +1 -1
  335. package/dist/node/schema/schema.js +1 -1
  336. package/dist/node/schema/schema.js.map +1 -1
  337. package/dist/node/schema.d.ts +4469 -0
  338. package/dist/node/schema.js +2 -1
  339. package/dist/node/schema.js.map +1 -1
  340. package/dist/node/signer/signatures.d.ts +1 -1
  341. package/dist/node/signer/signatures.js +45 -1
  342. package/dist/node/signer/signatures.js.map +1 -1
  343. package/dist/node/test/test-util.d.ts +63 -0
  344. package/dist/node/test/test-util.js +66 -11
  345. package/dist/node/test/test-util.js.map +1 -1
  346. package/dist/node/util.d.ts +4 -0
  347. package/dist/node/util.js +87 -0
  348. package/dist/node/util.js.map +1 -1
  349. package/dist/node/version.js +1 -1
  350. package/package.json +2 -2
  351. package/dist/bundled/chunks/{src-Dz9wtvrK.js → src-B07ldGHS.js} +1 -1
@@ -1,4 +1,4 @@
1
- import { getEquivalentCommunityAddresses, hideClassPrivateProps, isStringDomain, removeNullUndefinedValues, timestamp } from "../../../util.js";
1
+ import { getEquivalentCommunityAddresses, hideClassPrivateProps, isStringDomain, removeNullUndefinedValues, timestamp, withSortedKeys } from "../../../util.js";
2
2
  import { PKCError } from "../../../pkc-error.js";
3
3
  import path from "path";
4
4
  import assert from "assert";
@@ -22,10 +22,13 @@ import KeyvBetterSqlite3 from "./keyv-better-sqlite3.js";
22
22
  import { STORAGE_KEYS } from "../../../constants.js";
23
23
  import { CommentEditPubsubMessagePublicationSchema, CommentEditPubsubMessagePublicationWithFlexibleAuthorSchema } from "../../../publications/comment-edit/schema.js";
24
24
  import { TIMEFRAMES_TO_SECONDS } from "../../../pages/util.js";
25
- import { parseCommentEditsRow, parseCommentUpdateRow, parseCommentsTableRow, parsePrefixedComment, parseVoteRow, parseCommentModerationRow } from "./db-row-parser.js";
25
+ import { parseCommentEditsRow, parseCommentUpdateRow, parseCommentsTableRow, parseVoteRow, parseCommentModerationRow, createPositionalCommentRowMapper } from "./db-row-parser.js";
26
26
  import { ZodError } from "zod";
27
27
  import { messages } from "../../../errors.js";
28
28
  import { getAuthorNameFromWire } from "../../../publications/publication-author.js";
29
+ export const createCommunityAuthorMemo = () => ({ writeGeneration: -1, authors: new Map() });
30
+ // Per-connection temp table holding the rowids of the comments the update cycle has to recalculate (issue #355)
31
+ const COMMENTS_TO_UPDATE_TEMP_TABLE = "temp.commentsToUpdate";
29
32
  const TABLES = Object.freeze({
30
33
  COMMENTS: "comments",
31
34
  COMMENT_UPDATES: "commentUpdates",
@@ -34,23 +37,38 @@ const TABLES = Object.freeze({
34
37
  COMMENT_EDITS: "commentEdits",
35
38
  PSEUDONYMITY_ALIASES: "pseudonymityAliases"
36
39
  });
40
+ // The address an alias row's original public key derives to, or null when the key is malformed (kept, never looked up)
41
+ function deriveAliasOriginalAuthorSignerAddress(originalAuthorPublicKey) {
42
+ try {
43
+ return getPKCAddressFromPublicKeySync(originalAuthorPublicKey);
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
37
49
  export class DbHandler {
38
50
  constructor(community) {
51
+ // Advanced by every write to the publication tables (comments, votes, edits, moderations, aliases, purges). The
52
+ // update cycle's author memo (CommunityAuthorMemo) is only reused while it stays the same.
53
+ this._publicationWriteGeneration = 0;
39
54
  this._community = community;
40
55
  this._transactionDepth = 0;
41
56
  this._createdTables = false;
42
57
  this._columnNamesByTable = {};
58
+ this._positionalMappers = new Map();
59
+ this._preparedStatements = new Map();
43
60
  hideClassPrivateProps(this);
44
61
  }
45
- _parsePrefixedComment(row) {
46
- const parsed = parsePrefixedComment(row);
47
- const comment = removeNullUndefinedValues(this._spreadExtraProps(parsed.comment));
48
- const commentUpdate = removeNullUndefinedValues(this._spreadExtraProps(parsed.commentUpdate));
49
- return {
50
- comment,
51
- commentUpdate,
52
- extras: parsed.extras
53
- };
62
+ // Statements the update cycle runs per comment, compiled once per connection: preparing the SQL again on every
63
+ // call cost more than running some of them (issue #351). A cached statement keeps the mode (raw, pluck) its call
64
+ // site sets, so one SQL string belongs to one call site.
65
+ _prepareCached(sql) {
66
+ let statement = this._preparedStatements.get(sql);
67
+ if (!statement) {
68
+ statement = this._db.prepare(sql);
69
+ this._preparedStatements.set(sql, statement);
70
+ }
71
+ return statement;
54
72
  }
55
73
  _parseCommentsTableRow(row) {
56
74
  const parsed = parseCommentsTableRow(row);
@@ -92,6 +110,7 @@ export class DbHandler {
92
110
  const dbFilePath = this._dbConfig.filename;
93
111
  if (!this._db || !this._db.open) {
94
112
  this._columnNamesByTable = {}; // a reopened file may have been migrated by another process
113
+ this._preparedStatements.clear();
95
114
  this._db = new Database(dbFilePath, { ...this._dbConfig, ...dbConfigOptions });
96
115
  if (!this._db.readonly) {
97
116
  this._db.pragma("journal_mode = WAL");
@@ -145,6 +164,7 @@ export class DbHandler {
145
164
  }
146
165
  destoryConnection() {
147
166
  const log = Logger("pkc-js:local-community:dbHandler:destroyConnection");
167
+ this._preparedStatements.clear(); // statements belong to the connection
148
168
  if (this._db && this._db.open) {
149
169
  if (!this._db.readonly) {
150
170
  this._db.exec("PRAGMA wal_checkpoint"); // write all wal to disk
@@ -272,7 +292,7 @@ export class DbHandler {
272
292
  protocolVersion TEXT NOT NULL,
273
293
  signature TEXT NOT NULL, -- JSON
274
294
  author TEXT NULLABLE, -- JSON
275
- replies TEXT NULLABLE, -- JSON
295
+ replies TEXT NULLABLE, -- JSON: CID refs per sort (DbRepliesSchema)
276
296
  lastChildCid TEXT NULLABLE,
277
297
  lastReplyTimestamp INTEGER NULLABLE,
278
298
  postUpdatesBucket INTEGER NULLABLE,
@@ -281,6 +301,12 @@ export class DbHandler {
281
301
  )
282
302
  `);
283
303
  }
304
+ // The comment tree lookups every page and update cycle runs: children of a comment (reply pages, stale_replies),
305
+ // every reply under a post (requireReplies sorts). Without them each is a full scan of the comments table.
306
+ _createCommentsIndexes() {
307
+ this._db.exec(`CREATE INDEX IF NOT EXISTS idx_comments_parentCid ON ${TABLES.COMMENTS}(parentCid)`);
308
+ this._db.exec(`CREATE INDEX IF NOT EXISTS idx_comments_postCid ON ${TABLES.COMMENTS}(postCid)`);
309
+ }
284
310
  _createVotesTable(tableName) {
285
311
  this._db.exec(`
286
312
  CREATE TABLE IF NOT EXISTS ${tableName} (
@@ -343,12 +369,22 @@ export class DbHandler {
343
369
  commentCid TEXT NOT NULL PRIMARY KEY UNIQUE REFERENCES ${TABLES.COMMENTS}(cid) ON DELETE CASCADE,
344
370
  aliasPrivateKey TEXT NOT NULL,
345
371
  originalAuthorPublicKey TEXT NOT NULL,
372
+ originalAuthorSignerAddress TEXT NULLABLE, -- derived from originalAuthorPublicKey at insert, so an author's aliases are an indexed lookup (issue #351)
346
373
  originalAuthorName TEXT NULLABLE, -- the original author's name (e.g., user.eth) if they used one
347
374
  mode TEXT NOT NULL CHECK(mode IN ('per-post', 'per-reply', 'per-author')),
348
375
  insertedAt INTEGER NOT NULL
349
376
  )
350
377
  `);
351
378
  }
379
+ // The lookups the update cycle runs per comment: an author's comments (author.community karma), an author's
380
+ // aliases, and a comment's edits and moderations (flags, reason, flairs, approval, the author's latest edit).
381
+ // Without them each is a full scan of its table (issue #351).
382
+ _createAuthorIndexes() {
383
+ this._db.exec(`CREATE INDEX IF NOT EXISTS idx_comments_authorSignerAddress ON ${TABLES.COMMENTS}(authorSignerAddress)`);
384
+ this._db.exec(`CREATE INDEX IF NOT EXISTS idx_pseudonymityAliases_originalAuthorSignerAddress ON ${TABLES.PSEUDONYMITY_ALIASES}(originalAuthorSignerAddress)`);
385
+ this._db.exec(`CREATE INDEX IF NOT EXISTS idx_commentEdits_commentCid ON ${TABLES.COMMENT_EDITS}(commentCid)`);
386
+ this._db.exec(`CREATE INDEX IF NOT EXISTS idx_commentModerations_commentCid ON ${TABLES.COMMENT_MODERATIONS}(commentCid)`);
387
+ }
352
388
  getDbVersion() {
353
389
  const result = this._db.pragma("user_version", { simple: true });
354
390
  return Number(result);
@@ -415,6 +451,7 @@ export class DbHandler {
415
451
  this._db.exec(`DROP TABLE IF EXISTS ${TABLES.COMMENT_UPDATES}`);
416
452
  }
417
453
  this._columnNamesByTable = {}; // the loop below rewrites every table's schema
454
+ this._preparedStatements.clear();
418
455
  const createTableFunctions = [
419
456
  this._createCommentsTable.bind(this),
420
457
  this._createCommentUpdatesTable.bind(this),
@@ -441,6 +478,8 @@ export class DbHandler {
441
478
  this._db.exec(`ALTER TABLE ${tempTableName} RENAME TO ${tableName}`);
442
479
  }
443
480
  }
481
+ this._createCommentsIndexes(); // idempotent; the migration loop above recreates the tables without them
482
+ this._createAuthorIndexes();
444
483
  if (needToMigrate) {
445
484
  await this._purgeCommentsWithInvalidSchemaOrSignature();
446
485
  await this._purgeCommentEditsWithInvalidSchemaOrSignature();
@@ -488,6 +527,7 @@ export class DbHandler {
488
527
  assert.equal(newDbVersion, env.DB_VERSION);
489
528
  this._createdTables = true;
490
529
  this._columnNamesByTable = {}; // every table now carries the latest schema
530
+ this._preparedStatements.clear();
491
531
  if (needToMigrate)
492
532
  log(`Created/migrated the tables to the latest (${newDbVersion}) version and saved to path`, this._dbConfig.filename);
493
533
  if (backupDbPath)
@@ -679,6 +719,12 @@ export class DbHandler {
679
719
  delete srcRecord["originalAuthorDomain"];
680
720
  }
681
721
  }
722
+ // The alias reverse-lookup column (v42 → v43, issue #351). After the v39 rename above, so a pre-v39 row's key
723
+ // is already under originalAuthorPublicKey when it is derived.
724
+ if (currentDbVersion < 43 &&
725
+ srcTable === TABLES.PSEUDONYMITY_ALIASES &&
726
+ typeof srcRecord["originalAuthorPublicKey"] === "string")
727
+ srcRecord["originalAuthorSignerAddress"] = deriveAliasOriginalAuthorSignerAddress(srcRecord["originalAuthorPublicKey"]);
682
728
  // Prepare record for insertion (stringify JSONs, convert booleans)
683
729
  const processedRecord = this._processRecordsForDbBeforeInsert([srcRecord])[0];
684
730
  // Map values including rowid (preserve the original rowid value)
@@ -849,15 +895,18 @@ export class DbHandler {
849
895
  }
850
896
  }
851
897
  deleteVote(authorSignerAddress, commentCid) {
898
+ this._publicationWriteGeneration++;
852
899
  this._db
853
900
  .prepare(`DELETE FROM ${TABLES.VOTES} WHERE commentCid = ? AND authorSignerAddress = ?`)
854
901
  .run(commentCid, authorSignerAddress);
855
902
  }
856
903
  _deleteCommentEditRow(rowid) {
904
+ this._publicationWriteGeneration++;
857
905
  const deleteResult = this._db.prepare(`DELETE FROM ${TABLES.COMMENT_EDITS} WHERE rowid = ?`).run(rowid);
858
906
  return deleteResult.changes > 0;
859
907
  }
860
908
  insertVotes(votes) {
909
+ this._publicationWriteGeneration++;
861
910
  if (votes.length === 0)
862
911
  return;
863
912
  const processedVotes = this._processRecordsForDbBeforeInsert(votes);
@@ -885,6 +934,7 @@ export class DbHandler {
885
934
  insertMany(processedVotes);
886
935
  }
887
936
  insertComments(comments) {
937
+ this._publicationWriteGeneration++;
888
938
  if (comments.length === 0)
889
939
  return;
890
940
  const processedComments = this._processRecordsForDbBeforeInsert(comments);
@@ -909,13 +959,17 @@ export class DbHandler {
909
959
  insertMany(processedComments);
910
960
  }
911
961
  insertPseudonymityAliases(aliases) {
962
+ this._publicationWriteGeneration++;
912
963
  if (aliases.length === 0)
913
964
  return;
914
- const processedAliases = this._processRecordsForDbBeforeInsert(aliases);
965
+ const processedAliases = this._processRecordsForDbBeforeInsert(aliases).map((alias) => ({
966
+ ...alias,
967
+ originalAuthorSignerAddress: deriveAliasOriginalAuthorSignerAddress(alias.originalAuthorPublicKey)
968
+ }));
915
969
  const stmt = this._db.prepare(`
916
970
  INSERT OR REPLACE INTO ${TABLES.PSEUDONYMITY_ALIASES}
917
- (commentCid, aliasPrivateKey, originalAuthorPublicKey, originalAuthorName, mode, insertedAt)
918
- VALUES (@commentCid, @aliasPrivateKey, @originalAuthorPublicKey, @originalAuthorName, @mode, @insertedAt)
971
+ (commentCid, aliasPrivateKey, originalAuthorPublicKey, originalAuthorSignerAddress, originalAuthorName, mode, insertedAt)
972
+ VALUES (@commentCid, @aliasPrivateKey, @originalAuthorPublicKey, @originalAuthorSignerAddress, @originalAuthorName, @mode, @insertedAt)
919
973
  `);
920
974
  const insertMany = this._db.transaction((items) => {
921
975
  for (const alias of items)
@@ -927,7 +981,7 @@ export class DbHandler {
927
981
  const processedUpdates = this._processRecordsForDbBeforeInsert(updates);
928
982
  // Get all column names from the comment_updates table to create defaults
929
983
  const columnNames = this._getColumnNames(TABLES.COMMENT_UPDATES);
930
- const stmt = this._db.prepare(`
984
+ const stmt = this._prepareCached(`
931
985
  INSERT INTO ${TABLES.COMMENT_UPDATES}
932
986
  (cid, edit, upvoteCount, downvoteCount, replyCount, childCount, number, postNumber, flairs, spoiler, nsfw, pinned, locked, archived, removed, approved, reason, updatedAt, protocolVersion, signature, author, replies, lastChildCid, lastReplyTimestamp, postUpdatesBucket, publishedToPostUpdatesMFS, insertedAt)
933
987
  VALUES (@cid, @edit, @upvoteCount, @downvoteCount, @replyCount, @childCount, @number, @postNumber, @flairs, @spoiler, @nsfw, @pinned, @locked, @archived, @removed, @approved, @reason, @updatedAt, @protocolVersion, @signature, @author, @replies, @lastChildCid, @lastReplyTimestamp, @postUpdatesBucket, @publishedToPostUpdatesMFS, @insertedAt)
@@ -954,6 +1008,7 @@ export class DbHandler {
954
1008
  upsertMany(processedUpdates);
955
1009
  }
956
1010
  insertCommentModerations(moderations) {
1011
+ this._publicationWriteGeneration++;
957
1012
  if (moderations.length === 0)
958
1013
  return;
959
1014
  const processedModerations = this._processRecordsForDbBeforeInsert(moderations);
@@ -976,6 +1031,7 @@ export class DbHandler {
976
1031
  insertMany(processedModerations);
977
1032
  }
978
1033
  insertCommentEdits(edits) {
1034
+ this._publicationWriteGeneration++;
979
1035
  if (edits.length === 0)
980
1036
  return;
981
1037
  const processedEdits = this._processRecordsForDbBeforeInsert(edits);
@@ -1083,346 +1139,255 @@ export class DbHandler {
1083
1139
  whereClauses.push(this._approvedClause(commentUpdatesTable));
1084
1140
  return { whereClauses, params };
1085
1141
  }
1086
- queryMaximumTimestampUnderComment(comment) {
1087
- const { clause: addrClause, params: addrParams } = this._communityAddressClause("c");
1088
- const query = `
1089
- WITH RECURSIVE descendants AS (
1090
- SELECT c.cid, c.timestamp FROM ${TABLES.COMMENTS} c
1091
- LEFT JOIN ${TABLES.COMMENT_UPDATES} cu ON cu.cid = c.cid
1092
- WHERE c.parentCid = ?
1093
- AND COALESCE(cu.approved, 1) != 0
1094
- AND (c.pendingApproval IS NULL OR c.pendingApproval != 1)
1095
- UNION ALL
1096
- SELECT c.cid, c.timestamp FROM ${TABLES.COMMENTS} c
1097
- INNER JOIN ${TABLES.COMMENT_UPDATES} cu ON c.cid = cu.cid
1098
- LEFT JOIN (SELECT cid, json_extract(edit, '$.deleted') AS deleted_flag FROM ${TABLES.COMMENT_UPDATES}) AS d ON c.cid = d.cid
1099
- JOIN descendants desc_nodes ON c.parentCid = desc_nodes.cid
1100
- WHERE ${addrClause} AND (cu.removed IS NOT 1 AND cu.removed IS NOT TRUE) AND (d.deleted_flag IS NULL OR d.deleted_flag != 1)
1101
- AND COALESCE(cu.approved, 1) != 0
1102
- AND (c.pendingApproval IS NULL OR c.pendingApproval != 1)
1103
- )
1104
- SELECT MAX(timestamp) AS max_timestamp FROM descendants
1105
- `;
1106
- const result = this._db.prepare(query).get(comment.cid, ...addrParams);
1107
- if (result.max_timestamp === null)
1108
- return undefined;
1109
- return result.max_timestamp;
1142
+ // The column set of a page entry: every CommentIpfs column plus extraProps, and every CommentUpdate column
1143
+ // unless the caller excludes some (a flat page drops `replies`). Compiled mappers are cached per column set.
1144
+ _pageEntryMapper(commentUpdateFieldsToExclude, existingOnly = false) {
1145
+ const commentUpdateCols = keys(commentUpdateFieldsToExclude ? omit(CommentUpdateSchema.shape, commentUpdateFieldsToExclude) : CommentUpdateSchema.shape);
1146
+ const commentIpfsCols = [...keys(CommentIpfsSchema.shape), "extraProps"];
1147
+ return this._positionalMapperFor({
1148
+ commentIpfsCols: existingOnly ? this._existingColumns(TABLES.COMMENTS, commentIpfsCols) : commentIpfsCols,
1149
+ commentUpdateCols: existingOnly ? this._existingColumns(TABLES.COMMENT_UPDATES, commentUpdateCols) : commentUpdateCols
1150
+ });
1151
+ }
1152
+ _positionalMapperFor(cols) {
1153
+ const key = `${cols.commentIpfsCols.join(",")}|${cols.commentUpdateCols.join(",")}`;
1154
+ let mapper = this._positionalMappers.get(key);
1155
+ if (!mapper) {
1156
+ mapper = createPositionalCommentRowMapper(cols);
1157
+ this._positionalMappers.set(key, mapper);
1158
+ }
1159
+ return mapper;
1110
1160
  }
1111
1161
  queryPageComments(options) {
1112
- const commentUpdateCols = keys(options.commentUpdateFieldsToExclude
1113
- ? omit(CommentUpdateSchema.shape, options.commentUpdateFieldsToExclude)
1114
- : CommentUpdateSchema.shape);
1115
- const commentUpdateSelects = commentUpdateCols.map((col) => `${TABLES.COMMENT_UPDATES}.${col} AS commentUpdate_${col}`);
1116
- const commentIpfsCols = [...keys(CommentIpfsSchema.shape), "extraProps"];
1117
- const commentIpfsSelects = commentIpfsCols.map((col) => `${TABLES.COMMENTS}.${col} AS commentIpfs_${col}`);
1162
+ const mapper = this._pageEntryMapper(options.commentUpdateFieldsToExclude);
1118
1163
  const { whereClauses, params } = this._buildPageQueryParts(options);
1119
1164
  const queryStr = `
1120
- SELECT ${commentIpfsSelects.join(", ")}, ${commentUpdateSelects.join(", ")}
1165
+ SELECT ${mapper.selectList((col) => `${TABLES.COMMENTS}.${col}`, (col) => `${TABLES.COMMENT_UPDATES}.${col}`)}
1121
1166
  FROM ${TABLES.COMMENTS} INNER JOIN ${TABLES.COMMENT_UPDATES} ON ${TABLES.COMMENTS}.cid = ${TABLES.COMMENT_UPDATES}.cid
1122
1167
  WHERE ${whereClauses.join(" AND ")}
1123
1168
  `;
1124
- const commentsRaw = this._db.prepare(queryStr).all(...params);
1125
- return commentsRaw.map((commentRaw) => {
1126
- const { comment, commentUpdate } = this._parsePrefixedComment(commentRaw);
1127
- return { comment, commentUpdate };
1128
- });
1129
- }
1130
- queryPageCommentsWithResolvedReplies(options) {
1131
- // Fetch ALL descendants by following CID-ref lists stored in the DB `replies` column.
1132
- // Base case: direct children of parentCid.
1133
- // Recursive case: follow $.best.commentCids from each node's replies JSON.
1134
- // Returns flat rows with tree_parent for JS tree assembly.
1135
- const commentUpdateCols = keys(CommentUpdateSchema.shape);
1136
- const commentIpfsCols = [...keys(CommentIpfsSchema.shape), "extraProps"];
1137
- // Base case uses full table names (not aliases) to match _buildPageQueryParts WHERE clauses
1138
- const baseCommentUpdateSelects = commentUpdateCols.map((col) => `${TABLES.COMMENT_UPDATES}.${col} AS commentUpdate_${col}`);
1139
- const baseCommentIpfsSelects = commentIpfsCols.map((col) => `${TABLES.COMMENTS}.${col} AS commentIpfs_${col}`);
1140
- // Recursive part uses aliases since it joins fresh tables
1141
- const recCommentUpdateSelects = commentUpdateCols.map((col) => `cu2.${col} AS commentUpdate_${col}`);
1142
- const recCommentIpfsSelects = commentIpfsCols.map((col) => `c2.${col} AS commentIpfs_${col}`);
1143
- const { whereClauses, params } = this._buildPageQueryParts(options);
1144
- // Build recursive filter clauses (same pattern as queryFlattenedPageReplies)
1145
- const recursiveFilterClauses = [];
1146
- if (options.excludeCommentsWithDifferentCommunityAddress) {
1147
- const { clause, params: addrParams } = this._communityAddressClause("c2");
1148
- recursiveFilterClauses.push(clause);
1149
- params.push(...addrParams);
1150
- }
1151
- if (options.excludeCommentPendingApproval)
1152
- recursiveFilterClauses.push(this._pendingApprovalClause("c2"));
1153
- if (options.excludeRemovedComments)
1154
- recursiveFilterClauses.push(this._removedClause("cu2"));
1155
- if (options.excludeDeletedComments)
1156
- recursiveFilterClauses.push(this._deletedFromLookupClause("d2"));
1157
- if (options.excludeCommentWithApprovedFalse)
1158
- recursiveFilterClauses.push(this._approvedClause("cu2"));
1159
- const recursiveWhereExtra = recursiveFilterClauses.length > 0 ? `AND ${recursiveFilterClauses.join(" AND ")}` : "";
1160
- const deletedLookupJoin = options.excludeDeletedComments
1161
- ? `LEFT JOIN (SELECT cid, json_extract(edit, '$.deleted') AS deleted_flag FROM ${TABLES.COMMENT_UPDATES}) AS d2 ON c2.cid = d2.cid`
1162
- : "";
1163
- const queryStr = `
1164
- WITH RECURSIVE reply_tree AS (
1165
- -- Base: direct children of the target comment
1166
- SELECT ${baseCommentIpfsSelects.join(", ")}, ${baseCommentUpdateSelects.join(", ")},
1167
- ${TABLES.COMMENTS}.parentCid AS tree_parent
1168
- FROM ${TABLES.COMMENTS}
1169
- INNER JOIN ${TABLES.COMMENT_UPDATES} ON ${TABLES.COMMENTS}.cid = ${TABLES.COMMENT_UPDATES}.cid
1170
- WHERE ${whereClauses.join(" AND ")}
1171
-
1172
- UNION ALL
1173
-
1174
- -- Recursive: follow commentCids from each parent's replies
1175
- SELECT ${recCommentIpfsSelects.join(", ")}, ${recCommentUpdateSelects.join(", ")},
1176
- rt.commentUpdate_cid AS tree_parent
1177
- FROM reply_tree rt
1178
- CROSS JOIN json_each(
1179
- json_extract(rt.commentUpdate_replies, '$.best.commentCids')
1180
- ) child_ref
1181
- JOIN ${TABLES.COMMENTS} c2 ON c2.cid = child_ref.value
1182
- JOIN ${TABLES.COMMENT_UPDATES} cu2 ON c2.cid = cu2.cid
1183
- ${deletedLookupJoin}
1184
- WHERE rt.commentUpdate_replies IS NOT NULL
1185
- AND json_type(rt.commentUpdate_replies, '$.best.commentCids') = 'array'
1186
- ${recursiveWhereExtra}
1187
- )
1188
- SELECT * FROM reply_tree
1189
- `;
1190
- const rowsRaw = this._db.prepare(queryStr).all(...params);
1191
- // Group by tree_parent to reconstruct the hierarchy
1169
+ const rows = this._prepareCached(queryStr)
1170
+ .raw(true)
1171
+ .all(...params);
1172
+ return rows.map(mapper.map);
1173
+ }
1174
+ // Nested reply trees out of page entries and the parent each was reached through (its own parentCid, whether
1175
+ // it came from a flat subtree read or from the level walk).
1176
+ // A child listed under two preloaded sorts of the same parent comes back twice and is kept once. `attachReplies`
1177
+ // rebuilds one page per preloaded sort of a parent, in that sort's commentCids order (the CTE's row order is not
1178
+ // the json_each order once the JOINs are involved), and `buildWireCommentUpdate` swaps the DB-format `replies`
1179
+ // for the resolved pages, or for `pageCids` alone when nothing is embedded but pages exist.
1180
+ _assembleReplyTrees(items) {
1192
1181
  const parsedByCid = new Map();
1193
1182
  const childrenByParent = new Map();
1194
- for (const row of rowsRaw) {
1195
- const { comment, commentUpdate } = this._parsePrefixedComment(row);
1196
- parsedByCid.set(commentUpdate.cid, { comment, commentUpdate });
1197
- const parent = row.tree_parent;
1198
- if (!childrenByParent.has(parent))
1199
- childrenByParent.set(parent, []);
1200
- childrenByParent.get(parent).push({ comment, commentUpdate });
1201
- }
1202
- // Build a commentUpdate for wire format: strip DB replies, only add resolved replies if present
1183
+ for (const { entry, parent } of items) {
1184
+ const cid = entry.commentUpdate.cid;
1185
+ if (parsedByCid.has(cid))
1186
+ continue;
1187
+ parsedByCid.set(cid, entry);
1188
+ let siblings = childrenByParent.get(parent);
1189
+ if (!siblings)
1190
+ childrenByParent.set(parent, (siblings = new Map()));
1191
+ siblings.set(cid, entry);
1192
+ }
1193
+ // Keys stay sorted (see createPositionalCommentRowMapper): `replies` is re-inserted in order, and
1194
+ // `pageCids` sorts before `pages`
1203
1195
  const buildWireCommentUpdate = (commentUpdate, resolvedReplies) => {
1204
1196
  const { replies: dbReplies, ...rest } = commentUpdate;
1205
1197
  if (resolvedReplies)
1206
- return { ...rest, replies: resolvedReplies };
1207
- // If no resolved inline replies but DB has allPageCids, reconstruct pageCids so clients can fetch pages
1198
+ return withSortedKeys({ ...rest, replies: resolvedReplies });
1208
1199
  if (dbReplies) {
1209
- const dbEntries = dbReplies;
1210
1200
  const pageCids = {};
1211
- for (const [sortName, sortEntry] of Object.entries(dbEntries)) {
1201
+ for (const sortName of Object.keys(dbReplies).sort()) {
1202
+ const sortEntry = dbReplies[sortName];
1212
1203
  if (sortEntry?.allPageCids?.[0])
1213
1204
  pageCids[sortName] = sortEntry.allPageCids[0];
1214
1205
  }
1215
1206
  if (Object.keys(pageCids).length > 0)
1216
- return { ...rest, replies: { pages: {}, pageCids } };
1207
+ return withSortedKeys({ ...rest, replies: { pageCids, pages: {} } });
1217
1208
  }
1218
1209
  return rest;
1219
1210
  };
1220
- // Recursively attach nested replies
1221
1211
  const attachReplies = (cid) => {
1222
1212
  const children = childrenByParent.get(cid);
1223
- if (!children?.length)
1213
+ if (!children?.size)
1224
1214
  return undefined;
1225
- // Sort children by the parent's commentCids order.
1226
- // SQLite's recursive CTE does not guarantee row order matches json_each array order
1227
- // when additional JOINs are involved, so we must explicitly reorder.
1228
- const parent = parsedByCid.get(cid);
1229
- const parentDbReplies = parent?.commentUpdate?.replies;
1230
- const cidOrder = parentDbReplies?.best?.commentCids;
1231
- const orderedChildren = cidOrder
1232
- ? cidOrder.map((childCid) => children.find((c) => c.commentUpdate.cid === childCid)).filter(Boolean)
1233
- : children;
1234
- return {
1235
- pages: {
1236
- best: {
1237
- comments: orderedChildren.map((child) => ({
1215
+ const parentDbReplies = parsedByCid.get(cid)?.commentUpdate.replies;
1216
+ const pages = {};
1217
+ for (const sortName of Object.keys(parentDbReplies ?? {}).sort()) {
1218
+ const sortEntry = parentDbReplies[sortName];
1219
+ if (!sortEntry?.commentCids)
1220
+ continue;
1221
+ const comments = [];
1222
+ for (const childCid of sortEntry.commentCids) {
1223
+ const child = children.get(childCid);
1224
+ if (child)
1225
+ comments.push({
1238
1226
  comment: child.comment,
1239
- commentUpdate: buildWireCommentUpdate(child.commentUpdate, attachReplies(child.commentUpdate.cid))
1240
- }))
1241
- }
1227
+ commentUpdate: buildWireCommentUpdate(child.commentUpdate, attachReplies(childCid))
1228
+ });
1242
1229
  }
1243
- };
1230
+ pages[sortName] = { comments };
1231
+ }
1232
+ if (Object.keys(pages).length === 0)
1233
+ return undefined;
1234
+ return { pages };
1244
1235
  };
1245
- // Direct children with nested replies attached
1246
- const directChildren = childrenByParent.get(options.parentCid) ?? [];
1247
- return directChildren.map((child) => ({
1248
- comment: child.comment,
1249
- commentUpdate: buildWireCommentUpdate(child.commentUpdate, attachReplies(child.commentUpdate.cid))
1250
- }));
1236
+ return { parsedByCid, childrenByParent, buildWireCommentUpdate, attachReplies };
1251
1237
  }
1238
+ // The page entries under one comment: its direct children that pass the page's exclusions, each carrying the
1239
+ // reply pages its own CommentUpdate was signed with (resolveRepliesCidRefsForEntries). The exclusions apply to
1240
+ // the children only: what a child embeds was fixed when the child was signed, and a change below it re-flags the
1241
+ // child before its parent (stale_replies), so re-filtering the nested tree here could only disagree with the
1242
+ // child's signature. The recursive CTE this replaced cost a scan of the board per call (issue #351).
1243
+ queryPageCommentsWithResolvedReplies(options) {
1244
+ return this.resolveRepliesCidRefsForEntries(this.queryPageComments(options));
1245
+ }
1246
+ // Whether a loaded entry's CommentUpdate has replies to embed: its DB-format `replies` names a preloaded page or
1247
+ // a page CID (deriveDbReplies never stores an empty object)
1248
+ _entryHasDbReplies(entry) {
1249
+ const replies = entry.commentUpdate.replies;
1250
+ if (!replies)
1251
+ return false;
1252
+ return Object.values(replies).some((sortEntry) => sortEntry?.commentCids || sortEntry?.allPageCids);
1253
+ }
1254
+ // Run one statement per batch of cids, under the SQLite variable cap, collecting every row
1255
+ _forEachCidBatch(cids, queryFor, onRow, raw = false) {
1256
+ for (const { placeholders, params } of this._cidChunks(cids)) {
1257
+ const statement = this._prepareCached(queryFor(placeholders));
1258
+ if (raw)
1259
+ statement.raw(true);
1260
+ for (const row of statement.all(...params))
1261
+ onRow(row);
1262
+ }
1263
+ }
1264
+ // Every reply under the given posts (depth > 0, by postCid, indexed), as page entries with their DB-format reply
1265
+ // refs, in one flat read per batch of posts: what the page generator streams through, one batch of posts at a
1266
+ // time, to build those posts' nested reply pages (issue #351). Reads a post's whole subtree, listed or not; the
1267
+ // assembly keeps what the CID refs list.
1268
+ queryRepliesUnderPosts(postCids) {
1269
+ const mapper = this._pageEntryMapper(undefined, true);
1270
+ const entries = [];
1271
+ this._forEachCidBatch(postCids, (placeholders) => `
1272
+ SELECT ${mapper.selectList((col) => `c.${col}`, (col) => `cu.${col}`)}
1273
+ FROM ${TABLES.COMMENTS} c
1274
+ INNER JOIN ${TABLES.COMMENT_UPDATES} cu ON c.cid = cu.cid
1275
+ WHERE c.depth > 0 AND c.postCid IN (${placeholders})
1276
+ `, (row) => entries.push(mapper.map(row)), true);
1277
+ return entries;
1278
+ }
1279
+ // Resolve each entry's CID-ref replies into the wire form its CommentUpdate was signed with: one page per preloaded
1280
+ // sort in that sort's commentCids order, nested recursively, `pageCids` from allPageCids. Entries without replies
1281
+ // come back untouched. Posts are resolved from one flat read of their subtrees (queryRepliesUnderPosts); a set
1282
+ // that includes replies walks the listed CID refs level by level. The page generator calls this per bounded
1283
+ // batch of posts (issue #351), never for a whole board at once.
1252
1284
  resolveRepliesCidRefsForEntries(entries) {
1253
- // For entries whose replies are in CID-ref format, resolve them by fetching descendants.
1254
- // Collects all root CIDs from all entries, runs a single recursive query, then distributes results.
1255
- const commentUpdateCols = this._existingColumns(TABLES.COMMENT_UPDATES, keys(CommentUpdateSchema.shape));
1256
- const commentIpfsCols = this._existingColumns(TABLES.COMMENTS, [...keys(CommentIpfsSchema.shape), "extraProps"]);
1257
- // Gather all CIDs from CID-ref replies across all entries
1258
- const allCids = [];
1259
- for (const entry of entries) {
1260
- const replies = entry.commentUpdate.replies;
1261
- if (!replies)
1262
- continue;
1263
- for (const sortEntry of Object.values(replies)) {
1264
- if (sortEntry?.commentCids)
1265
- allCids.push(...sortEntry.commentCids);
1266
- }
1267
- }
1268
- // Build lookup maps from recursive query (only if we have CIDs to resolve)
1269
- const parsedByCid = new Map();
1270
- const childrenByParent = new Map();
1271
- if (allCids.length > 0) {
1272
- // Fetch all descendant trees starting from these CIDs using a recursive CTE
1273
- const placeholders = allCids.map(() => "?").join(",");
1274
- const commentUpdateSelects = commentUpdateCols.map((col) => `cu.${col} AS commentUpdate_${col}`);
1275
- const commentIpfsSelects = commentIpfsCols.map((col) => `c.${col} AS commentIpfs_${col}`);
1276
- const recCommentUpdateSelects = commentUpdateCols.map((col) => `cu2.${col} AS commentUpdate_${col}`);
1277
- const recCommentIpfsSelects = commentIpfsCols.map((col) => `c2.${col} AS commentIpfs_${col}`);
1278
- const queryStr = `
1279
- WITH RECURSIVE reply_tree AS (
1280
- SELECT ${commentIpfsSelects.join(", ")}, ${commentUpdateSelects.join(", ")},
1281
- c.parentCid AS tree_parent
1282
- FROM ${TABLES.COMMENTS} c
1283
- INNER JOIN ${TABLES.COMMENT_UPDATES} cu ON c.cid = cu.cid
1284
- WHERE c.cid IN (${placeholders})
1285
-
1286
- UNION ALL
1287
-
1288
- SELECT ${recCommentIpfsSelects.join(", ")}, ${recCommentUpdateSelects.join(", ")},
1289
- rt.commentUpdate_cid AS tree_parent
1290
- FROM reply_tree rt
1291
- CROSS JOIN json_each(
1292
- json_extract(rt.commentUpdate_replies, '$.best.commentCids')
1293
- ) child_ref
1294
- JOIN ${TABLES.COMMENTS} c2 ON c2.cid = child_ref.value
1295
- JOIN ${TABLES.COMMENT_UPDATES} cu2 ON c2.cid = cu2.cid
1296
- WHERE rt.commentUpdate_replies IS NOT NULL
1297
- AND json_type(rt.commentUpdate_replies, '$.best.commentCids') = 'array'
1298
- )
1299
- SELECT * FROM reply_tree
1300
- `;
1301
- const rowsRaw = this._db.prepare(queryStr).all(...allCids);
1302
- // Build lookup: cid -> parsed entry
1303
- for (const row of rowsRaw) {
1304
- const { comment, commentUpdate } = this._parsePrefixedComment(row);
1305
- parsedByCid.set(commentUpdate.cid, { comment, commentUpdate });
1306
- const parent = row.tree_parent;
1307
- if (!childrenByParent.has(parent))
1308
- childrenByParent.set(parent, []);
1309
- childrenByParent.get(parent).push({ comment, commentUpdate });
1310
- }
1311
- }
1312
- // Build a commentUpdate for wire format: strip DB replies, attach resolved replies or reconstruct pageCids
1313
- const buildWireCommentUpdate = (commentUpdate, resolvedReplies) => {
1314
- const { replies: dbReplies, ...rest } = commentUpdate;
1315
- if (resolvedReplies)
1316
- return { ...rest, replies: resolvedReplies };
1317
- // If no resolved inline replies but DB has allPageCids, reconstruct pageCids so clients can fetch pages
1318
- if (dbReplies) {
1319
- const dbEntries = dbReplies;
1320
- const pageCids = {};
1321
- for (const [sortName, sortEntry] of Object.entries(dbEntries)) {
1322
- if (sortEntry?.allPageCids?.[0])
1323
- pageCids[sortName] = sortEntry.allPageCids[0];
1324
- }
1325
- if (Object.keys(pageCids).length > 0)
1326
- return { ...rest, replies: { pages: {}, pageCids } };
1285
+ const withReplies = entries.filter((entry) => this._entryHasDbReplies(entry));
1286
+ if (withReplies.length === 0)
1287
+ return entries;
1288
+ const trees = withReplies.every((entry) => entry.comment.depth === 0)
1289
+ ? this._assembleReplyTrees(this.queryRepliesUnderPosts(withReplies.map((entry) => entry.commentUpdate.cid)).map((entry) => ({
1290
+ entry,
1291
+ parent: entry.comment.parentCid
1292
+ })))
1293
+ : this._assembleReplyTreesFromCidRefs(withReplies);
1294
+ return this._rebuildEntriesFromTrees(entries, trees);
1295
+ }
1296
+ // The listed-subtree resolution for entries that are not all posts (a reply's page, at any depth): walk the CID
1297
+ // refs level by level, each level one primary-key read of the cids the level above lists, batched under the
1298
+ // SQLite variable cap. Reads exactly the tree the entries embed, so a reply's cost is its own subtree (issue #351).
1299
+ _assembleReplyTreesFromCidRefs(entries) {
1300
+ const mapper = this._pageEntryMapper(undefined, true);
1301
+ const listedChildren = (ofEntries) => {
1302
+ const cids = [];
1303
+ for (const entry of ofEntries) {
1304
+ const replies = entry.commentUpdate.replies;
1305
+ if (!replies)
1306
+ continue;
1307
+ for (const sortEntry of Object.values(replies))
1308
+ if (sortEntry?.commentCids)
1309
+ cids.push(...sortEntry.commentCids);
1327
1310
  }
1328
- return rest;
1329
- };
1330
- const attachReplies = (cid) => {
1331
- const children = childrenByParent.get(cid);
1332
- if (!children?.length)
1333
- return undefined;
1334
- // Sort children by the parent's commentCids order.
1335
- // SQLite's recursive CTE does not guarantee row order matches json_each array order
1336
- // when additional JOINs are involved, so we must explicitly reorder.
1337
- const parent = parsedByCid.get(cid);
1338
- const parentDbReplies = parent?.commentUpdate?.replies;
1339
- const cidOrder = parentDbReplies?.best?.commentCids;
1340
- const orderedChildren = cidOrder
1341
- ? cidOrder.map((childCid) => children.find((c) => c.commentUpdate.cid === childCid)).filter(Boolean)
1342
- : children;
1343
- return {
1344
- pages: {
1345
- best: {
1346
- comments: orderedChildren.map((child) => ({
1347
- comment: child.comment,
1348
- commentUpdate: buildWireCommentUpdate(child.commentUpdate, attachReplies(child.commentUpdate.cid))
1349
- }))
1350
- }
1351
- }
1352
- };
1311
+ return cids;
1353
1312
  };
1354
- // Resolve each entry's CID-ref replies into full nested data
1313
+ const items = [];
1314
+ const seen = new Set();
1315
+ let frontier = listedChildren(entries);
1316
+ while (frontier.length > 0) {
1317
+ const level = [];
1318
+ this._forEachCidBatch(frontier.filter((cid) => !seen.has(cid)), (placeholders) => `
1319
+ SELECT ${mapper.selectList((col) => `c.${col}`, (col) => `cu.${col}`)}
1320
+ FROM ${TABLES.COMMENTS} c
1321
+ INNER JOIN ${TABLES.COMMENT_UPDATES} cu ON c.cid = cu.cid
1322
+ WHERE c.cid IN (${placeholders})
1323
+ `, (row) => level.push(mapper.map(row)), true);
1324
+ for (const entry of level) {
1325
+ seen.add(entry.commentUpdate.cid);
1326
+ items.push({ entry, parent: entry.comment.parentCid });
1327
+ }
1328
+ frontier = listedChildren(level);
1329
+ }
1330
+ return this._assembleReplyTrees(items);
1331
+ }
1332
+ // Each entry's pages rebuilt from the assembled trees in its own commentCids order
1333
+ _rebuildEntriesFromTrees(entries, { parsedByCid, buildWireCommentUpdate, attachReplies }) {
1355
1334
  return entries.map((entry) => {
1356
- const replies = entry.commentUpdate.replies;
1357
- if (!replies)
1358
- return entry;
1359
- // Check if it's DB CID-ref format (has commentCids or allPageCids)
1360
- const isDbFormat = Object.values(replies).some((s) => s?.commentCids || s?.allPageCids);
1361
- if (!isDbFormat)
1335
+ if (!this._entryHasDbReplies(entry))
1362
1336
  return entry;
1363
- // Build resolved pages and pageCids from CID refs
1337
+ const replies = entry.commentUpdate.replies;
1364
1338
  const resolvedPages = {};
1365
1339
  const resolvedPageCids = {};
1366
- for (const [sortName, sortEntry] of Object.entries(replies)) {
1340
+ for (const sortName of Object.keys(replies).sort()) {
1341
+ const sortEntry = replies[sortName];
1367
1342
  if (sortEntry?.commentCids) {
1368
- const resolvedComments = sortEntry.commentCids
1369
- .map((cid) => parsedByCid.get(cid))
1370
- .filter(Boolean)
1371
- .map((child) => ({
1372
- comment: child.comment,
1373
- commentUpdate: buildWireCommentUpdate(child.commentUpdate, attachReplies(child.commentUpdate.cid))
1374
- }));
1375
- // Derive nextCid from allPageCids[0]; fall back to legacy nextCid field for old DB rows
1343
+ const resolvedComments = [];
1344
+ for (const cid of sortEntry.commentCids) {
1345
+ const child = parsedByCid.get(cid);
1346
+ if (child)
1347
+ resolvedComments.push({
1348
+ comment: child.comment,
1349
+ commentUpdate: buildWireCommentUpdate(child.commentUpdate, attachReplies(cid))
1350
+ });
1351
+ }
1352
+ // nextCid from allPageCids[0]; the legacy nextCid field for old DB rows
1376
1353
  const nextCidForSort = sortEntry.allPageCids?.[0] ?? sortEntry.nextCid;
1377
- resolvedPages[sortName] = {
1378
- comments: resolvedComments,
1379
- ...(nextCidForSort ? { nextCid: nextCidForSort } : {})
1380
- };
1354
+ resolvedPages[sortName] = { comments: resolvedComments, ...(nextCidForSort ? { nextCid: nextCidForSort } : {}) };
1381
1355
  }
1382
- // Derive pageCid from allPageCids[0] (first page CID for this sort)
1383
- if (sortEntry?.allPageCids?.[0]) {
1356
+ if (sortEntry?.allPageCids?.[0])
1384
1357
  resolvedPageCids[sortName] = sortEntry.allPageCids[0];
1385
- }
1386
1358
  }
1387
1359
  const { replies: _dbReplies, ...entryCommentUpdateRest } = entry.commentUpdate;
1388
1360
  return {
1389
1361
  ...entry,
1390
- commentUpdate: {
1362
+ commentUpdate: withSortedKeys({
1391
1363
  ...entryCommentUpdateRest,
1392
1364
  replies: {
1393
- pages: resolvedPages,
1394
- ...(Object.keys(resolvedPageCids).length > 0 ? { pageCids: resolvedPageCids } : {})
1365
+ ...(Object.keys(resolvedPageCids).length > 0 ? { pageCids: resolvedPageCids } : {}),
1366
+ pages: resolvedPages
1395
1367
  }
1396
- }
1368
+ })
1397
1369
  };
1398
1370
  });
1399
1371
  }
1400
1372
  queryCommentAndCommentUpdateByCids(cids, opts) {
1401
1373
  if (cids.length === 0)
1402
1374
  return [];
1403
- const placeholders = cids.map(() => "?").join(",");
1404
- const commentUpdateSelects = this._existingColumns(TABLES.COMMENT_UPDATES, opts.commentUpdateCols).map((col) => `cu.${col} AS commentUpdate_${col}`);
1405
- const commentIpfsSelects = this._existingColumns(TABLES.COMMENTS, opts.commentIpfsCols).map((col) => `c.${col} AS commentIpfs_${col}`);
1406
- const queryStr = `
1407
- SELECT ${commentIpfsSelects.join(", ")}, ${commentUpdateSelects.join(", ")}
1375
+ const mapper = this._positionalMapperFor({
1376
+ commentIpfsCols: this._existingColumns(TABLES.COMMENTS, opts.commentIpfsCols),
1377
+ commentUpdateCols: this._existingColumns(TABLES.COMMENT_UPDATES, opts.commentUpdateCols)
1378
+ });
1379
+ const entries = [];
1380
+ this._forEachCidBatch(cids, (placeholders) => `
1381
+ SELECT ${mapper.selectList((col) => `c.${col}`, (col) => `cu.${col}`)}
1408
1382
  FROM ${TABLES.COMMENTS} c
1409
1383
  INNER JOIN ${TABLES.COMMENT_UPDATES} cu ON c.cid = cu.cid
1410
1384
  WHERE c.cid IN (${placeholders})
1411
- `;
1412
- const rows = this._db.prepare(queryStr).all(...cids);
1413
- return rows.map((row) => {
1414
- const { comment, commentUpdate } = this._parsePrefixedComment(row);
1415
- return { comment, commentUpdate };
1416
- });
1385
+ `, (row) => entries.push(mapper.map(row)), true);
1386
+ return entries;
1417
1387
  }
1418
1388
  queryFlattenedPageReplies(options) {
1419
- const commentUpdateCols = keys(options.commentUpdateFieldsToExclude
1420
- ? omit(CommentUpdateSchema.shape, options.commentUpdateFieldsToExclude)
1421
- : CommentUpdateSchema.shape);
1422
- // TODO, is it omitting replies?
1423
- const commentUpdateSelects = commentUpdateCols.map((col) => `c_updates.${col} AS commentUpdate_${col}`);
1424
- const commentIpfsCols = [...keys(CommentIpfsSchema.shape), "extraProps"];
1425
- const commentIpfsSelects = commentIpfsCols.map((col) => `comments_alias.${col} AS commentIpfs_${col}`);
1389
+ const mapper = this._pageEntryMapper(options.commentUpdateFieldsToExclude);
1390
+ const commentUpdateCols = mapper.commentUpdateCols;
1426
1391
  let baseWhereClausesStr = "";
1427
1392
  let recursiveWhereClausesStr = "";
1428
1393
  const params = [options.parentCid];
@@ -1476,25 +1441,22 @@ export class DbHandler {
1476
1441
  INNER JOIN comment_tree tree ON comments.parentCid = tree.cid
1477
1442
  WHERE 1=1 ${recursiveWhereClausesStr}
1478
1443
  )
1479
- SELECT ${commentIpfsSelects.join(", ")}, ${commentUpdateCols.map((col) => `comments_alias.c_updates_${col} AS commentUpdate_${col}`).join(", ")}
1444
+ SELECT ${mapper.selectList((col) => `comments_alias.${col}`, (col) => `comments_alias.c_updates_${col}`)}
1480
1445
  FROM comment_tree comments_alias
1481
1446
  `;
1482
- const commentsRaw = this._db.prepare(query).all(...params);
1483
- return commentsRaw.map((commentRaw) => {
1484
- const { comment, commentUpdate } = this._parsePrefixedComment(commentRaw);
1485
- return { comment, commentUpdate };
1486
- });
1447
+ const rows = this._prepareCached(query)
1448
+ .raw(true)
1449
+ .all(...params);
1450
+ return rows.map(mapper.map);
1487
1451
  }
1488
1452
  queryStoredCommentUpdate(comment) {
1489
- const row = this._db.prepare(`SELECT * FROM ${TABLES.COMMENT_UPDATES} WHERE cid = ?`).get(comment.cid);
1453
+ const row = this._prepareCached(`SELECT * FROM ${TABLES.COMMENT_UPDATES} WHERE cid = ?`).get(comment.cid);
1490
1454
  if (!row)
1491
1455
  return undefined;
1492
1456
  return this._parseCommentUpdatesRow(row);
1493
1457
  }
1494
1458
  queryCommentUpdateTimestampBucketReplies(opts) {
1495
- const row = this._db
1496
- .prepare(`SELECT updatedAt, postUpdatesBucket, replies FROM ${TABLES.COMMENT_UPDATES} WHERE cid = ?`)
1497
- .get(opts.cid);
1459
+ const row = this._prepareCached(`SELECT updatedAt, postUpdatesBucket, replies FROM ${TABLES.COMMENT_UPDATES} WHERE cid = ?`).get(opts.cid);
1498
1460
  if (!row)
1499
1461
  return undefined;
1500
1462
  return {
@@ -1504,7 +1466,7 @@ export class DbHandler {
1504
1466
  };
1505
1467
  }
1506
1468
  queryCommentUpdateBucketAndReplies(opts) {
1507
- const row = this._db.prepare(`SELECT postUpdatesBucket, replies FROM ${TABLES.COMMENT_UPDATES} WHERE cid = ?`).get(opts.cid);
1469
+ const row = this._prepareCached(`SELECT postUpdatesBucket, replies FROM ${TABLES.COMMENT_UPDATES} WHERE cid = ?`).get(opts.cid);
1508
1470
  if (!row)
1509
1471
  return undefined;
1510
1472
  return {
@@ -1522,12 +1484,10 @@ export class DbHandler {
1522
1484
  return row !== undefined;
1523
1485
  }
1524
1486
  queryCommentBySignatureEncoded(signatureEncoded) {
1525
- const row = this._db
1526
- .prepare(`SELECT * FROM ${TABLES.COMMENTS}
1487
+ const row = this._prepareCached(`SELECT * FROM ${TABLES.COMMENTS}
1527
1488
  WHERE json_extract(signature, '$.signature') = ?
1528
1489
  OR originalCommentSignatureEncoded = ?
1529
- LIMIT 1`)
1530
- .get(signatureEncoded, signatureEncoded);
1490
+ LIMIT 1`).get(signatureEncoded, signatureEncoded);
1531
1491
  if (!row)
1532
1492
  return undefined;
1533
1493
  return this._parseCommentsTableRow(row);
@@ -1557,132 +1517,236 @@ export class DbHandler {
1557
1517
  return this._db.prepare(query).all(rootComment.parentCid);
1558
1518
  }
1559
1519
  queryCommentsPendingApproval() {
1560
- const results = this._db
1561
- .prepare(`SELECT * FROM ${TABLES.COMMENTS} WHERE pendingApproval = 1 ORDER BY rowid DESC`)
1562
- .all();
1520
+ const results = this._prepareCached(`SELECT * FROM ${TABLES.COMMENTS} WHERE pendingApproval = 1 ORDER BY rowid DESC`).all();
1563
1521
  return results.map((r) => this._parseCommentsTableRow(r));
1564
1522
  }
1565
- queryCommentsToBeUpdated() {
1566
- // TODO optimize this query in the future
1567
- // Make sure tests in commentsToUpdate.db.community.test.js are passing
1568
- const query = `
1569
- WITH RECURSIVE
1570
- direct_updates AS (
1571
- SELECT c.* FROM ${TABLES.COMMENTS} c LEFT JOIN ${TABLES.COMMENT_UPDATES} cu ON c.cid = cu.cid
1572
- WHERE (c.pendingApproval IS NULL OR c.pendingApproval != 1)
1573
- AND (cu.cid IS NULL OR (cu.publishedToPostUpdatesMFS = 0 OR cu.publishedToPostUpdatesMFS IS FALSE))
1574
- UNION
1575
- SELECT c.* FROM ${TABLES.COMMENTS} c JOIN ${TABLES.COMMENT_UPDATES} cu ON c.cid = cu.cid
1576
- WHERE (c.pendingApproval IS NULL OR c.pendingApproval != 1)
1577
- AND (
1578
- EXISTS (SELECT 1 FROM ${TABLES.VOTES} v WHERE v.commentCid = c.cid AND v.insertedAt >= cu.insertedAt)
1579
- OR EXISTS (SELECT 1 FROM ${TABLES.COMMENT_EDITS} ce WHERE ce.commentCid = c.cid AND ce.insertedAt >= cu.insertedAt)
1580
- OR EXISTS (SELECT 1 FROM ${TABLES.COMMENT_MODERATIONS} cm WHERE cm.commentCid = c.cid AND cm.insertedAt >= cu.insertedAt)
1581
- OR EXISTS (SELECT 1 FROM ${TABLES.COMMENTS} cc WHERE cc.parentCid = c.cid AND cc.insertedAt >= cu.insertedAt)
1582
- )
1523
+ // Windowed reply sorts (settings.pages.replies[].options.maxAge) need a time-based trigger that nothing else
1524
+ // provides: a page's membership changes when a reply crosses the window boundary even though no row changed.
1525
+ // One arm per distinct window flags a parent only when a reply was inside the window at the parent's last
1526
+ // generation (cu.updatedAt) and is outside it now, so a quiet thread costs a range scan and nothing else.
1527
+ // Flat sorts filter the whole flattened subtree and are generated for posts only, so their arm walks
1528
+ // descendants up to the depth-0 ancestor. Pinned replies bypass the filter when pinnedFirst is true. Omitted
1529
+ // entirely (empty strings) when no reply sort is windowed, so the default config pays nothing.
1530
+ _buildWindowedReplySortArms(now) {
1531
+ const windowedSorts = (this._community._pageSorts?.replies ?? []).filter((sort) => typeof sort.maxAgeSeconds === "number");
1532
+ if (windowedSorts.length === 0)
1533
+ return { ctes: "", selects: [], params: {} };
1534
+ const ctes = [];
1535
+ const selects = [];
1536
+ const params = { windowNow: now };
1537
+ const seen = new Set();
1538
+ for (const sort of windowedSorts) {
1539
+ const key = `${sort.maxAgeSeconds}:${sort.flat}:${sort.pinnedFirst}`;
1540
+ if (seen.has(key))
1541
+ continue;
1542
+ seen.add(key);
1543
+ const i = ctes.length;
1544
+ params[`windowMaxAge${i}`] = sort.maxAgeSeconds;
1545
+ const pinnedClause = sort.pinnedFirst ? `AND (rcu.pinned IS NULL OR rcu.pinned != 1)` : "";
1546
+ const crossingClause = `r_ts >= cu_anc.updatedAt - :windowMaxAge${i} AND r_ts < :windowNow - :windowMaxAge${i}`;
1547
+ if (sort.flat)
1548
+ ctes.push(`
1549
+ windowed_flat_${i}_ancestors AS (
1550
+ SELECT r.cid AS reply_cid, r.timestamp AS r_ts, r.parentCid AS anc_cid FROM ${TABLES.COMMENTS} r WHERE r.parentCid IS NOT NULL
1551
+ UNION ALL
1552
+ SELECT wa.reply_cid, wa.r_ts, p.parentCid FROM windowed_flat_${i}_ancestors wa
1553
+ JOIN ${TABLES.COMMENTS} p ON p.cid = wa.anc_cid WHERE p.parentCid IS NOT NULL
1583
1554
  ),
1584
- child_counts AS (
1585
- SELECT
1586
- c.parentCid AS cid,
1587
- COUNT(*) AS actual_child_count
1555
+ windowed_${i} AS (
1556
+ SELECT DISTINCT wa.anc_cid AS cid
1557
+ FROM windowed_flat_${i}_ancestors wa
1558
+ JOIN ${TABLES.COMMENTS} anc ON anc.cid = wa.anc_cid AND anc.depth = 0
1559
+ JOIN ${TABLES.COMMENT_UPDATES} cu_anc ON cu_anc.cid = wa.anc_cid
1560
+ LEFT JOIN ${TABLES.COMMENT_UPDATES} rcu ON rcu.cid = wa.reply_cid
1561
+ WHERE ${crossingClause} ${pinnedClause}
1562
+ )`);
1563
+ else
1564
+ ctes.push(`
1565
+ windowed_${i} AS (
1566
+ SELECT DISTINCT r.parentCid AS cid
1567
+ FROM (SELECT cid, parentCid, timestamp AS r_ts FROM ${TABLES.COMMENTS} WHERE parentCid IS NOT NULL) r
1568
+ JOIN ${TABLES.COMMENT_UPDATES} cu_anc ON cu_anc.cid = r.parentCid
1569
+ LEFT JOIN ${TABLES.COMMENT_UPDATES} rcu ON rcu.cid = r.cid
1570
+ WHERE ${crossingClause} ${pinnedClause}
1571
+ )`);
1572
+ selects.push(`SELECT c.rowid FROM ${TABLES.COMMENTS} c JOIN windowed_${i} w${i} ON c.cid = w${i}.cid`);
1573
+ }
1574
+ return { ctes: ctes.join(","), selects, params };
1575
+ }
1576
+ // The rowids of every comment the update cycle has to recalculate, collected into a temp table (issue #355).
1577
+ //
1578
+ // The flag set used to be one query: recursive CTEs stacked over `SELECT c.*`, whose every UNION and
1579
+ // DISTINCT deduplicated ~900-byte rows on a temp B-tree and whose every level re-copied the board. On a
1580
+ // 3.1M-comment board that was 2.7 GB of heap and 5.7 minutes before the first CommentUpdate. Here the set
1581
+ // is an integer-keyed temp table, each arm of the old query is one INSERT OR IGNORE into it, and the two
1582
+ // cascades (an author's other comments, and the parent chain) read the table they extend. Integer keys are
1583
+ // what make it cheap: the same sequence keyed by cid strings took three times as long. Measured on that
1584
+ // board: 14.5 s for the whole sequence in the SQLite shell.
1585
+ //
1586
+ // The arms, in order, and what each stands for:
1587
+ // 1. no CommentUpdate row yet, or one not yet published to the postUpdates directory;
1588
+ // 2. a vote, edit, moderation or child comment inserted since the row was calculated (issue #209: the
1589
+ // row's insertedAt is the batch's start timestamp, so nothing inserted during a batch is missed);
1590
+ // 3. childCount no longer matches the counted children (a child removed, deleted or purged);
1591
+ // 4. lastChildCid no longer matches the newest counted child;
1592
+ // 5. a child listed in the stored replies pages has a newer CommentUpdate than the parent's;
1593
+ // 6. windowed reply sorts, one arm per distinct window (see _buildWindowedReplySortArms);
1594
+ // 7. every other comment by an author of a flagged comment (author.community aggregates);
1595
+ // 8. every ancestor of a flagged comment (its counts, last reply and reply pages read the child's row).
1596
+ // Pending-approval comments are never flagged and stop the parent-chain walk.
1597
+ // Make sure the tests in commentsToUpdate.db.community.test.ts are passing after changing any of them.
1598
+ _collectCommentsToBeUpdated() {
1599
+ const windowed = this._buildWindowedReplySortArms(timestamp());
1600
+ const notPending = (alias) => `(${alias}.pendingApproval IS NULL OR ${alias}.pendingApproval != 1)`;
1601
+ // A child that counts towards its parent's childCount: has a CommentUpdate, not removed, not deleted by its author
1602
+ const countedChild = `
1588
1603
  FROM ${TABLES.COMMENTS} c
1589
1604
  JOIN ${TABLES.COMMENT_UPDATES} cu_child ON c.cid = cu_child.cid
1590
- LEFT JOIN (
1591
- SELECT cid, json_extract(edit, '$.deleted') AS deleted_flag FROM ${TABLES.COMMENT_UPDATES}
1592
- ) deleted_lookup ON deleted_lookup.cid = c.cid
1593
1605
  WHERE c.parentCid IS NOT NULL
1594
- AND (c.pendingApproval IS NULL OR c.pendingApproval != 1)
1606
+ AND ${notPending("c")}
1595
1607
  AND (cu_child.removed IS NOT 1 AND cu_child.removed IS NOT TRUE)
1596
- AND (deleted_lookup.deleted_flag IS NULL OR deleted_lookup.deleted_flag != 1)
1608
+ AND (json_extract(cu_child.edit, '$.deleted') IS NULL OR json_extract(cu_child.edit, '$.deleted') != 1)`;
1609
+ const insert = `INSERT OR IGNORE INTO ${COMMENTS_TO_UPDATE_TEMP_TABLE}`;
1610
+ const arms = [
1611
+ `${insert}
1612
+ SELECT c.rowid FROM ${TABLES.COMMENTS} c LEFT JOIN ${TABLES.COMMENT_UPDATES} cu ON c.cid = cu.cid
1613
+ WHERE ${notPending("c")}
1614
+ AND (cu.cid IS NULL OR (cu.publishedToPostUpdatesMFS = 0 OR cu.publishedToPostUpdatesMFS IS FALSE))`,
1615
+ `${insert}
1616
+ SELECT c.rowid FROM ${TABLES.COMMENTS} c JOIN ${TABLES.COMMENT_UPDATES} cu ON c.cid = cu.cid
1617
+ WHERE ${notPending("c")}
1618
+ AND (
1619
+ EXISTS (SELECT 1 FROM ${TABLES.VOTES} v WHERE v.commentCid = c.cid AND v.insertedAt >= cu.insertedAt)
1620
+ OR EXISTS (SELECT 1 FROM ${TABLES.COMMENT_EDITS} ce WHERE ce.commentCid = c.cid AND ce.insertedAt >= cu.insertedAt)
1621
+ OR EXISTS (SELECT 1 FROM ${TABLES.COMMENT_MODERATIONS} cm WHERE cm.commentCid = c.cid AND cm.insertedAt >= cu.insertedAt)
1622
+ OR EXISTS (SELECT 1 FROM ${TABLES.COMMENTS} cc WHERE cc.parentCid = c.cid AND cc.insertedAt >= cu.insertedAt)
1623
+ )`,
1624
+ `${insert}
1625
+ WITH child_counts AS (
1626
+ SELECT c.parentCid AS cid, COUNT(*) AS actual_child_count
1627
+ ${countedChild}
1597
1628
  GROUP BY c.parentCid
1598
- ),
1599
- filtered_children AS (
1600
- SELECT
1601
- c.parentCid AS cid,
1602
- c.cid AS child_cid,
1603
- ROW_NUMBER() OVER (PARTITION BY c.parentCid ORDER BY c.rowid DESC) AS child_rank
1604
- FROM ${TABLES.COMMENTS} c
1605
- JOIN ${TABLES.COMMENT_UPDATES} cu_child ON c.cid = cu_child.cid
1606
- LEFT JOIN (
1607
- SELECT cid, json_extract(edit, '$.deleted') AS deleted_flag FROM ${TABLES.COMMENT_UPDATES}
1608
- ) deleted_lookup ON deleted_lookup.cid = c.cid
1609
- WHERE c.parentCid IS NOT NULL
1610
- AND (c.pendingApproval IS NULL OR c.pendingApproval != 1)
1611
- AND (cu_child.removed IS NOT 1 AND cu_child.removed IS NOT TRUE)
1612
- AND (deleted_lookup.deleted_flag IS NULL OR deleted_lookup.deleted_flag != 1)
1629
+ )
1630
+ SELECT parent.rowid
1631
+ FROM ${TABLES.COMMENT_UPDATES} cu_parent
1632
+ JOIN ${TABLES.COMMENTS} parent ON parent.cid = cu_parent.cid
1633
+ LEFT JOIN child_counts cc ON cc.cid = cu_parent.cid
1634
+ WHERE ${notPending("parent")}
1635
+ AND COALESCE(cc.actual_child_count, 0) != COALESCE(cu_parent.childCount, 0)`,
1636
+ `${insert}
1637
+ WITH last_child_rowids AS (
1638
+ SELECT c.parentCid AS cid, MAX(c.rowid) AS last_child_rowid
1639
+ ${countedChild}
1613
1640
  AND COALESCE(cu_child.approved, 1) != 0
1614
- ),
1615
- last_child_cids AS (
1616
- SELECT cid, child_cid AS actual_last_child_cid
1617
- FROM filtered_children
1618
- WHERE child_rank = 1
1619
- ),
1620
- stale_child_counts AS (
1621
- SELECT parent.cid
1622
- FROM ${TABLES.COMMENTS} parent
1623
- JOIN ${TABLES.COMMENT_UPDATES} cu_parent ON parent.cid = cu_parent.cid
1624
- LEFT JOIN child_counts cc ON cc.cid = parent.cid
1625
- WHERE (parent.pendingApproval IS NULL OR parent.pendingApproval != 1)
1626
- AND COALESCE(cc.actual_child_count, 0) != COALESCE(cu_parent.childCount, 0)
1627
- ),
1628
- stale_last_child_cids AS (
1629
- SELECT parent.cid
1630
- FROM ${TABLES.COMMENTS} parent
1631
- JOIN ${TABLES.COMMENT_UPDATES} cu_parent ON parent.cid = cu_parent.cid
1632
- LEFT JOIN last_child_cids lc ON lc.cid = parent.cid
1633
- WHERE (parent.pendingApproval IS NULL OR parent.pendingApproval != 1)
1634
- AND COALESCE(lc.actual_last_child_cid, '') != COALESCE(cu_parent.lastChildCid, '')
1635
- ),
1636
- stale_replies AS (
1637
- SELECT DISTINCT cu_parent.cid
1638
- FROM ${TABLES.COMMENT_UPDATES} cu_parent
1639
- CROSS JOIN json_each(
1640
- json_extract(cu_parent.replies, '$.best.commentCids')
1641
- ) child_ref
1642
- JOIN ${TABLES.COMMENT_UPDATES} cu_child ON cu_child.cid = child_ref.value
1643
- WHERE cu_parent.replies IS NOT NULL
1644
- AND json_type(cu_parent.replies, '$.best.commentCids') = 'array'
1645
- -- Compare against the parent's updatedAt, not its insertedAt (issue #230). insertedAt is
1646
- -- the batch's start timestamp, shared by every row the batch writes (issue #209/#211),
1647
- -- while updatedAt is stamped when that row is actually calculated. A batch walks a post
1648
- -- tree deepest-depth-first, so a child is always calculated before its parent and its
1649
- -- updatedAt lands after the batch's start: comparing against insertedAt re-flagged every
1650
- -- parent of any batch spanning >= 1 second, which produced another such batch, and the
1651
- -- update loop never converged. updatedAt is when the parent's replies page was actually
1652
- -- generated, which is the point the child's data could have gone stale relative to.
1653
- AND cu_child.updatedAt > cu_parent.updatedAt
1654
- ),
1655
- base_updates AS (
1656
- SELECT * FROM direct_updates
1657
- UNION SELECT c.* FROM ${TABLES.COMMENTS} c JOIN stale_child_counts scc ON c.cid = scc.cid
1658
- UNION SELECT c.* FROM ${TABLES.COMMENTS} c JOIN stale_last_child_cids slc ON c.cid = slc.cid
1659
- UNION SELECT c.* FROM ${TABLES.COMMENTS} c JOIN stale_replies sr ON c.cid = sr.cid
1660
- ),
1661
- authors_to_update AS (SELECT DISTINCT authorSignerAddress FROM base_updates),
1662
- author_comments AS (
1663
- SELECT c.* FROM ${TABLES.COMMENTS} c JOIN authors_to_update a ON c.authorSignerAddress = a.authorSignerAddress
1664
- WHERE (c.pendingApproval IS NULL OR c.pendingApproval != 1)
1665
- ),
1666
- comments_needing_update AS (
1667
- SELECT * FROM base_updates
1668
- UNION SELECT * FROM author_comments
1669
- ),
1670
- parent_chain AS (
1671
- SELECT DISTINCT p.* FROM ${TABLES.COMMENTS} p JOIN comments_needing_update cnu ON p.cid = cnu.parentCid
1672
- WHERE p.cid IS NOT NULL AND (p.pendingApproval IS NULL OR p.pendingApproval != 1)
1641
+ GROUP BY c.parentCid
1642
+ )
1643
+ SELECT parent.rowid
1644
+ FROM ${TABLES.COMMENT_UPDATES} cu_parent
1645
+ JOIN ${TABLES.COMMENTS} parent ON parent.cid = cu_parent.cid
1646
+ LEFT JOIN last_child_rowids lc ON lc.cid = cu_parent.cid
1647
+ LEFT JOIN ${TABLES.COMMENTS} last_child ON last_child.rowid = lc.last_child_rowid
1648
+ WHERE ${notPending("parent")}
1649
+ AND COALESCE(last_child.cid, '') != COALESCE(cu_parent.lastChildCid, '')`,
1650
+ `${insert}
1651
+ SELECT DISTINCT parent.rowid
1652
+ FROM ${TABLES.COMMENT_UPDATES} cu_parent
1653
+ JOIN ${TABLES.COMMENTS} parent ON parent.cid = cu_parent.cid
1654
+ CROSS JOIN json_each(cu_parent.replies) sort_entry
1655
+ CROSS JOIN json_each(json_extract(sort_entry.value, '$.commentCids')) child_ref
1656
+ JOIN ${TABLES.COMMENT_UPDATES} cu_child ON cu_child.cid = child_ref.value
1657
+ WHERE cu_parent.replies IS NOT NULL
1658
+ AND json_type(sort_entry.value, '$.commentCids') = 'array'
1659
+ -- Compare against the parent's updatedAt, not its insertedAt (issue #230). insertedAt is
1660
+ -- the batch's start timestamp, shared by every row the batch writes (issue #209/#211),
1661
+ -- while updatedAt is stamped when that row is actually calculated. A batch walks a post
1662
+ -- tree deepest-depth-first, so a child is always calculated before its parent and its
1663
+ -- updatedAt lands after the batch's start: comparing against insertedAt re-flagged every
1664
+ -- parent of any batch spanning >= 1 second, which produced another such batch, and the
1665
+ -- update loop never converged. updatedAt is when the parent's replies page was actually
1666
+ -- generated, which is the point the child's data could have gone stale relative to.
1667
+ AND cu_child.updatedAt > cu_parent.updatedAt`,
1668
+ ...windowed.selects.map((select) => `${insert} WITH RECURSIVE ${windowed.ctes} ${select}`),
1669
+ `${insert}
1670
+ SELECT c.rowid FROM ${TABLES.COMMENTS} c
1671
+ WHERE ${notPending("c")}
1672
+ AND c.authorSignerAddress IN (
1673
+ SELECT flagged.authorSignerAddress FROM ${COMMENTS_TO_UPDATE_TEMP_TABLE} t
1674
+ JOIN ${TABLES.COMMENTS} flagged ON flagged.rowid = t.id
1675
+ )`,
1676
+ `${insert}
1677
+ WITH RECURSIVE parent_chain(id, parentCid) AS (
1678
+ SELECT p.rowid, p.parentCid FROM ${COMMENTS_TO_UPDATE_TEMP_TABLE} t
1679
+ JOIN ${TABLES.COMMENTS} c ON c.rowid = t.id
1680
+ JOIN ${TABLES.COMMENTS} p ON p.cid = c.parentCid
1681
+ WHERE ${notPending("p")}
1673
1682
  UNION
1674
- SELECT DISTINCT p.* FROM ${TABLES.COMMENTS} p JOIN parent_chain pc ON p.cid = pc.parentCid
1675
- WHERE p.cid IS NOT NULL AND (p.pendingApproval IS NULL OR p.pendingApproval != 1)
1676
- ),
1677
- all_updates AS (
1678
- SELECT cid FROM comments_needing_update UNION SELECT cid FROM parent_chain
1683
+ SELECT p.rowid, p.parentCid FROM parent_chain pc JOIN ${TABLES.COMMENTS} p ON p.cid = pc.parentCid
1684
+ WHERE ${notPending("p")}
1679
1685
  )
1680
- SELECT c.* FROM ${TABLES.COMMENTS} c JOIN all_updates au ON c.cid = au.cid
1686
+ SELECT id FROM parent_chain`
1687
+ ];
1688
+ for (const arm of arms) {
1689
+ // Each statement is bound only to the named parameters it mentions (the windowed arms' window bounds)
1690
+ const params = Object.fromEntries(Object.entries(windowed.params).filter(([name]) => arm.includes(`:${name}`)));
1691
+ this._prepareCached(arm).run(params);
1692
+ }
1693
+ }
1694
+ // Runs `read` with the temp table of comments to update filled, and empties it afterwards whatever happens.
1695
+ // The table is one per connection: the update loop is the only caller on a community's own connection, and
1696
+ // a test handler is a connection of its own.
1697
+ _withCommentsToBeUpdated(read) {
1698
+ this._db.exec(`CREATE TEMP TABLE IF NOT EXISTS ${COMMENTS_TO_UPDATE_TEMP_TABLE} (id INTEGER PRIMARY KEY)`);
1699
+ this._db.exec(`DELETE FROM ${COMMENTS_TO_UPDATE_TEMP_TABLE}`);
1700
+ try {
1701
+ this._collectCommentsToBeUpdated();
1702
+ return read();
1703
+ }
1704
+ finally {
1705
+ this._db.exec(`DELETE FROM ${COMMENTS_TO_UPDATE_TEMP_TABLE}`);
1706
+ }
1707
+ }
1708
+ // Cached, so every caller sets raw() itself: the mode sticks to the statement object
1709
+ _selectCommentsToBeUpdated() {
1710
+ return this._prepareCached(`
1711
+ SELECT c.cid, c.depth FROM ${COMMENTS_TO_UPDATE_TEMP_TABLE} t JOIN ${TABLES.COMMENTS} c ON c.rowid = t.id
1681
1712
  WHERE (c.pendingApproval IS NULL OR c.pendingApproval != 1)
1682
- ORDER BY c.rowid
1683
- `;
1684
- const results = this._db.prepare(query).all();
1685
- return results.map((r) => this._parseCommentsTableRow(r));
1713
+ ORDER BY t.id
1714
+ `);
1715
+ }
1716
+ // Which comments the update cycle has to recalculate, as cid and depth only, in rowid order. Each slice of the
1717
+ // cycle loads its own rows through queryCommentsByCids. See _collectCommentsToBeUpdated for what flags a comment.
1718
+ queryCommentsToBeUpdated() {
1719
+ return this._withCommentsToBeUpdated(() => this._selectCommentsToBeUpdated().raw(false).all());
1720
+ }
1721
+ // The same set as queryCommentsToBeUpdated, grouped by depth as plain arrays of cids in rowid order: what the update
1722
+ // cycle holds for the whole board is then one string per comment to update (3.1M comments: 250 MB against 750 MB
1723
+ // of {cid, depth} objects, and 2.7 GB of full rows before issue #355).
1724
+ queryCidsToBeUpdatedByDepth() {
1725
+ return this._withCommentsToBeUpdated(() => {
1726
+ const cidsByDepth = new Map();
1727
+ for (const [cid, depth] of this._selectCommentsToBeUpdated().raw(true).iterate()) {
1728
+ const cids = cidsByDepth.get(depth);
1729
+ if (cids)
1730
+ cids.push(cid);
1731
+ else
1732
+ cidsByDepth.set(depth, [cid]);
1733
+ }
1734
+ return cidsByDepth;
1735
+ });
1736
+ }
1737
+ // The comment rows behind a list of cids, parsed, in the order the cids were given (a cid with no row is
1738
+ // skipped). What each slice of the update cycle loads for itself, since queryCommentsToBeUpdated no longer
1739
+ // materializes the rows for the whole board (issue #355).
1740
+ queryCommentsByCids(cids) {
1741
+ const byCid = new Map();
1742
+ this._forEachCidBatch(cids, (placeholders) => `SELECT * FROM ${TABLES.COMMENTS} WHERE cid IN (${placeholders})`, (row) => byCid.set(row.cid, this._parseCommentsTableRow(row)));
1743
+ const rows = [];
1744
+ for (const cid of cids) {
1745
+ const row = byCid.get(cid);
1746
+ if (row)
1747
+ rows.push(row);
1748
+ }
1749
+ return rows;
1686
1750
  }
1687
1751
  queryCommunityStats() {
1688
1752
  // if you change this logic, make sure to run stats.community.test.js
@@ -1868,18 +1932,16 @@ export class DbHandler {
1868
1932
  return hash;
1869
1933
  }
1870
1934
  queryComment(cid) {
1871
- const row = this._db.prepare(`SELECT * FROM ${TABLES.COMMENTS} WHERE cid = ?`).get(cid);
1935
+ const row = this._prepareCached(`SELECT * FROM ${TABLES.COMMENTS} WHERE cid = ?`).get(cid);
1872
1936
  if (!row)
1873
1937
  return undefined;
1874
1938
  return this._parseCommentsTableRow(row);
1875
1939
  }
1876
1940
  commentExistsInDb(cid) {
1877
- return this._db.prepare(`SELECT 1 FROM ${TABLES.COMMENTS} WHERE cid = ? LIMIT 1`).get(cid) !== undefined;
1941
+ return this._prepareCached(`SELECT 1 FROM ${TABLES.COMMENTS} WHERE cid = ? LIMIT 1`).get(cid) !== undefined;
1878
1942
  }
1879
1943
  queryPseudonymityAliasByCommentCid(commentCid) {
1880
- const row = this._db
1881
- .prepare(`SELECT commentCid, aliasPrivateKey, originalAuthorPublicKey, originalAuthorName, mode, insertedAt FROM ${TABLES.PSEUDONYMITY_ALIASES} WHERE commentCid = ?`)
1882
- .get(commentCid);
1944
+ const row = this._prepareCached(`SELECT commentCid, aliasPrivateKey, originalAuthorPublicKey, originalAuthorName, mode, insertedAt FROM ${TABLES.PSEUDONYMITY_ALIASES} WHERE commentCid = ?`).get(commentCid);
1883
1945
  return row;
1884
1946
  }
1885
1947
  queryPseudonymityAliasForPost(originalAuthorPublicKey, postCid) {
@@ -1915,35 +1977,6 @@ export class DbHandler {
1915
1977
  const parentCid = typeof row.parentCid === "string" ? row.parentCid : row.parentCid === null ? null : undefined;
1916
1978
  return { authorSignerAddress, parentCid };
1917
1979
  }
1918
- _queryCommentCounts(cid) {
1919
- const { clause: addrClause, params: addrParams } = this._communityAddressClauseNamed("c", "cc");
1920
- const query = `
1921
- SELECT
1922
- (SELECT COUNT(*) FROM ${TABLES.VOTES} WHERE commentCid = :cid AND vote = 1) AS upvoteCount,
1923
- (SELECT COUNT(*) FROM ${TABLES.VOTES} WHERE commentCid = :cid AND vote = -1) AS downvoteCount,
1924
- (
1925
- WITH RECURSIVE descendants AS (
1926
- SELECT c.cid FROM ${TABLES.COMMENTS} c
1927
- INNER JOIN ${TABLES.COMMENT_UPDATES} cu ON c.cid = cu.cid
1928
- LEFT JOIN (SELECT cid, json_extract(edit, '$.deleted') AS deleted_flag FROM ${TABLES.COMMENT_UPDATES}) AS d ON c.cid = d.cid
1929
- WHERE c.parentCid = :cid AND ${addrClause} AND (cu.removed IS NOT 1 AND cu.removed IS NOT TRUE) AND (d.deleted_flag IS NULL OR d.deleted_flag != 1)
1930
- UNION ALL
1931
- SELECT c.cid FROM ${TABLES.COMMENTS} c
1932
- INNER JOIN ${TABLES.COMMENT_UPDATES} cu ON c.cid = cu.cid
1933
- LEFT JOIN (SELECT cid, json_extract(edit, '$.deleted') AS deleted_flag FROM ${TABLES.COMMENT_UPDATES}) AS d ON c.cid = d.cid
1934
- JOIN descendants desc_nodes ON c.parentCid = desc_nodes.cid
1935
- WHERE ${addrClause} AND (cu.removed IS NOT 1 AND cu.removed IS NOT TRUE) AND (d.deleted_flag IS NULL OR d.deleted_flag != 1)
1936
- ) SELECT COUNT(*) FROM descendants
1937
- ) AS replyCount,
1938
- (
1939
- SELECT COUNT(*) FROM ${TABLES.COMMENTS} c
1940
- INNER JOIN ${TABLES.COMMENT_UPDATES} cu ON c.cid = cu.cid
1941
- LEFT JOIN (SELECT cid, json_extract(edit, '$.deleted') AS deleted_flag FROM ${TABLES.COMMENT_UPDATES}) AS d ON c.cid = d.cid
1942
- WHERE c.parentCid = :cid AND ${addrClause} AND (cu.removed IS NOT 1 AND cu.removed IS NOT TRUE) AND (d.deleted_flag IS NULL OR d.deleted_flag != 1)
1943
- ) AS childCount
1944
- `;
1945
- return this._db.prepare(query).get({ cid, ...addrParams });
1946
- }
1947
1980
  queryPostsWithOutdatedBuckets(buckets) {
1948
1981
  const currentTimestampSeconds = timestamp(); // timestamp is in seconds
1949
1982
  const maxBucket = Math.max(...buckets);
@@ -1963,7 +1996,7 @@ export class DbHandler {
1963
1996
  ) SELECT cid, timestamp, current_bucket AS currentBucket, new_bucket AS newBucket
1964
1997
  FROM post_data WHERE current_bucket != new_bucket
1965
1998
  `;
1966
- return this._db.prepare(query).all(...addrParams, maxBucket);
1999
+ return this._prepareCached(query).all(...addrParams, maxBucket);
1967
2000
  }
1968
2001
  queryLatestAuthorEdit(cid, authorSignerAddress) {
1969
2002
  const row = this._db
@@ -1981,6 +2014,7 @@ export class DbHandler {
1981
2014
  return pick(parsed, ["signature", ...signedKeys, ...commentEditFields]);
1982
2015
  }
1983
2016
  removeCommentFromPendingApproval(comment) {
2017
+ this._publicationWriteGeneration++;
1984
2018
  const log = Logger("pkc-js:local-community:db-handler:removeCommentFromPendingApproval");
1985
2019
  const stmt = this._db.prepare(`UPDATE ${TABLES.COMMENTS} SET pendingApproval = 0 WHERE cid = ?`);
1986
2020
  const res = stmt.run(comment.cid);
@@ -2112,17 +2146,6 @@ export class DbHandler {
2112
2146
  }
2113
2147
  return purgedDetails;
2114
2148
  }
2115
- _queryLatestModeratorReason(comment) {
2116
- const result = this._db
2117
- .prepare(`
2118
- SELECT json_extract(commentModeration, '$.reason') AS reason FROM ${TABLES.COMMENT_MODERATIONS}
2119
- WHERE commentCid = ? AND json_extract(commentModeration, '$.reason') IS NOT NULL ORDER BY rowid DESC LIMIT 1
2120
- `)
2121
- .get(comment.cid);
2122
- if (!result)
2123
- return undefined;
2124
- return result;
2125
- }
2126
2149
  queryCommentFlagsSetByMod(cid) {
2127
2150
  const query = `
2128
2151
  WITH flags_with_rank AS (
@@ -2148,7 +2171,7 @@ export class DbHandler {
2148
2171
  MAX(CASE WHEN nsfw IS NOT NULL AND nsfw_rank = 1 THEN nsfw ELSE NULL END) AS nsfw
2149
2172
  FROM flags_with_rank
2150
2173
  `;
2151
- const flags = this._db.prepare(query).get(cid);
2174
+ const flags = this._prepareCached(query).get(cid);
2152
2175
  if (!flags)
2153
2176
  return {};
2154
2177
  return mapValues(removeNullUndefinedValues(flags), Boolean);
@@ -2162,141 +2185,507 @@ export class DbHandler {
2162
2185
  .get(cid);
2163
2186
  return result && result.deleted !== null ? { deleted: Boolean(result.deleted) } : undefined;
2164
2187
  }
2165
- _queryModCommentFlairs(comment) {
2166
- const result = this._db
2167
- .prepare(`
2168
- SELECT json_extract(commentModeration, '$.flairs') AS flairs FROM ${TABLES.COMMENT_MODERATIONS}
2169
- WHERE commentCid = ? AND json_extract(commentModeration, '$.flairs') IS NOT NULL ORDER BY rowid DESC LIMIT 1
2170
- `)
2171
- .get(comment.cid);
2172
- if (!result)
2188
+ _queryIsCommentApproved(comment) {
2189
+ const result = this._prepareCached(`
2190
+ SELECT json_extract(commentModeration, '$.approved') AS approved FROM ${TABLES.COMMENT_MODERATIONS}
2191
+ WHERE commentCid = ? AND json_extract(commentModeration, '$.approved') IS NOT NULL ORDER BY rowid DESC LIMIT 1
2192
+ `).get(comment.cid);
2193
+ if (!result || result.approved === null)
2173
2194
  return undefined;
2174
- return { flairs: JSON.parse(result.flairs) };
2195
+ return { approved: Boolean(result.approved) };
2175
2196
  }
2176
- _queryLastChildCidAndLastReplyTimestamp(comment) {
2177
- const lastChildCid = this._db
2178
- .prepare(`SELECT c.cid FROM ${TABLES.COMMENTS} c
2197
+ queryCalculatedCommentUpdate(opts) {
2198
+ // The batch of one: the per-comment API is the batched calculation over a single entry (issue #352)
2199
+ return this._calculateCommentUpdates([{ comment: opts.comment, authorDomain: opts.authorDomain }], createCommunityAuthorMemo()).get(opts.comment.cid);
2200
+ }
2201
+ // The CommentUpdate fields of every given comment in one pass (issue #352): each field group is one statement per
2202
+ // chunk of comments instead of nine statements per comment, and the author aggregates are computed once per
2203
+ // distinct author (address set + domain) and kept in `authorMemo`, which the update cycle shares across its
2204
+ // depth batches so an author with a thousand comments is aggregated once per cycle, or once per publication
2205
+ // write (a memo filled before a vote landed would pair that vote's fresh counts with a stale score). Same result as
2206
+ // queryCalculatedCommentUpdate for every comment (the per-comment API is this over one entry).
2207
+ queryCalculatedCommentUpdates(opts) {
2208
+ return this._calculateCommentUpdates(opts.comments.map((comment) => ({ comment, authorDomain: getAuthorNameFromWire(comment.author) })), opts.authorMemo ?? createCommunityAuthorMemo());
2209
+ }
2210
+ _calculateCommentUpdates(entries, authorMemo) {
2211
+ const result = new Map();
2212
+ if (entries.length === 0)
2213
+ return result;
2214
+ const cids = entries.map((entry) => entry.comment.cid);
2215
+ const authorByCid = this._queryCommunityAuthorsForCommentUpdates(entries, authorMemo);
2216
+ const votes = this._queryVoteCountsByCids(cids);
2217
+ const counts = this._queryReplyCountsByParentCids(cids);
2218
+ const lastReplyTimestamps = this._queryLastReplyTimestampsByParentCids(cids);
2219
+ const lastChildCids = this._queryLastChildCidsByParentCids(cids);
2220
+ const moderations = this._queryModerationSummariesByCids(cids);
2221
+ const authorEdits = this._queryLatestAuthorEditsByCids(entries.map((entry) => entry.comment));
2222
+ const numbers = this._queryCommentNumbersByCids(cids);
2223
+ for (const { comment } of entries) {
2224
+ const authorCommunity = authorByCid.get(comment.cid);
2225
+ if (!authorCommunity)
2226
+ throw Error("Failed to query author.community in queryCalculatedCommentUpdate");
2227
+ const moderation = moderations.get(comment.cid);
2228
+ const authorEdit = authorEdits.get(comment.cid);
2229
+ const isThisCommentApproved = moderation?.approved !== undefined ? { approved: moderation.approved } : undefined;
2230
+ const removedFromApproved = isThisCommentApproved?.approved === false ? { removed: true } : undefined; // automatically add removed:true if approved=false. Will be overridden if there's commentFlags.removed
2231
+ const { number: commentNumber, postNumber } = numbers.get(comment.cid) ?? {};
2232
+ const voteCounts = votes.get(comment.cid) ?? { upvoteCount: 0, downvoteCount: 0 };
2233
+ const replyCounts = counts.get(comment.cid) ?? { replyCount: 0, childCount: 0 };
2234
+ // Seed with challenge-supplied commentUpdate (lowest priority, per-field). Mod queries below
2235
+ // overwrite individual keys (reason, flairs, flags, approved, ...) when the mod has actually
2236
+ // published a moderation that set that key — challenge keys the mod never touched persist.
2237
+ // Same logic applies one level deeper for author.community: challenge-supplied
2238
+ // commentUpdate.author.community.<newKey> (e.g. countryCode) seeds underneath the computed
2239
+ // authorCommunity, so community-computed keys (postScore, replyScore, ...) and mod-settable
2240
+ // keys (flairs, banExpiresAt) always win. The validator forbids challenges from setting any
2241
+ // schema-defined key on author.community, so the spread here only carries novel extras.
2242
+ const challengeAuthorCommunity = comment.challengeCommentUpdate?.author
2243
+ ?.community;
2244
+ result.set(comment.cid, {
2245
+ ...(comment.challengeCommentUpdate ?? {}),
2246
+ ...(removedFromApproved ? removedFromApproved : undefined),
2247
+ cid: comment.cid,
2248
+ ...(commentNumber !== undefined ? { number: commentNumber } : undefined),
2249
+ ...(postNumber !== undefined ? { postNumber } : undefined),
2250
+ upvoteCount: voteCounts.upvoteCount,
2251
+ downvoteCount: voteCounts.downvoteCount,
2252
+ replyCount: replyCounts.replyCount,
2253
+ childCount: replyCounts.childCount,
2254
+ flairs: moderation?.flairs || authorEdit?.flairs || comment.challengeCommentUpdate?.flairs,
2255
+ ...moderation?.flags,
2256
+ // moderatorReason wins when present, else fall back to the challenge-supplied reason (if any).
2257
+ reason: moderation?.reason ?? comment.challengeCommentUpdate?.reason,
2258
+ author: { community: { ...(challengeAuthorCommunity ?? {}), ...authorCommunity } },
2259
+ lastChildCid: lastChildCids.get(comment.cid),
2260
+ lastReplyTimestamp: lastReplyTimestamps.get(comment.cid),
2261
+ ...(authorEdit ? { edit: authorEdit } : undefined),
2262
+ ...(isThisCommentApproved ? { approved: isThisCommentApproved.approved } : undefined)
2263
+ });
2264
+ }
2265
+ return result;
2266
+ }
2267
+ // Chunks of a cid list under the SQLite variable cap, each padded with NULLs to a power of two so a query has at
2268
+ // most 13 prepared variants instead of one per batch size (`x IN (..., NULL)` never matches the padding).
2269
+ _cidChunks(cids, variablesBesidesCids = 0) {
2270
+ const max = 4096 - variablesBesidesCids;
2271
+ const chunks = [];
2272
+ for (let start = 0; start < cids.length; start += max) {
2273
+ const chunk = cids.slice(start, start + max);
2274
+ let size = 1;
2275
+ while (size < chunk.length)
2276
+ size *= 2;
2277
+ size = Math.min(size, max);
2278
+ chunks.push({
2279
+ placeholders: new Array(size).fill("?").join(","),
2280
+ params: [...chunk, ...new Array(size - chunk.length).fill(null)]
2281
+ });
2282
+ }
2283
+ return chunks;
2284
+ }
2285
+ _queryVoteCountsByCids(cids) {
2286
+ const result = new Map();
2287
+ for (const { placeholders, params } of this._cidChunks(cids)) {
2288
+ const rows = this._prepareCached(`SELECT commentCid,
2289
+ SUM(CASE WHEN vote = 1 THEN 1 ELSE 0 END) AS upvoteCount,
2290
+ SUM(CASE WHEN vote = -1 THEN 1 ELSE 0 END) AS downvoteCount
2291
+ FROM ${TABLES.VOTES} WHERE commentCid IN (${placeholders}) GROUP BY commentCid`).all(...params);
2292
+ for (const row of rows)
2293
+ result.set(row.commentCid, { upvoteCount: row.upvoteCount, downvoteCount: row.downvoteCount });
2294
+ }
2295
+ return result;
2296
+ }
2297
+ // replyCount and childCount of every parent in one recursive walk anchored on all of them: a descendant counts
2298
+ // when every comment on the path from the parent has a CommentUpdate, this community's address, and is neither
2299
+ // removed nor deleted; childCount is the level-1 part of the same walk.
2300
+ _queryReplyCountsByParentCids(parentCids) {
2301
+ const result = new Map();
2302
+ const { clause: addrClause, params: addrParams } = this._communityAddressClause("c");
2303
+ const passes = `${addrClause} AND (cu.removed IS NOT 1 AND cu.removed IS NOT TRUE) AND (d.deleted_flag IS NULL OR d.deleted_flag != 1)`;
2304
+ const deletedLookup = `(SELECT cid, json_extract(edit, '$.deleted') AS deleted_flag FROM ${TABLES.COMMENT_UPDATES})`;
2305
+ for (const { placeholders, params } of this._cidChunks(parentCids, addrParams.length * 2)) {
2306
+ const rows = this._prepareCached(`WITH RECURSIVE descendants(root, cid, level) AS (
2307
+ SELECT c.parentCid, c.cid, 1 FROM ${TABLES.COMMENTS} c
2308
+ INNER JOIN ${TABLES.COMMENT_UPDATES} cu ON c.cid = cu.cid
2309
+ LEFT JOIN ${deletedLookup} AS d ON c.cid = d.cid
2310
+ WHERE c.parentCid IN (${placeholders}) AND ${passes}
2311
+ UNION ALL
2312
+ SELECT desc_nodes.root, c.cid, desc_nodes.level + 1 FROM ${TABLES.COMMENTS} c
2313
+ INNER JOIN ${TABLES.COMMENT_UPDATES} cu ON c.cid = cu.cid
2314
+ LEFT JOIN ${deletedLookup} AS d ON c.cid = d.cid
2315
+ JOIN descendants desc_nodes ON c.parentCid = desc_nodes.cid
2316
+ WHERE ${passes}
2317
+ )
2318
+ SELECT root, COUNT(*) AS replyCount, SUM(CASE WHEN level = 1 THEN 1 ELSE 0 END) AS childCount
2319
+ FROM descendants GROUP BY root`).all(...params, ...addrParams, ...addrParams);
2320
+ for (const row of rows)
2321
+ result.set(row.root, { replyCount: row.replyCount, childCount: row.childCount });
2322
+ }
2323
+ return result;
2324
+ }
2325
+ // lastReplyTimestamp of every parent: the newest timestamp under it. A direct reply counts unless it is pending or
2326
+ // disapproved (a reply with no CommentUpdate yet counts); a deeper one also needs a CommentUpdate, this
2327
+ // community's address and to be neither removed nor deleted, and so does every comment on its path.
2328
+ _queryLastReplyTimestampsByParentCids(parentCids) {
2329
+ const result = new Map();
2330
+ const { clause: addrClause, params: addrParams } = this._communityAddressClause("c");
2331
+ for (const { placeholders, params } of this._cidChunks(parentCids, addrParams.length)) {
2332
+ const rows = this._prepareCached(`WITH RECURSIVE descendants(root, cid, timestamp) AS (
2333
+ SELECT c.parentCid, c.cid, c.timestamp FROM ${TABLES.COMMENTS} c
2334
+ LEFT JOIN ${TABLES.COMMENT_UPDATES} cu ON cu.cid = c.cid
2335
+ WHERE c.parentCid IN (${placeholders})
2336
+ AND COALESCE(cu.approved, 1) != 0
2337
+ AND (c.pendingApproval IS NULL OR c.pendingApproval != 1)
2338
+ UNION ALL
2339
+ SELECT desc_nodes.root, c.cid, c.timestamp FROM ${TABLES.COMMENTS} c
2340
+ INNER JOIN ${TABLES.COMMENT_UPDATES} cu ON c.cid = cu.cid
2341
+ LEFT JOIN (SELECT cid, json_extract(edit, '$.deleted') AS deleted_flag FROM ${TABLES.COMMENT_UPDATES}) AS d ON c.cid = d.cid
2342
+ JOIN descendants desc_nodes ON c.parentCid = desc_nodes.cid
2343
+ WHERE ${addrClause} AND (cu.removed IS NOT 1 AND cu.removed IS NOT TRUE) AND (d.deleted_flag IS NULL OR d.deleted_flag != 1)
2344
+ AND COALESCE(cu.approved, 1) != 0
2345
+ AND (c.pendingApproval IS NULL OR c.pendingApproval != 1)
2346
+ )
2347
+ SELECT root, MAX(timestamp) AS maxTimestamp FROM descendants GROUP BY root`).all(...params, ...addrParams);
2348
+ for (const row of rows)
2349
+ if (row.maxTimestamp !== null)
2350
+ result.set(row.root, row.maxTimestamp);
2351
+ }
2352
+ return result;
2353
+ }
2354
+ // lastChildCid of every parent: its newest direct reply that has a CommentUpdate and is not pending, disapproved,
2355
+ // removed or deleted (MAX(rowid) with the bare cid column: SQLite returns the cid of that row).
2356
+ _queryLastChildCidsByParentCids(parentCids) {
2357
+ const result = new Map();
2358
+ for (const { placeholders, params } of this._cidChunks(parentCids)) {
2359
+ const rows = this._prepareCached(`SELECT c.parentCid AS parentCid, c.cid AS cid, MAX(c.rowid) AS lastRowid FROM ${TABLES.COMMENTS} c
2179
2360
  INNER JOIN ${TABLES.COMMENT_UPDATES} cu ON cu.cid = c.cid
2180
2361
  LEFT JOIN (
2181
2362
  SELECT cid, json_extract(edit, '$.deleted') AS deleted_flag FROM ${TABLES.COMMENT_UPDATES}
2182
2363
  ) deleted_lookup ON deleted_lookup.cid = c.cid
2183
- WHERE c.parentCid = ?
2364
+ WHERE c.parentCid IN (${placeholders})
2184
2365
  AND (c.pendingApproval IS NULL OR c.pendingApproval != 1)
2185
2366
  AND COALESCE(cu.approved, 1) != 0
2186
2367
  AND (cu.removed IS NOT 1 AND cu.removed IS NOT TRUE)
2187
2368
  AND (deleted_lookup.deleted_flag IS NULL OR deleted_lookup.deleted_flag != 1)
2188
- ORDER BY c.rowid DESC
2189
- LIMIT 1`)
2190
- .get(comment.cid);
2191
- const lastReplyTimestamp = this.queryMaximumTimestampUnderComment(comment);
2192
- return { lastChildCid: lastChildCid?.cid, lastReplyTimestamp };
2369
+ GROUP BY c.parentCid`).all(...params);
2370
+ for (const row of rows)
2371
+ result.set(row.parentCid, row.cid);
2372
+ }
2373
+ return result;
2193
2374
  }
2194
- _queryIsCommentApproved(comment) {
2195
- const result = this._db
2196
- .prepare(`
2197
- SELECT json_extract(commentModeration, '$.approved') AS approved FROM ${TABLES.COMMENT_MODERATIONS}
2198
- WHERE commentCid = ? AND json_extract(commentModeration, '$.approved') IS NOT NULL ORDER BY rowid DESC LIMIT 1
2199
- `)
2200
- .get(comment.cid);
2201
- if (!result || result.approved === null)
2202
- return undefined;
2203
- return { approved: Boolean(result.approved) };
2375
+ // What the moderations of each comment say, newest first, one read for the batch: the latest non-null value per
2376
+ // field (reason, flairs, approved and the six flags), the same precedence as the per-field statements this replaces.
2377
+ _queryModerationSummariesByCids(cids) {
2378
+ const FLAG_NAMES = ["spoiler", "pinned", "locked", "archived", "removed", "nsfw"];
2379
+ const result = new Map();
2380
+ for (const { placeholders, params } of this._cidChunks(cids)) {
2381
+ const rows = this._prepareCached(`SELECT commentCid, commentModeration FROM ${TABLES.COMMENT_MODERATIONS} WHERE commentCid IN (${placeholders}) ORDER BY rowid DESC`).all(...params);
2382
+ for (const row of rows) {
2383
+ const moderation = JSON.parse(row.commentModeration);
2384
+ let summary = result.get(row.commentCid);
2385
+ if (!summary) {
2386
+ summary = { flags: {} };
2387
+ result.set(row.commentCid, summary);
2388
+ }
2389
+ if (summary.reason === undefined && typeof moderation.reason === "string")
2390
+ summary.reason = moderation.reason;
2391
+ if (summary.flairs === undefined && moderation.flairs !== null && moderation.flairs !== undefined)
2392
+ summary.flairs = moderation.flairs;
2393
+ if (summary.approved === undefined && moderation.approved !== null && moderation.approved !== undefined)
2394
+ summary.approved = Boolean(moderation.approved);
2395
+ for (const flag of FLAG_NAMES)
2396
+ if (summary.flags[flag] === undefined && moderation[flag] !== null && moderation[flag] !== undefined)
2397
+ summary.flags[flag] = Boolean(moderation[flag]);
2398
+ }
2399
+ }
2400
+ return result;
2204
2401
  }
2205
- _calculateCommentNumbers(cid) {
2206
- const commentRowMeta = this._db
2207
- .prepare(`SELECT rowid as rowid, depth, pendingApproval, number, postNumber FROM ${TABLES.COMMENTS} WHERE cid = ?`)
2208
- .get(cid);
2209
- if (!commentRowMeta)
2210
- throw Error(`Failed to query row metadata for comment ${cid}`);
2211
- if (commentRowMeta.pendingApproval === 1)
2212
- return {};
2213
- let commentNumber = typeof commentRowMeta.number === "number" && commentRowMeta.number > 0 ? commentRowMeta.number : undefined;
2214
- let postNumber = typeof commentRowMeta.postNumber === "number" && commentRowMeta.postNumber > 0 ? commentRowMeta.postNumber : undefined;
2215
- if (commentNumber === undefined || (commentRowMeta.depth === 0 && postNumber === undefined)) {
2216
- const existingNumbers = this._db
2217
- .prepare(`SELECT number, postNumber FROM ${TABLES.COMMENT_UPDATES} WHERE cid = ? LIMIT 1`)
2218
- .get(cid);
2219
- if (commentNumber === undefined && typeof existingNumbers?.number === "number" && existingNumbers.number > 0)
2220
- commentNumber = existingNumbers.number;
2221
- if (commentRowMeta.depth === 0 && postNumber === undefined) {
2222
- if (typeof existingNumbers?.postNumber === "number" && existingNumbers.postNumber > 0)
2223
- postNumber = existingNumbers.postNumber;
2402
+ // The latest edit each comment's own author published, one read for the batch
2403
+ _queryLatestAuthorEditsByCids(comments) {
2404
+ const result = new Map();
2405
+ const authorOf = new Map(comments.map((comment) => [comment.cid, comment.authorSignerAddress]));
2406
+ const commentEditFields = keys(CommentEditPubsubMessagePublicationSchema.shape);
2407
+ for (const { placeholders, params } of this._cidChunks(comments.map((comment) => comment.cid))) {
2408
+ const rows = this._prepareCached(`SELECT * FROM ${TABLES.COMMENT_EDITS} WHERE commentCid IN (${placeholders}) AND isAuthorEdit = 1 ORDER BY rowid DESC`).all(...params);
2409
+ for (const row of rows) {
2410
+ if (result.has(row.commentCid) || authorOf.get(row.commentCid) !== row.authorSignerAddress)
2411
+ continue;
2412
+ const parsed = this._spreadExtraProps(this._parseCommentEditsRow(row));
2413
+ const signedKeys = parsed.signature.signedPropertyNames;
2414
+ result.set(row.commentCid, pick(parsed, ["signature", ...signedKeys, ...commentEditFields]));
2224
2415
  }
2225
2416
  }
2226
- return {
2227
- ...(commentNumber !== undefined ? { number: commentNumber } : undefined),
2228
- ...(postNumber !== undefined ? { postNumber } : undefined)
2229
- };
2417
+ return result;
2230
2418
  }
2231
- queryCalculatedCommentUpdate(opts) {
2232
- const { comment, authorDomain } = opts;
2233
- const authorCommunity = this.queryCommunityAuthorForCommentUpdate({
2234
- authorSignerAddress: comment.authorSignerAddress,
2235
- commentCid: comment.cid,
2236
- authorDomain
2237
- });
2238
- const authorEdit = this.queryLatestAuthorEdit(comment.cid, comment.authorSignerAddress);
2239
- const commentUpdateCounts = this._queryCommentCounts(comment.cid);
2240
- const moderatorReason = this._queryLatestModeratorReason(comment);
2241
- const commentFlags = this.queryCommentFlagsSetByMod(comment.cid);
2242
- const commentModFlairs = this._queryModCommentFlairs(comment);
2243
- const lastChildAndLastReplyTimestamp = this._queryLastChildCidAndLastReplyTimestamp(comment);
2244
- const isThisCommentApproved = this._queryIsCommentApproved(comment);
2245
- const removedFromApproved = isThisCommentApproved?.approved === false ? { removed: true } : undefined; // automatically add removed:true if approved=false. Will be overridden if there's commentFlags.removed
2246
- const { number: commentNumber, postNumber } = this._calculateCommentNumbers(comment.cid);
2247
- if (!authorCommunity)
2248
- throw Error("Failed to query author.community in queryCalculatedCommentUpdate");
2249
- // Seed with challenge-supplied commentUpdate (lowest priority, per-field). Mod queries below
2250
- // overwrite individual keys (reason, flairs, flags, approved, ...) when the mod has actually
2251
- // published a moderation that set that key — challenge keys the mod never touched persist.
2252
- // Same logic applies one level deeper for author.community: challenge-supplied
2253
- // commentUpdate.author.community.<newKey> (e.g. countryCode) seeds underneath the computed
2254
- // authorCommunity, so community-computed keys (postScore, replyScore, ...) and mod-settable
2255
- // keys (flairs, banExpiresAt) always win. The validator forbids challenges from setting any
2256
- // schema-defined key on author.community, so the spread here only carries novel extras.
2257
- const challengeAuthorCommunity = comment.challengeCommentUpdate?.author
2258
- ?.community;
2259
- return {
2260
- ...(comment.challengeCommentUpdate ?? {}),
2261
- ...(removedFromApproved ? removedFromApproved : undefined),
2262
- cid: comment.cid,
2263
- ...(commentNumber !== undefined ? { number: commentNumber } : undefined),
2264
- ...(postNumber !== undefined ? { postNumber } : undefined),
2265
- ...commentUpdateCounts,
2266
- flairs: commentModFlairs?.flairs || authorEdit?.flairs || comment.challengeCommentUpdate?.flairs,
2267
- ...commentFlags,
2268
- // moderatorReason wins when present, else fall back to the challenge-supplied reason (if any).
2269
- reason: moderatorReason?.reason ?? comment.challengeCommentUpdate?.reason,
2270
- author: { community: { ...(challengeAuthorCommunity ?? {}), ...authorCommunity } },
2271
- ...lastChildAndLastReplyTimestamp,
2272
- ...(authorEdit ? { edit: authorEdit } : undefined),
2273
- ...(isThisCommentApproved ? { approved: isThisCommentApproved.approved } : undefined)
2274
- };
2419
+ // number / postNumber of each comment: the comments row's, else the numbers an earlier CommentUpdate carried
2420
+ _queryCommentNumbersByCids(cids) {
2421
+ const metaByCid = new Map();
2422
+ for (const { placeholders, params } of this._cidChunks(cids)) {
2423
+ const rows = this._prepareCached(`SELECT cid, depth, pendingApproval, number, postNumber FROM ${TABLES.COMMENTS} WHERE cid IN (${placeholders})`).all(...params);
2424
+ for (const row of rows)
2425
+ metaByCid.set(row.cid, row);
2426
+ }
2427
+ const result = new Map();
2428
+ const needStored = [];
2429
+ for (const cid of cids) {
2430
+ const meta = metaByCid.get(cid);
2431
+ if (!meta)
2432
+ throw Error(`Failed to query row metadata for comment ${cid}`);
2433
+ if (meta.pendingApproval === 1) {
2434
+ result.set(cid, {});
2435
+ continue;
2436
+ }
2437
+ const number = typeof meta.number === "number" && meta.number > 0 ? meta.number : undefined;
2438
+ const postNumber = typeof meta.postNumber === "number" && meta.postNumber > 0 ? meta.postNumber : undefined;
2439
+ result.set(cid, {
2440
+ ...(number !== undefined ? { number } : undefined),
2441
+ ...(postNumber !== undefined ? { postNumber } : undefined)
2442
+ });
2443
+ if (number === undefined || (meta.depth === 0 && postNumber === undefined))
2444
+ needStored.push(cid);
2445
+ }
2446
+ for (const { placeholders, params } of this._cidChunks(needStored)) {
2447
+ const rows = this._prepareCached(`SELECT cid, number, postNumber FROM ${TABLES.COMMENT_UPDATES} WHERE cid IN (${placeholders})`).all(...params);
2448
+ for (const row of rows) {
2449
+ const meta = metaByCid.get(row.cid);
2450
+ const numbers = result.get(row.cid);
2451
+ if (numbers.number === undefined && typeof row.number === "number" && row.number > 0)
2452
+ numbers.number = row.number;
2453
+ if (meta.depth === 0 && numbers.postNumber === undefined && typeof row.postNumber === "number" && row.postNumber > 0)
2454
+ numbers.postNumber = row.postNumber;
2455
+ }
2456
+ }
2457
+ return result;
2458
+ }
2459
+ // author.community of every comment: the karma of the address set the comment's author is (an alias comment
2460
+ // counts the alias alone, a plain one every alias of the author too) plus the mod edits (bans, flairs) targeting
2461
+ // any of them or the domain. Aggregated once per distinct (karma set, mod-edit set, domain) and memoised for the
2462
+ // cycle, as long as nothing was written to the publication tables since the memo was filled; the aggregates
2463
+ // themselves are one GROUP BY per chunk of addresses.
2464
+ _queryCommunityAuthorsForCommentUpdates(entries, memo) {
2465
+ if (memo.writeGeneration !== this._publicationWriteGeneration) {
2466
+ memo.authors.clear();
2467
+ memo.writeGeneration = this._publicationWriteGeneration;
2468
+ }
2469
+ const aliasByCid = this._queryPseudonymityAliasesByCommentCids(entries.map((entry) => entry.comment.cid));
2470
+ const plainAddresses = [
2471
+ ...new Set(entries.filter((entry) => !aliasByCid.has(entry.comment.cid)).map((entry) => entry.comment.authorSignerAddress))
2472
+ ];
2473
+ const expanded = this._queryAliasExpandedAddressSets(plainAddresses);
2474
+ const keyOf = (karma, modEdit, domain) => `${[...karma].sort().join(",")}|${[...modEdit].sort().join(",")}|${domain ?? ""}`;
2475
+ const needByCid = new Map();
2476
+ for (const { comment, authorDomain } of entries) {
2477
+ const alias = aliasByCid.get(comment.cid);
2478
+ if (alias) {
2479
+ // Karma for just this alias, but mod edits (bans/flairs) from both the alias and the original author
2480
+ const modEdit = [comment.authorSignerAddress];
2481
+ try {
2482
+ const originalAddress = getPKCAddressFromPublicKeySync(alias.originalAuthorPublicKey);
2483
+ if (originalAddress !== comment.authorSignerAddress)
2484
+ modEdit.push(originalAddress);
2485
+ }
2486
+ catch {
2487
+ // ignore malformed keys
2488
+ }
2489
+ const domain = alias.originalAuthorName || authorDomain;
2490
+ needByCid.set(comment.cid, {
2491
+ karma: [comment.authorSignerAddress],
2492
+ modEdit,
2493
+ domain,
2494
+ key: keyOf([comment.authorSignerAddress], modEdit, domain)
2495
+ });
2496
+ }
2497
+ else {
2498
+ const set = [...(expanded.get(comment.authorSignerAddress) ?? new Set([comment.authorSignerAddress]))];
2499
+ needByCid.set(comment.cid, { karma: set, modEdit: set, domain: authorDomain, key: keyOf(set, set, authorDomain) });
2500
+ }
2501
+ }
2502
+ const uncached = new Map();
2503
+ for (const need of needByCid.values())
2504
+ if (!memo.authors.has(need.key))
2505
+ uncached.set(need.key, need);
2506
+ if (uncached.size > 0) {
2507
+ const addresses = new Set();
2508
+ const domains = new Set();
2509
+ for (const need of uncached.values()) {
2510
+ for (const address of need.karma)
2511
+ addresses.add(address);
2512
+ for (const address of need.modEdit)
2513
+ addresses.add(address);
2514
+ if (need.domain)
2515
+ domains.add(need.domain);
2516
+ }
2517
+ const aggregates = this._queryAuthorAggregatesByAddresses([...addresses]);
2518
+ const modEditRows = this._queryAuthorModEditRows([...addresses], [...domains]);
2519
+ for (const need of uncached.values()) {
2520
+ const modEditSet = new Set(need.modEdit);
2521
+ const modAuthorEdits = modEditRows
2522
+ .filter((row) => (row.targetAuthorSignerAddress !== null && modEditSet.has(row.targetAuthorSignerAddress)) ||
2523
+ (need.domain !== undefined && row.targetAuthorDomain === need.domain))
2524
+ .map((row) => row.author);
2525
+ const banAuthor = modAuthorEdits.find((modEdit) => typeof modEdit?.banExpiresAt === "number");
2526
+ const authorFlairsByMod = modAuthorEdits.find((modEdit) => modEdit?.flairs);
2527
+ const modEdits = {};
2528
+ if (banAuthor?.banExpiresAt)
2529
+ modEdits.banExpiresAt = banAuthor.banExpiresAt;
2530
+ if (authorFlairsByMod?.flairs)
2531
+ modEdits.flairs = authorFlairsByMod.flairs;
2532
+ const karma = need.karma.map((address) => aggregates.get(address)).filter((aggregate) => aggregate !== undefined);
2533
+ if (karma.length === 0) {
2534
+ memo.authors.set(need.key, Object.keys(modEdits).length > 0 ? modEdits : undefined);
2535
+ continue;
2536
+ }
2537
+ const last = firstBy(karma, [(aggregate) => aggregate.lastRowid, "desc"]);
2538
+ const first = firstBy(karma, (aggregate) => aggregate.firstRowid);
2539
+ memo.authors.set(need.key, {
2540
+ postScore: sumBy(karma, (aggregate) => aggregate.postScore),
2541
+ replyScore: sumBy(karma, (aggregate) => aggregate.replyScore),
2542
+ lastCommentCid: last.lastCommentCid,
2543
+ ...modEdits,
2544
+ firstCommentTimestamp: first.firstCommentTimestamp
2545
+ });
2546
+ }
2547
+ }
2548
+ const result = new Map();
2549
+ for (const [cid, need] of needByCid)
2550
+ result.set(cid, memo.authors.get(need.key));
2551
+ return result;
2552
+ }
2553
+ _queryPseudonymityAliasesByCommentCids(cids) {
2554
+ const result = new Map();
2555
+ for (const { placeholders, params } of this._cidChunks(cids)) {
2556
+ const rows = this._prepareCached(`SELECT commentCid, aliasPrivateKey, originalAuthorPublicKey, originalAuthorName, mode, insertedAt FROM ${TABLES.PSEUDONYMITY_ALIASES} WHERE commentCid IN (${placeholders})`).all(...params);
2557
+ for (const row of rows)
2558
+ result.set(row.commentCid, row);
2559
+ }
2560
+ return result;
2561
+ }
2562
+ // What queryCommunityAuthor sums for a plain address: the address itself, every alias address of that original
2563
+ // author, and the original author of that alias address, batched over a chunk of addresses.
2564
+ _queryAliasExpandedAddressSets(addresses) {
2565
+ const result = new Map(addresses.map((address) => [address, new Set([address])]));
2566
+ for (const { placeholders, params } of this._cidChunks(addresses)) {
2567
+ const aliasRows = this._prepareCached(`SELECT alias.originalAuthorSignerAddress AS original, comments.authorSignerAddress AS aliasAddress
2568
+ FROM ${TABLES.PSEUDONYMITY_ALIASES} AS alias
2569
+ INNER JOIN ${TABLES.COMMENTS} AS comments ON comments.cid = alias.commentCid
2570
+ WHERE alias.originalAuthorSignerAddress IN (${placeholders})`).all(...params);
2571
+ for (const row of aliasRows)
2572
+ if (row.aliasAddress)
2573
+ result.get(row.original)?.add(row.aliasAddress);
2574
+ const originalRows = this._prepareCached(`SELECT comments.authorSignerAddress AS aliasAddress, alias.originalAuthorPublicKey AS originalAuthorPublicKey
2575
+ FROM ${TABLES.PSEUDONYMITY_ALIASES} AS alias
2576
+ INNER JOIN ${TABLES.COMMENTS} AS comments ON comments.cid = alias.commentCid
2577
+ WHERE comments.authorSignerAddress IN (${placeholders})`).all(...params);
2578
+ for (const row of originalRows)
2579
+ try {
2580
+ result.get(row.aliasAddress)?.add(getPKCAddressFromPublicKeySync(row.originalAuthorPublicKey));
2581
+ }
2582
+ catch {
2583
+ // ignore malformed keys
2584
+ }
2585
+ }
2586
+ return result;
2587
+ }
2588
+ // Per address: post and reply karma (votes on the author's comments) and the rowids of the author's first and
2589
+ // last comments, one GROUP BY per chunk of addresses
2590
+ _queryAuthorAggregatesByAddresses(addresses) {
2591
+ const partial = new Map();
2592
+ for (const { placeholders, params } of this._cidChunks(addresses)) {
2593
+ const rows = this._prepareCached(`SELECT c.authorSignerAddress AS address,
2594
+ COALESCE(SUM(CASE WHEN c.depth = 0 AND v.vote = 1 THEN 1 WHEN c.depth = 0 AND v.vote = -1 THEN -1 ELSE 0 END), 0) AS postScore,
2595
+ COALESCE(SUM(CASE WHEN c.depth > 0 AND v.vote = 1 THEN 1 WHEN c.depth > 0 AND v.vote = -1 THEN -1 ELSE 0 END), 0) AS replyScore,
2596
+ MAX(c.rowid) AS lastRowid, MIN(c.rowid) AS firstRowid
2597
+ FROM ${TABLES.COMMENTS} c LEFT JOIN ${TABLES.VOTES} v ON c.cid = v.commentCid
2598
+ WHERE c.authorSignerAddress IN (${placeholders}) GROUP BY c.authorSignerAddress`).all(...params);
2599
+ for (const row of rows)
2600
+ partial.set(row.address, row);
2601
+ }
2602
+ const rowids = [...new Set([...partial.values()].flatMap((aggregate) => [aggregate.lastRowid, aggregate.firstRowid]))];
2603
+ const byRowid = new Map();
2604
+ for (const { placeholders, params } of this._cidChunks(rowids.map(String))) {
2605
+ const rows = this._prepareCached(`SELECT rowid, cid, timestamp FROM ${TABLES.COMMENTS} WHERE rowid IN (${placeholders})`).all(...params);
2606
+ for (const row of rows)
2607
+ byRowid.set(row.rowid, row);
2608
+ }
2609
+ const result = new Map();
2610
+ for (const [address, aggregate] of partial) {
2611
+ const last = byRowid.get(aggregate.lastRowid);
2612
+ const first = byRowid.get(aggregate.firstRowid);
2613
+ if (!last)
2614
+ throw Error("Failed to query communityAuthor.lastCommentCid");
2615
+ if (!first)
2616
+ throw Error("Failed to query communityAuthor.firstCommentTimestamp");
2617
+ result.set(address, { ...aggregate, lastCommentCid: last.cid, firstCommentTimestamp: first.timestamp });
2618
+ }
2619
+ return result;
2620
+ }
2621
+ // Every mod edit of an author (bans, flairs) targeting any of the addresses or domains, newest first, with its
2622
+ // target so a caller keeps the ones aimed at one author. Read by target, so a ban survives a purged comment.
2623
+ _queryAuthorModEditRows(addresses, domains) {
2624
+ const rows = [];
2625
+ const select = `SELECT rowid, targetAuthorSignerAddress, targetAuthorDomain, json_extract(commentModeration, '$.author') AS commentAuthorJson
2626
+ FROM ${TABLES.COMMENT_MODERATIONS} WHERE json_extract(commentModeration, '$.author') IS NOT NULL AND`;
2627
+ for (const { placeholders, params } of this._cidChunks(addresses))
2628
+ rows.push(...this._prepareCached(`${select} targetAuthorSignerAddress IN (${placeholders})`).all(...params));
2629
+ for (const { placeholders, params } of this._cidChunks(domains))
2630
+ rows.push(...this._prepareCached(`${select} targetAuthorDomain IN (${placeholders})`).all(...params));
2631
+ const unique = uniqueBy(rows, (row) => row.rowid).sort((a, b) => b.rowid - a.rowid);
2632
+ return unique.map((row) => ({
2633
+ rowid: row.rowid,
2634
+ targetAuthorSignerAddress: row.targetAuthorSignerAddress,
2635
+ targetAuthorDomain: row.targetAuthorDomain,
2636
+ author: JSON.parse(row.commentAuthorJson)
2637
+ }));
2638
+ }
2639
+ // The stored updatedAt / postUpdatesBucket / replies of a batch of comments, what calculateNewCommentUpdate reads
2640
+ // before writing the next CommentUpdate (issue #352)
2641
+ queryCommentUpdateTimestampBucketRepliesByCids(cids) {
2642
+ const result = new Map();
2643
+ for (const { placeholders, params } of this._cidChunks(cids)) {
2644
+ const rows = this._prepareCached(`SELECT cid, updatedAt, postUpdatesBucket, replies FROM ${TABLES.COMMENT_UPDATES} WHERE cid IN (${placeholders})`).all(...params);
2645
+ for (const row of rows)
2646
+ result.set(row.cid, {
2647
+ updatedAt: row.updatedAt,
2648
+ postUpdatesBucket: row.postUpdatesBucket ?? undefined,
2649
+ replies: typeof row.replies === "string" ? JSON.parse(row.replies) : undefined
2650
+ });
2651
+ }
2652
+ return result;
2275
2653
  }
2276
2654
  queryLatestPostCid() {
2277
- return this._db
2278
- .prepare(`SELECT c.cid FROM ${TABLES.COMMENTS} c
2655
+ return this._prepareCached(`SELECT c.cid FROM ${TABLES.COMMENTS} c
2279
2656
  LEFT JOIN ${TABLES.COMMENT_UPDATES} cu ON cu.cid = c.cid
2280
2657
  WHERE c.depth = 0
2281
2658
  AND c.pendingApproval IS NOT 1
2282
2659
  AND COALESCE(cu.approved, 1) != 0
2283
2660
  ORDER BY c.rowid DESC
2284
- LIMIT 1`)
2285
- .get();
2661
+ LIMIT 1`).get();
2286
2662
  }
2287
2663
  queryLatestCommentCid() {
2288
- return this._db
2289
- .prepare(`SELECT c.cid FROM ${TABLES.COMMENTS} c
2664
+ return this._prepareCached(`SELECT c.cid FROM ${TABLES.COMMENTS} c
2290
2665
  LEFT JOIN ${TABLES.COMMENT_UPDATES} cu ON cu.cid = c.cid
2291
2666
  WHERE c.pendingApproval IS NOT 1
2292
2667
  AND COALESCE(cu.approved, 1) != 0
2293
2668
  ORDER BY c.rowid DESC
2294
- LIMIT 1`)
2295
- .get();
2296
- }
2297
- queryAllCommentsOrderedByIdAsc() {
2298
- const results = this._db.prepare(`SELECT * FROM ${TABLES.COMMENTS} ORDER BY rowid ASC`).all();
2299
- return results.map((r) => this._parseCommentsTableRow(r));
2669
+ LIMIT 1`).get();
2670
+ }
2671
+ // Every comment row in rowid order, one batch at a time, so a caller that walks the whole board holds a
2672
+ // batch instead of the board. Reading them all at once (what the repin on start used to do) was 2.7 GB of
2673
+ // rows on a 3.1M-comment board, an OOM before the publish loop had started (issue #359).
2674
+ //
2675
+ // Keyset paging on rowid rather than one held-open cursor: the caller awaits IPFS between batches, and an
2676
+ // open better-sqlite3 iterator would block every other statement on this connection for that whole time.
2677
+ // The rowid selected alongside the row is the cursor only: it is not part of the comments schema, so the
2678
+ // row parser drops it.
2679
+ *iterateAllCommentsOrderedByIdAsc(batchSize) {
2680
+ assert(Number.isInteger(batchSize) && batchSize > 0, `iterateAllCommentsOrderedByIdAsc needs a positive batch size`);
2681
+ let lastRowid = 0;
2682
+ while (true) {
2683
+ const rows = this._prepareCached(`SELECT rowid, * FROM ${TABLES.COMMENTS} WHERE rowid > ? ORDER BY rowid ASC LIMIT ?`).all(lastRowid, batchSize);
2684
+ if (rows.length === 0)
2685
+ return;
2686
+ lastRowid = rows[rows.length - 1].rowid;
2687
+ yield rows.map((row) => this._parseCommentsTableRow(row));
2688
+ }
2300
2689
  }
2301
2690
  queryAuthorModEdits(opts) {
2302
2691
  const { authorSignerAddresses, authorDomain } = opts;
@@ -2314,12 +2703,10 @@ export class DbHandler {
2314
2703
  params.push(authorDomain);
2315
2704
  }
2316
2705
  // Query directly by targetAuthorSignerAddress or targetAuthorDomain to find bans/flairs even for purged comments
2317
- const modAuthorEditsRaw = this._db
2318
- .prepare(`
2706
+ const modAuthorEditsRaw = this._prepareCached(`
2319
2707
  SELECT json_extract(commentModeration, '$.author') AS commentAuthorJson FROM ${TABLES.COMMENT_MODERATIONS}
2320
2708
  WHERE (${conditions.join(" OR ")}) AND json_extract(commentModeration, '$.author') IS NOT NULL ORDER BY rowid DESC
2321
- `)
2322
- .all(...params);
2709
+ `).all(...params);
2323
2710
  const modAuthorEdits = modAuthorEditsRaw.map((r) => JSON.parse(r.commentAuthorJson));
2324
2711
  const banAuthor = modAuthorEdits.find((modEdit) => typeof modEdit?.banExpiresAt === "number");
2325
2712
  const authorFlairsByMod = modAuthorEdits.find((modEdit) => modEdit?.flairs);
@@ -2370,37 +2757,24 @@ export class DbHandler {
2370
2757
  }
2371
2758
  queryCommunityAuthor(authorSignerAddress, authorDomain) {
2372
2759
  const authorSignerAddresses = new Set([authorSignerAddress]);
2373
- // If the provided address is the original signer, include all alias signer addresses for that author.
2374
- const aliasRowsForOriginal = this._db
2375
- .prepare(`
2376
- SELECT alias.commentCid, alias.originalAuthorPublicKey
2760
+ // If the provided address is the original signer, include all alias signer addresses for that author
2761
+ // (originalAuthorSignerAddress is derived from the original public key at insert and indexed, issue #351).
2762
+ const aliasSignerRows = this._prepareCached(`
2763
+ SELECT comments.authorSignerAddress
2377
2764
  FROM ${TABLES.PSEUDONYMITY_ALIASES} AS alias
2378
- `)
2379
- .all();
2380
- for (const aliasRow of aliasRowsForOriginal) {
2381
- try {
2382
- const originalAddress = getPKCAddressFromPublicKeySync(aliasRow.originalAuthorPublicKey);
2383
- if (originalAddress === authorSignerAddress) {
2384
- const commentRow = this._db
2385
- .prepare(`SELECT authorSignerAddress FROM ${TABLES.COMMENTS} WHERE cid = ?`)
2386
- .get(aliasRow.commentCid);
2387
- if (commentRow?.authorSignerAddress)
2388
- authorSignerAddresses.add(commentRow.authorSignerAddress);
2389
- }
2390
- }
2391
- catch {
2392
- // ignore malformed keys
2393
- }
2394
- }
2765
+ INNER JOIN ${TABLES.COMMENTS} AS comments ON comments.cid = alias.commentCid
2766
+ WHERE alias.originalAuthorSignerAddress = ?
2767
+ `).all(authorSignerAddress);
2768
+ for (const row of aliasSignerRows)
2769
+ if (row.authorSignerAddress)
2770
+ authorSignerAddresses.add(row.authorSignerAddress);
2395
2771
  // If the provided address is an alias, include the original signer address for that alias.
2396
- const aliasRowsForAliasAddress = this._db
2397
- .prepare(`
2772
+ const aliasRowsForAliasAddress = this._prepareCached(`
2398
2773
  SELECT alias.originalAuthorPublicKey
2399
2774
  FROM ${TABLES.PSEUDONYMITY_ALIASES} AS alias
2400
2775
  INNER JOIN ${TABLES.COMMENTS} AS comments ON comments.cid = alias.commentCid
2401
2776
  WHERE comments.authorSignerAddress = ?
2402
- `)
2403
- .all(authorSignerAddress);
2777
+ `).all(authorSignerAddress);
2404
2778
  for (const aliasRow of aliasRowsForAliasAddress) {
2405
2779
  try {
2406
2780
  const originalAddress = getPKCAddressFromPublicKeySync(aliasRow.originalAuthorPublicKey);
@@ -2418,15 +2792,13 @@ export class DbHandler {
2418
2792
  return undefined;
2419
2793
  const placeholders = karmaAddresses.map(() => "?").join(", ");
2420
2794
  const modAuthorEdits = this.queryAuthorModEdits({ authorSignerAddresses: modEditAddresses, authorDomain });
2421
- const authorCommentsData = this._db
2422
- .prepare(`
2795
+ const authorCommentsData = this._prepareCached(`
2423
2796
  SELECT c.depth, c.rowid, c.timestamp, c.cid,
2424
2797
  COALESCE(SUM(CASE WHEN v.vote = 1 THEN 1 ELSE 0 END), 0) as upvoteCount,
2425
2798
  COALESCE(SUM(CASE WHEN v.vote = -1 THEN 1 ELSE 0 END), 0) as downvoteCount
2426
2799
  FROM ${TABLES.COMMENTS} c LEFT JOIN ${TABLES.VOTES} v ON c.cid = v.commentCid
2427
2800
  WHERE c.authorSignerAddress IN (${placeholders}) GROUP BY c.cid
2428
- `)
2429
- .all(...karmaAddresses);
2801
+ `).all(...karmaAddresses);
2430
2802
  if (authorCommentsData.length === 0) {
2431
2803
  if (Object.keys(modAuthorEdits).length > 0) {
2432
2804
  return modAuthorEdits;
@@ -2456,30 +2828,6 @@ export class DbHandler {
2456
2828
  * We query karma for ONLY the alias address (no lookup to other aliases like queryCommunityAuthor does),
2457
2829
  * but include mod edits from both alias and original author.
2458
2830
  */
2459
- queryCommunityAuthorForCommentUpdate(opts) {
2460
- const { authorSignerAddress, commentCid, authorDomain } = opts;
2461
- // Check if this comment has a pseudonymity alias
2462
- const aliasRow = this.queryPseudonymityAliasByCommentCid(commentCid);
2463
- if (!aliasRow) {
2464
- // No pseudonymity mode - use standard aggregated karma
2465
- return this.queryCommunityAuthor(authorSignerAddress, authorDomain);
2466
- }
2467
- // Get original author's address for mod edits (bans/flairs are applied to original author)
2468
- const modEditAddresses = [authorSignerAddress];
2469
- try {
2470
- const originalAddress = getPKCAddressFromPublicKeySync(aliasRow.originalAuthorPublicKey);
2471
- if (originalAddress !== authorSignerAddress) {
2472
- modEditAddresses.push(originalAddress);
2473
- }
2474
- }
2475
- catch {
2476
- // ignore malformed keys
2477
- }
2478
- // For mod edits (bans/flairs), use the original author's domain if available
2479
- const modEditDomain = aliasRow.originalAuthorName || authorDomain;
2480
- // Query karma for just this alias, but mod edits from both alias and original
2481
- return this._queryCommunityAuthorByAddresses([authorSignerAddress], modEditAddresses, modEditDomain);
2482
- }
2483
2831
  _getAllDescendantCids(cid) {
2484
2832
  const allCids = [cid];
2485
2833
  const directChildren = this._db.prepare(`SELECT cid FROM ${TABLES.COMMENTS} WHERE parentCid = ?`).all(cid);
@@ -2489,6 +2837,7 @@ export class DbHandler {
2489
2837
  return allCids;
2490
2838
  }
2491
2839
  purgeComment(cid, isNestedCall = false) {
2840
+ this._publicationWriteGeneration++;
2492
2841
  const log = Logger("pkc-js:local-community:db-handler:purgeComment");
2493
2842
  const purgedRecords = [];
2494
2843
  const detachedPageCids = [];
@@ -2560,10 +2909,10 @@ export class DbHandler {
2560
2909
  const allCommentsToUpdate = [...allAffectedAuthorCids, ...Array.from(commentsToForceUpdate)];
2561
2910
  // Force update on a random comment to ensure IPNS update triggers even if no other comments need updating
2562
2911
  // Make sure we don't select a comment that's being purged
2563
- const placeholders = allCidsToBeDeleted.map(() => "?").join(",");
2912
+ // The purged set as one JSON parameter: a purged post can have more descendants than a statement may bind
2564
2913
  const randomComment = this._db
2565
- .prepare(`SELECT cid FROM ${TABLES.COMMENTS} WHERE cid NOT IN (${placeholders}) ORDER BY RANDOM() LIMIT 1`)
2566
- .get(...allCidsToBeDeleted);
2914
+ .prepare(`SELECT cid FROM ${TABLES.COMMENTS} WHERE cid NOT IN (SELECT value FROM json_each(?)) ORDER BY RANDOM() LIMIT 1`)
2915
+ .get(JSON.stringify(allCidsToBeDeleted));
2567
2916
  if (randomComment) {
2568
2917
  allCommentsToUpdate.push(randomComment.cid);
2569
2918
  log(`Forcing update on random comment ${randomComment.cid} to ensure IPNS update after purge`);
@@ -2701,11 +3050,12 @@ export class DbHandler {
2701
3050
  const communityDbPath = path.join(this._community._pkc.dataPath, "communities", this._community.address);
2702
3051
  return fs.existsSync(communityDbPath);
2703
3052
  }
3053
+ // Batched under the SQLite variable cap: a cycle that updates every comment (after a migration, or a
3054
+ // settings.pages edit) passes more cids than one statement may bind (issue #351)
2704
3055
  markCommentsAsPublishedToPostUpdates(commentCids) {
2705
3056
  if (commentCids.length === 0)
2706
3057
  return;
2707
- const stmt = this._db.prepare(`UPDATE ${TABLES.COMMENT_UPDATES} SET publishedToPostUpdatesMFS = 1 WHERE cid IN (${commentCids.map(() => "?").join(",")})`);
2708
- stmt.run(...commentCids);
3058
+ this._runForEachCidBatch(commentCids, (placeholders) => `UPDATE ${TABLES.COMMENT_UPDATES} SET publishedToPostUpdatesMFS = 1 WHERE cid IN (${placeholders})`);
2709
3059
  }
2710
3060
  forceUpdateOnAllComments() {
2711
3061
  this._db.prepare(`UPDATE ${TABLES.COMMENT_UPDATES} SET publishedToPostUpdatesMFS = 0`).run();
@@ -2713,92 +3063,64 @@ export class DbHandler {
2713
3063
  forceUpdateOnAllCommentsWithCid(commentCids) {
2714
3064
  if (commentCids.length === 0)
2715
3065
  return;
2716
- this._db
2717
- .prepare(`UPDATE ${TABLES.COMMENT_UPDATES} SET publishedToPostUpdatesMFS = 0 WHERE cid IN (${commentCids.map(() => "?").join(",")})`)
2718
- .run(...commentCids);
2719
- }
2720
- queryAllCommentCidsAndTheirReplies() {
2721
- const log = Logger("pkc-js:local-community:db-handler:queryAllCidsUnderThisCommunity");
2722
- const rows = this._db
2723
- .prepare(`SELECT c.cid AS cid, cu.replies AS replies
2724
- FROM ${TABLES.COMMENTS} c
2725
- LEFT JOIN ${TABLES.COMMENT_UPDATES} cu ON c.cid = cu.cid`)
2726
- .all();
2727
- return rows.map((row) => {
2728
- const allPageCids = [];
2729
- if (typeof row.replies === "string" && row.replies.length > 0) {
2730
- try {
2731
- const parsed = JSON.parse(row.replies);
2732
- for (const sortEntry of Object.values(parsed)) {
2733
- if (sortEntry?.allPageCids)
2734
- allPageCids.push(...sortEntry.allPageCids);
2735
- }
2736
- }
2737
- catch (e) {
2738
- log.error(`Failed to parse replies JSON for comment ${row.cid} when collecting cids`, e);
2739
- }
3066
+ this._runForEachCidBatch(commentCids, (placeholders) => `UPDATE ${TABLES.COMMENT_UPDATES} SET publishedToPostUpdatesMFS = 0 WHERE cid IN (${placeholders})`);
3067
+ }
3068
+ // The write form of _forEachCidBatch: one statement per batch, in one transaction
3069
+ _runForEachCidBatch(cids, queryFor) {
3070
+ const BATCH = 4096;
3071
+ const run = this._db.transaction(() => {
3072
+ for (let start = 0; start < cids.length; start += BATCH) {
3073
+ const batch = cids.slice(start, start + BATCH);
3074
+ this._prepareCached(queryFor(new Array(batch.length).fill("?").join(","))).run(...batch);
2740
3075
  }
2741
- return { cid: row.cid, allPageCids };
2742
3076
  });
3077
+ run();
3078
+ }
3079
+ // Every comment's cid and the cids of its reply pages, in rowid slices: what delete() unpins. One slice is
3080
+ // held at a time, so deleting a board of a million comments costs a slice of memory, not the board
3081
+ // (issue #389: the all-at-once array and Set ran a 4 GB heap out at 1M).
3082
+ *iterateCommentCidsAndTheirPageCids(sliceSize) {
3083
+ const log = Logger("pkc-js:local-community:db-handler:iterateCommentCidsAndTheirPageCids");
3084
+ const statement = this._db.prepare(`SELECT c.rowid AS rowid, c.cid AS cid, cu.replies AS replies
3085
+ FROM ${TABLES.COMMENTS} c
3086
+ LEFT JOIN ${TABLES.COMMENT_UPDATES} cu ON c.cid = cu.cid
3087
+ WHERE c.rowid > ?
3088
+ ORDER BY c.rowid ASC
3089
+ LIMIT ?`);
3090
+ let afterRowid = 0;
3091
+ while (true) {
3092
+ const rows = statement.all(afterRowid, sliceSize);
3093
+ if (rows.length === 0)
3094
+ return;
3095
+ afterRowid = rows[rows.length - 1].rowid;
3096
+ yield rows.map((row) => {
3097
+ const allPageCids = [];
3098
+ if (typeof row.replies === "string" && row.replies.length > 0) {
3099
+ try {
3100
+ const parsed = JSON.parse(row.replies);
3101
+ for (const sortEntry of Object.values(parsed)) {
3102
+ if (sortEntry?.allPageCids)
3103
+ allPageCids.push(...sortEntry.allPageCids);
3104
+ }
3105
+ }
3106
+ catch (e) {
3107
+ log.error(`Failed to parse replies JSON for comment ${row.cid} when collecting cids`, e);
3108
+ }
3109
+ }
3110
+ return { cid: row.cid, allPageCids };
3111
+ });
3112
+ }
2743
3113
  }
2744
- queryPostsWithActiveScore(pageOptions) {
2745
- const { clause: rootAddrClause, params: rootAddrParams } = this._communityAddressClauseNamed("p", "asRoot");
2746
- const { clause: descAddrClause, params: descAddrParams } = this._communityAddressClauseNamed("c", "asDesc");
2747
- const { clause: postsAddrClause, params: postsAddrParams } = this._communityAddressClauseNamed("c", "asPosts");
2748
- const activeScoreRootConditions = ["p.depth = 0"];
2749
- if (pageOptions.excludeCommentsWithDifferentCommunityAddress)
2750
- activeScoreRootConditions.push(rootAddrClause);
2751
- if (pageOptions.excludeCommentPendingApproval)
2752
- activeScoreRootConditions.push(this._pendingApprovalClause("p"));
2753
- if (pageOptions.excludeRemovedComments)
2754
- activeScoreRootConditions.push(this._removedClause("cu_root"));
2755
- if (pageOptions.excludeDeletedComments)
2756
- activeScoreRootConditions.push(this._deletedFromUpdatesClause("cu_root"));
2757
- if (pageOptions.excludeCommentWithApprovedFalse)
2758
- activeScoreRootConditions.push(this._approvedClause("cu_root"));
2759
- const activeScoreRootWhere = `WHERE ${activeScoreRootConditions.join(" AND ")}`;
2760
- const activeScoreDescendantConditions = [];
2761
- if (pageOptions.excludeCommentsWithDifferentCommunityAddress)
2762
- activeScoreDescendantConditions.push(descAddrClause);
2763
- if (pageOptions.excludeCommentPendingApproval)
2764
- activeScoreDescendantConditions.push(this._pendingApprovalClause("c"));
2765
- if (pageOptions.excludeRemovedComments)
2766
- activeScoreDescendantConditions.push(this._removedClause("cu"));
2767
- if (pageOptions.excludeDeletedComments)
2768
- activeScoreDescendantConditions.push(this._deletedFromLookupClause("deleted_lookup"));
2769
- if (pageOptions.excludeCommentWithApprovedFalse)
2770
- activeScoreDescendantConditions.push(this._approvedClause("cu"));
2771
- const activeScoreDescendantWhere = activeScoreDescendantConditions.length > 0 ? `WHERE ${activeScoreDescendantConditions.join(" AND ")}` : "";
2772
- const activeScoresCte = `
2773
- WITH RECURSIVE descendants AS (
2774
- SELECT p.cid AS post_cid, p.cid AS current_cid, p.timestamp
2775
- FROM ${TABLES.COMMENTS} p
2776
- INNER JOIN ${TABLES.COMMENT_UPDATES} cu_root ON p.cid = cu_root.cid
2777
- ${activeScoreRootWhere}
2778
- UNION ALL
2779
- SELECT desc_tree.post_cid, c.cid AS current_cid, c.timestamp FROM ${TABLES.COMMENTS} c
2780
- INNER JOIN ${TABLES.COMMENT_UPDATES} cu ON c.cid = cu.cid
2781
- LEFT JOIN (SELECT cid, json_extract(edit, '$.deleted') AS deleted_flag FROM ${TABLES.COMMENT_UPDATES}) AS deleted_lookup ON c.cid = deleted_lookup.cid
2782
- JOIN descendants desc_tree ON c.parentCid = desc_tree.current_cid
2783
- ${activeScoreDescendantWhere}
2784
- ) SELECT post_cid, MAX(timestamp) as active_score FROM descendants GROUP BY post_cid
2785
- `;
2786
- const commentUpdateCols = keys(pageOptions.commentUpdateFieldsToExclude
2787
- ? omit(CommentUpdateSchema.shape, pageOptions.commentUpdateFieldsToExclude)
2788
- : CommentUpdateSchema.shape);
2789
- const commentUpdateSelects = commentUpdateCols.map((col) => `cu.${col} AS commentUpdate_${col}`);
2790
- const commentIpfsCols = [...keys(CommentIpfsSchema.shape), "extraProps"];
2791
- const commentIpfsSelects = commentIpfsCols.map((col) => `c.${col} AS commentIpfs_${col}`);
2792
- let postsQueryStr = `
2793
- SELECT ${commentIpfsSelects.join(", ")}, ${commentUpdateSelects.join(", ")}, asc_scores.active_score
2794
- FROM ${TABLES.COMMENTS} c INNER JOIN ${TABLES.COMMENT_UPDATES} cu ON c.cid = cu.cid
2795
- INNER JOIN (${activeScoresCte}) AS asc_scores ON c.cid = asc_scores.post_cid
2796
- `;
2797
- const params = { ...rootAddrParams, ...descAddrParams };
3114
+ // Every post (depth 0) that survives the exclusion options, with its DB-format replies (CID refs) still attached;
3115
+ // callers resolve those with resolveRepliesCidRefsForEntries. Scoring is the page sort's job (issue #73).
3116
+ queryPosts(pageOptions) {
3117
+ const mapper = this._pageEntryMapper(pageOptions.commentUpdateFieldsToExclude);
3118
+ const params = {};
2798
3119
  const postsWhereClauses = ["c.depth = 0"];
2799
3120
  if (pageOptions.excludeCommentsWithDifferentCommunityAddress) {
2800
- postsWhereClauses.push(postsAddrClause);
2801
- Object.assign(params, postsAddrParams);
3121
+ const { clause, params: addrParams } = this._communityAddressClauseNamed("c", "asPosts");
3122
+ postsWhereClauses.push(clause);
3123
+ Object.assign(params, addrParams);
2802
3124
  }
2803
3125
  if (pageOptions.excludeRemovedComments)
2804
3126
  postsWhereClauses.push(this._removedClause("cu"));
@@ -2808,16 +3130,111 @@ export class DbHandler {
2808
3130
  postsWhereClauses.push(this._pendingApprovalClause("c"));
2809
3131
  if (pageOptions.excludeCommentWithApprovedFalse)
2810
3132
  postsWhereClauses.push(this._approvedClause("cu"));
2811
- postsQueryStr += ` WHERE ${postsWhereClauses.join(" AND ")}`;
2812
- const postsRaw = this._db.prepare(postsQueryStr).all(params);
2813
- return postsRaw.map((postRaw) => {
2814
- const { comment, commentUpdate } = this._parsePrefixedComment(postRaw);
2815
- return {
2816
- comment,
2817
- commentUpdate,
2818
- activeScore: postRaw.active_score
3133
+ const postsQueryStr = `
3134
+ SELECT ${mapper.selectList((col) => `c.${col}`, (col) => `cu.${col}`)}
3135
+ FROM ${TABLES.COMMENTS} c INNER JOIN ${TABLES.COMMENT_UPDATES} cu ON c.cid = cu.cid
3136
+ WHERE ${postsWhereClauses.join(" AND ")}
3137
+ `;
3138
+ const rows = this._prepareCached(postsQueryStr).raw(true).all(params);
3139
+ return rows.map(mapper.map);
3140
+ }
3141
+ // queryPosts plus the built-in active score: the bump time the CommentUpdate carries (max of the post's own
3142
+ // timestamp and lastReplyTimestamp), the same number the active page sort file and a client compute. Kept as one
3143
+ // entry point for "posts with their bump score" for the migration tests.
3144
+ queryPostsWithActiveScore(pageOptions) {
3145
+ return this.queryPosts(pageOptions).map((post) => ({
3146
+ ...post,
3147
+ activeScore: Math.max(post.comment.timestamp, post.commentUpdate.lastReplyTimestamp ?? 0)
3148
+ }));
3149
+ }
3150
+ // Every reply (depth > 0) in the community, or under one post, with no exclusions applied: the one reply set a
3151
+ // generation loads for its requireReplies sorts, each of which then filters it with its own exclusion options
3152
+ // through applyPageSortExclusions (the same filter a client applies) and slices each comment's subtree from it
3153
+ // (createDescendantsLookup). Lean on purpose (PageSortReplyEntry): a fixed column list mapped by hand, no
3154
+ // signature, no nested pages, no schema row parser, since a board can hold a million replies and this runs per
3155
+ // generation. A pending-approval reply is marked on its CommentUpdate the way the mod queue marks one, so the
3156
+ // filter can see it; the list never reaches a page.
3157
+ queryAllRepliesForPageSort(opts = {}) {
3158
+ // A list of posts streams in batches under the SQLite variable cap: what a post sort with requireReplies
3159
+ // scores over, one batch of posts' subtrees at a time (issue #351)
3160
+ if (opts.postCids) {
3161
+ const entries = [];
3162
+ const BATCH = 4096;
3163
+ for (let start = 0; start < opts.postCids.length; start += BATCH) {
3164
+ const batch = opts.postCids.slice(start, start + BATCH);
3165
+ entries.push(...this._queryRepliesForPageSort(`c.postCid IN (${batch.map(() => "?").join(",")})`, batch));
3166
+ }
3167
+ return entries;
3168
+ }
3169
+ if (opts.postCid)
3170
+ return this._queryRepliesForPageSort("c.postCid = ?", [opts.postCid]);
3171
+ return this._queryRepliesForPageSort("1 = 1", []);
3172
+ }
3173
+ _queryRepliesForPageSort(scopeClause, params) {
3174
+ const whereClauses = ["c.depth > 0", scopeClause];
3175
+ const query = `
3176
+ SELECT c.cid, c.parentCid, c.postCid, c.depth, c.timestamp, c.content, c.title, c.link, c.author, c.communityPublicKey,
3177
+ c.communityName, c.nsfw, c.spoiler, c.flairs, c.pendingApproval,
3178
+ cu.upvoteCount, cu.downvoteCount, cu.replyCount, cu.childCount, cu.updatedAt, cu.lastReplyTimestamp, cu.pinned,
3179
+ cu.locked, cu.removed, cu.approved, cu.nsfw AS cuNsfw, cu.spoiler AS cuSpoiler, cu.flairs AS cuFlairs,
3180
+ json_extract(cu.edit, '$.deleted') AS editDeleted
3181
+ FROM ${TABLES.COMMENTS} c INNER JOIN ${TABLES.COMMENT_UPDATES} cu ON c.cid = cu.cid
3182
+ WHERE ${whereClauses.join(" AND ")}
3183
+ `;
3184
+ const rows = this._prepareCached(query)
3185
+ .raw(true)
3186
+ .all(...params);
3187
+ const entries = new Array(rows.length);
3188
+ for (let i = 0; i < rows.length; i++) {
3189
+ const [cid, parentCid, postCid, depth, timestamp, content, title, link, author, communityPublicKey, communityName, nsfw, spoiler, flairs, pendingApproval, upvoteCount, downvoteCount, replyCount, childCount, updatedAt, lastReplyTimestamp, pinned, locked, removed, approved, cuNsfw, cuSpoiler, cuFlairs, editDeleted] = rows[i];
3190
+ const comment = { parentCid, postCid, depth, timestamp, author: JSON.parse(author) };
3191
+ if (content !== null)
3192
+ comment.content = content;
3193
+ if (title !== null)
3194
+ comment.title = title;
3195
+ if (link !== null)
3196
+ comment.link = link;
3197
+ if (communityPublicKey !== null)
3198
+ comment.communityPublicKey = communityPublicKey;
3199
+ if (communityName !== null)
3200
+ comment.communityName = communityName;
3201
+ if (nsfw !== null)
3202
+ comment.nsfw = Boolean(nsfw);
3203
+ if (spoiler !== null)
3204
+ comment.spoiler = Boolean(spoiler);
3205
+ if (flairs !== null)
3206
+ comment.flairs = JSON.parse(flairs);
3207
+ const commentUpdate = {
3208
+ cid,
3209
+ upvoteCount,
3210
+ downvoteCount,
3211
+ replyCount,
3212
+ childCount,
3213
+ updatedAt
2819
3214
  };
2820
- });
3215
+ if (lastReplyTimestamp !== null)
3216
+ commentUpdate.lastReplyTimestamp = lastReplyTimestamp;
3217
+ if (pinned !== null)
3218
+ commentUpdate.pinned = Boolean(pinned);
3219
+ if (locked !== null)
3220
+ commentUpdate.locked = Boolean(locked);
3221
+ if (removed !== null)
3222
+ commentUpdate.removed = Boolean(removed);
3223
+ if (approved !== null)
3224
+ commentUpdate.approved = Boolean(approved);
3225
+ if (cuNsfw !== null)
3226
+ commentUpdate.nsfw = Boolean(cuNsfw);
3227
+ if (cuSpoiler !== null)
3228
+ commentUpdate.spoiler = Boolean(cuSpoiler);
3229
+ if (cuFlairs !== null)
3230
+ commentUpdate.flairs = JSON.parse(cuFlairs);
3231
+ if (editDeleted)
3232
+ commentUpdate.edit = { deleted: true };
3233
+ if (pendingApproval)
3234
+ commentUpdate.pendingApproval = true;
3235
+ entries[i] = { comment, commentUpdate };
3236
+ }
3237
+ return entries;
2821
3238
  }
2822
3239
  _processRecordsForDbBeforeInsert(records) {
2823
3240
  return records.map((record) => {