@rubytech/create-maxy-code 0.1.488 → 0.1.490

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 (282) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-22-task-1899-reconcile-report-only.md +748 -0
  3. package/payload/platform/docs/superpowers/plans/2026-07-22-task-1910-two-way-portal-exchange.md +1716 -0
  4. package/payload/platform/docs/superpowers/specs/2026-07-22-task-1899-reconcile-report-only-design.md +121 -0
  5. package/payload/platform/docs/superpowers/specs/2026-07-22-task-1910-two-way-portal-exchange-design.md +103 -0
  6. package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cypher-parser.test.js +51 -0
  7. package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cypher-parser.test.js.map +1 -1
  8. package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.d.ts +2 -2
  9. package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.d.ts.map +1 -1
  10. package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.js +20 -4
  11. package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.js.map +1 -1
  12. package/payload/platform/lib/graph-mcp/src/__tests__/schema-cypher-parser.test.ts +55 -0
  13. package/payload/platform/lib/graph-mcp/src/schema-cypher-parser.ts +19 -4
  14. package/payload/platform/lib/graph-style/dist/index.d.ts +24 -0
  15. package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
  16. package/payload/platform/lib/graph-style/dist/index.js +25 -1
  17. package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
  18. package/payload/platform/lib/graph-style/src/__tests__/additional-base-labels.test.ts +22 -0
  19. package/payload/platform/lib/graph-style/src/index.ts +27 -0
  20. package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js +15 -0
  21. package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js.map +1 -1
  22. package/payload/platform/lib/storage-broker/dist/audit.d.ts +34 -1
  23. package/payload/platform/lib/storage-broker/dist/audit.d.ts.map +1 -1
  24. package/payload/platform/lib/storage-broker/dist/audit.js +28 -4
  25. package/payload/platform/lib/storage-broker/dist/audit.js.map +1 -1
  26. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +13 -0
  27. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
  28. package/payload/platform/lib/storage-broker/dist/cf-exec.js +22 -0
  29. package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
  30. package/payload/platform/lib/storage-broker/src/__tests__/audit.test.ts +21 -0
  31. package/payload/platform/lib/storage-broker/src/audit.ts +67 -3
  32. package/payload/platform/lib/storage-broker/src/cf-exec.ts +48 -0
  33. package/payload/platform/neo4j/schema.cypher +105 -0
  34. package/payload/platform/plugins/admin/skills/agent-builder/references/agent-pattern.md +34 -5
  35. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +8 -2
  36. package/payload/platform/plugins/cloudflare/PLUGIN.md +22 -2
  37. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +161 -41
  38. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-delete-withdraw.test.ts +95 -0
  39. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +75 -13
  40. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +216 -16
  41. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +38 -2
  42. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-moved-download.test.ts +83 -0
  43. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-schema-shape.test.ts +48 -0
  44. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts +62 -0
  45. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-target.test.ts +103 -0
  46. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-upload-multipart.test.ts +211 -0
  47. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-upload-target.test.ts +168 -0
  48. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-uploads-listing.test.ts +138 -0
  49. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +179 -0
  50. package/payload/platform/plugins/cloudflare/mcp/__tests__/upload-route.test.ts +14 -13
  51. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +87 -13
  52. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +87 -9
  53. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs +74 -0
  54. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/target.ts +91 -0
  55. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +23 -0
  56. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/delete.ts +45 -0
  57. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +72 -5
  58. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +80 -13
  59. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts +358 -16
  60. package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +12 -1
  61. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +45 -0
  62. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +258 -21
  63. package/payload/platform/plugins/docs/references/calendar-booking.md +5 -1
  64. package/payload/platform/plugins/docs/references/internals.md +2 -0
  65. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.js +61 -31
  66. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.js.map +1 -1
  67. package/payload/platform/plugins/scheduling/PLUGIN.md +1 -1
  68. package/payload/platform/plugins/scheduling/mcp/dist/index.js +2 -2
  69. package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
  70. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js +70 -5
  71. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js.map +1 -1
  72. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/getUserTimezone.test.js +31 -6
  73. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/getUserTimezone.test.js.map +1 -1
  74. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts +31 -4
  75. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts.map +1 -1
  76. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js +18 -7
  77. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js.map +1 -1
  78. package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.d.ts +15 -6
  79. package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.d.ts.map +1 -1
  80. package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.js +24 -11
  81. package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.js.map +1 -1
  82. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +5 -122
  83. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  84. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js +84 -5
  85. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js.map +1 -1
  86. package/payload/platform/scripts/__tests__/quarantine-restore.test.sh +143 -0
  87. package/payload/platform/scripts/quarantine-restore.mjs +175 -0
  88. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts +24 -7
  89. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts.map +1 -1
  90. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js +52 -74
  91. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js.map +1 -1
  92. package/payload/platform/services/claude-session-manager/dist/index.js +11 -8
  93. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  94. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +32 -0
  95. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  96. package/payload/platform/services/whatsapp-channel/dist/notification.js +32 -2
  97. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  98. package/payload/platform/services/whatsapp-channel/dist/targets.d.ts +6 -1
  99. package/payload/platform/services/whatsapp-channel/dist/targets.d.ts.map +1 -1
  100. package/payload/platform/services/whatsapp-channel/dist/targets.js +6 -0
  101. package/payload/platform/services/whatsapp-channel/dist/targets.js.map +1 -1
  102. package/payload/platform/templates/account-schema/SCHEMA.md +7 -4
  103. package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
  104. package/payload/platform/templates/agents/passive/IDENTITY.md +6 -0
  105. package/payload/platform/templates/agents/public/IDENTITY.md +6 -0
  106. package/payload/platform/templates/specialists/agents/citation-auditor.md +6 -0
  107. package/payload/platform/templates/specialists/agents/coding-assistant.md +6 -0
  108. package/payload/platform/templates/specialists/agents/compiled-truth-rewriter.md +6 -0
  109. package/payload/platform/templates/specialists/agents/content-producer.md +6 -0
  110. package/payload/platform/templates/specialists/agents/data-manager.md +6 -0
  111. package/payload/platform/templates/specialists/agents/database-operator.md +6 -0
  112. package/payload/platform/templates/specialists/agents/librarian.md +6 -0
  113. package/payload/platform/templates/specialists/agents/personal-assistant.md +6 -0
  114. package/payload/platform/templates/specialists/agents/project-manager.md +6 -0
  115. package/payload/platform/templates/specialists/agents/public-session-reviewer.md +6 -0
  116. package/payload/platform/templates/specialists/agents/research-assistant.md +7 -1
  117. package/payload/platform/templates/specialists/agents/typed-edge-classifier.md +6 -0
  118. package/payload/premium-plugins/writer-craft/agents/writer-craft--manuscript-reviewer.md +6 -0
  119. package/payload/server/{chunk-REWXLAPC.js → chunk-KZFE3MOY.js} +52 -4
  120. package/payload/server/public/activity.html +5 -6
  121. package/payload/server/public/assets/AdminLoginScreens-DgVFsHtd.js +1 -0
  122. package/payload/server/public/assets/AdminShell-BwNqwb4k.js +2 -0
  123. package/payload/server/public/assets/activity-2DL2DwLr.js +1 -0
  124. package/payload/server/public/assets/admin-COe0dT2U.js +1 -0
  125. package/payload/server/public/assets/{arc-D-QzyndF.js → arc-CFMJyMm-.js} +1 -1
  126. package/payload/server/public/assets/architecture-YZFGNWBL-Cypt5qHL.js +1 -0
  127. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-W7f0Us4S.js → architectureDiagram-Q4EWVU46-CiSSgdL8.js} +1 -1
  128. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-CfPS4FXg.js → blockDiagram-DXYQGD6D-_sXtIW5c.js} +1 -1
  129. package/payload/server/public/assets/{bot-DnuyRsLS.js → bot-CpBuDQlF.js} +1 -1
  130. package/payload/server/public/assets/{browser-q4EFqKvR.js → browser-CSApuZrv.js} +1 -1
  131. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-Dq60cLVI.js → c4Diagram-AHTNJAMY-Bh7OAMXs.js} +1 -1
  132. package/payload/server/public/assets/{calendar-C-q9kWmA.js → calendar-DlvwZnS7.js} +1 -1
  133. package/payload/server/public/assets/channel-BMyXE4zc.js +1 -0
  134. package/payload/server/public/assets/chat-DWvYv9jq.js +1 -0
  135. package/payload/server/public/assets/chevron-down-Dnuuw6Q2.js +1 -0
  136. package/payload/server/public/assets/chevron-left-CC4zmWOr.js +1 -0
  137. package/payload/server/public/assets/chevron-right-CG_lHTx_.js +1 -0
  138. package/payload/server/public/assets/{chunk-2KRD3SAO-CXDjuDN0.js → chunk-2KRD3SAO-D4fktAwW.js} +1 -1
  139. package/payload/server/public/assets/{chunk-336JU56O-926ox0tj.js → chunk-336JU56O-CcQoKH2G.js} +2 -2
  140. package/payload/server/public/assets/chunk-426QAEUC-Xu6JKP4d.js +1 -0
  141. package/payload/server/public/assets/{chunk-4BX2VUAB-C2nrPCfN.js → chunk-4BX2VUAB-C13Wz3zS.js} +1 -1
  142. package/payload/server/public/assets/{chunk-4TB4RGXK-CuW3FOR6.js → chunk-4TB4RGXK-BL48D7gN.js} +1 -1
  143. package/payload/server/public/assets/{chunk-55IACEB6-B4_-In90.js → chunk-55IACEB6-TmOKdy8a.js} +1 -1
  144. package/payload/server/public/assets/{chunk-5FUZZQ4R-DnYGr9CP.js → chunk-5FUZZQ4R-DZ_jSF6N.js} +1 -1
  145. package/payload/server/public/assets/{chunk-5PVQY5BW-BYCOi9bf.js → chunk-5PVQY5BW-De4G1vfV.js} +1 -1
  146. package/payload/server/public/assets/{chunk-67CJDMHE-CBxsC4JP.js → chunk-67CJDMHE-Cif9Uu5C.js} +1 -1
  147. package/payload/server/public/assets/{chunk-7N4EOEYR-CAACaqFr.js → chunk-7N4EOEYR-WjTHCXnd.js} +1 -1
  148. package/payload/server/public/assets/{chunk-AA7GKIK3-BoamZdS6.js → chunk-AA7GKIK3-CwCfdQmb.js} +1 -1
  149. package/payload/server/public/assets/{chunk-BSJP7CBP-CHk82eFy.js → chunk-BSJP7CBP-9A67yXwT.js} +1 -1
  150. package/payload/server/public/assets/{chunk-CIAEETIT-DdeW9zxl.js → chunk-CIAEETIT-DPMiWSBi.js} +1 -1
  151. package/payload/server/public/assets/{chunk-EDXVE4YY-DJqSRv2w.js → chunk-EDXVE4YY-C2f_Qvx-.js} +1 -1
  152. package/payload/server/public/assets/{chunk-ENJZ2VHE-BRi4BwM5.js → chunk-ENJZ2VHE-B-Q9EbSQ.js} +1 -1
  153. package/payload/server/public/assets/{chunk-FMBD7UC4-OJp2CtUE.js → chunk-FMBD7UC4-Bmt_ZRlV.js} +1 -1
  154. package/payload/server/public/assets/{chunk-FOC6F5B3-BUAQ6spG.js → chunk-FOC6F5B3-DfGJGxpX.js} +1 -1
  155. package/payload/server/public/assets/{chunk-ICPOFSXX-DeRggD-Q.js → chunk-ICPOFSXX-Wyym1Q8K.js} +2 -2
  156. package/payload/server/public/assets/{chunk-K5T4RW27-EHiXZ8Py.js → chunk-K5T4RW27-DMpoaqw2.js} +1 -1
  157. package/payload/server/public/assets/{chunk-KGLVRYIC-tOajPcpn.js → chunk-KGLVRYIC-DYUFgri2.js} +1 -1
  158. package/payload/server/public/assets/{chunk-LIHQZDEY-uwO6oHIU.js → chunk-LIHQZDEY-x3LmeKmx.js} +1 -1
  159. package/payload/server/public/assets/{chunk-ORNJ4GCN-Dpa1_BCA.js → chunk-ORNJ4GCN-BeuNAWkW.js} +1 -1
  160. package/payload/server/public/assets/{chunk-OYMX7WX6-ChdDqaQL.js → chunk-OYMX7WX6-BUWVAfQA.js} +1 -1
  161. package/payload/server/public/assets/chunk-QZHKN3VN-7_kS3RBy.js +1 -0
  162. package/payload/server/public/assets/{chunk-U2HBQHQK-5JgWosPv.js → chunk-U2HBQHQK-DKS5adQo.js} +1 -1
  163. package/payload/server/public/assets/{chunk-X2U36JSP-BGJqysB2.js → chunk-X2U36JSP-CT_UM4IN.js} +1 -1
  164. package/payload/server/public/assets/{chunk-XPW4576I-GERE2ekx.js → chunk-XPW4576I-ByFnN4JO.js} +1 -1
  165. package/payload/server/public/assets/{chunk-YZCP3GAM-BskqX1V5.js → chunk-YZCP3GAM-DCetC20J.js} +1 -1
  166. package/payload/server/public/assets/{chunk-ZZ45TVLE-DG1iY-L9.js → chunk-ZZ45TVLE-Bh1QIv8N.js} +1 -1
  167. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DJqbNcuC.js +1 -0
  168. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-CGglqfQA.js +1 -0
  169. package/payload/server/public/assets/clone-BiTMTzp_.js +1 -0
  170. package/payload/server/public/assets/{cose-bilkent-S5V4N54A-C8Rl4ykh.js → cose-bilkent-S5V4N54A-DgLEaApT.js} +1 -1
  171. package/payload/server/public/assets/{dagre-KV5264BT-C0YrJPF3.js → dagre-KV5264BT-BfkUPrEV.js} +1 -1
  172. package/payload/server/public/assets/{dagre-DTlv-FFy.js → dagre-unhMIyVo.js} +1 -1
  173. package/payload/server/public/assets/data-CJbC_cVy.js +1 -0
  174. package/payload/server/public/assets/{diagram-5BDNPKRD-Dfk5oepI.js → diagram-5BDNPKRD-BjjwcTD9.js} +1 -1
  175. package/payload/server/public/assets/{diagram-G4DWMVQ6-QRj55DiB.js → diagram-G4DWMVQ6-BbgWtVSS.js} +1 -1
  176. package/payload/server/public/assets/{diagram-MMDJMWI5-BAPKsohU.js → diagram-MMDJMWI5-DyAnkWGy.js} +1 -1
  177. package/payload/server/public/assets/{diagram-TYMM5635-C3iZXPkC.js → diagram-TYMM5635-SAMMoLxq.js} +1 -1
  178. package/payload/server/public/assets/{erDiagram-SMLLAGMA-DIbI7g_5.js → erDiagram-SMLLAGMA-Cvo-5-fD.js} +1 -1
  179. package/payload/server/public/assets/{flatten-DlAJeN0L.js → flatten-BXzaHgtL.js} +1 -1
  180. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-l3eJJr8R.js → flowDiagram-DWJPFMVM-y3o2Ycld.js} +1 -1
  181. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-aaEzHxIq.js → ganttDiagram-T4ZO3ILL-BEm_CQaK.js} +1 -1
  182. package/payload/server/public/assets/gitGraph-7Q5UKJZL-C_-XjxLd.js +1 -0
  183. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-Di-1eg_m.js → gitGraphDiagram-UUTBAWPF-CqmOxS7F.js} +1 -1
  184. package/payload/server/public/assets/{graph-CNgt7ygi.js → graph-kaxS9a_7.js} +2 -2
  185. package/payload/server/public/assets/graph-labels-GgejRtgV.js +1 -0
  186. package/payload/server/public/assets/{graphlib-D5awC2yp.js → graphlib-_HfbQcBf.js} +1 -1
  187. package/payload/server/public/assets/info-OMHHGYJF-BH8yDCfL.js +1 -0
  188. package/payload/server/public/assets/infoDiagram-42DDH7IO-DyDXyJ6U.js +2 -0
  189. package/payload/server/public/assets/{isEmpty-Dh1dF-tM.js → isEmpty-uB1dEu_Z.js} +1 -1
  190. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-CLOd_cNf.js → ishikawaDiagram-UXIWVN3A-C_d0vcZC.js} +1 -1
  191. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-cGyfW6Tg.js → journeyDiagram-VCZTEJTY-DsjRBqlW.js} +1 -1
  192. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-CRTKWPlh.js → kanban-definition-6JOO6SKY-BwtK6LS-.js} +1 -1
  193. package/payload/server/public/assets/{line-B8Brm0TD.js → line-szBCFOLJ.js} +1 -1
  194. package/payload/server/public/assets/{linear-BHnSKXBe.js → linear-X3O7lwK3.js} +1 -1
  195. package/payload/server/public/assets/{maximize-2-k3Fu5hdP.js → maximize-2-CPq3nwZA.js} +1 -1
  196. package/payload/server/public/assets/{mermaid-parser.core-BtNpdQct.js → mermaid-parser.core-BavvMAhU.js} +2 -2
  197. package/payload/server/public/assets/{mermaid.core-BY87anQZ.js → mermaid.core-Br2F9Hdm.js} +3 -3
  198. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-rUSv9qN1.js → mindmap-definition-QFDTVHPH-4qlwXoJ-.js} +1 -1
  199. package/payload/server/public/assets/operator-CldXXilj.js +1 -0
  200. package/payload/server/public/assets/{ordinal-Bnl5GL-x.js → ordinal-CZY9laxg.js} +1 -1
  201. package/payload/server/public/assets/packet-4T2RLAQJ-Bajcns4r.js +1 -0
  202. package/payload/server/public/assets/{page-Bvx0A2jB.js → page-BE6DkJP9.js} +1 -1
  203. package/payload/server/public/assets/{page-DhwPG5Zf.js → page-BIwdWXnv.js} +3 -3
  204. package/payload/server/public/assets/{pdf-render-CrpAi4Ba.js → pdf-render-H884L3EI.js} +1 -1
  205. package/payload/server/public/assets/pie-ZZUOXDRM-D7hjM5w1.js +1 -0
  206. package/payload/server/public/assets/{pieDiagram-DEJITSTG-Cjn9IP-C.js → pieDiagram-DEJITSTG-BbXryIg0.js} +1 -1
  207. package/payload/server/public/assets/{public-BJ5Oo2Fw.js → public-BKQEQGPE.js} +1 -1
  208. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-BkSHkSsn.js → quadrantDiagram-34T5L4WZ-BHLjcvh3.js} +1 -1
  209. package/payload/server/public/assets/radar-PYXPWWZC-B46d5x7a.js +1 -0
  210. package/payload/server/public/assets/{reduce-Bp5oNw1n.js → reduce-BGQzAenn.js} +1 -1
  211. package/payload/server/public/assets/{requirementDiagram-MS252O5E-hLwgyB-F.js → requirementDiagram-MS252O5E-BXaVO0lk.js} +1 -1
  212. package/payload/server/public/assets/{rotate-ccw-D-ov5jM4.js → rotate-ccw-DieZzl10.js} +1 -1
  213. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-D6Dl1RIa.js → sankeyDiagram-XADWPNL6-CmN1s17Q.js} +1 -1
  214. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-j0QBqkbL.js → sequenceDiagram-FGHM5R23-DC08e5NY.js} +1 -1
  215. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-TvEBNsuV.js → stateDiagram-FHFEXIEX-DnoQMuOW.js} +1 -1
  216. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CrstH34p.js +1 -0
  217. package/payload/server/public/assets/{tasks-DHMxTZVD.js → tasks-DYyCGz8W.js} +1 -1
  218. package/payload/server/public/assets/{time-entry-format-EmV5lz6o.js → time-entry-format-CC_9jFuR.js} +1 -1
  219. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-BfivOP-j.js → timeline-definition-GMOUNBTQ-eTn3uRaH.js} +1 -1
  220. package/payload/server/public/assets/treeView-SZITEDCU-3XQh5xQ1.js +1 -0
  221. package/payload/server/public/assets/treemap-W4RFUUIX-DwR-_l5z.js +1 -0
  222. package/payload/server/public/assets/{triangle-alert-CivyPfAL.js → triangle-alert-7mQfCQWP.js} +1 -1
  223. package/payload/server/public/assets/{useCopyFeedback-hbnPS-r9.js → useCopyFeedback-os7N0yCx.js} +1 -1
  224. package/payload/server/public/assets/useSubAccountSwitcher-Bgfqig1V.css +1 -0
  225. package/payload/server/public/assets/{useVoiceRecorder-QN9_GqB_.js → useVoiceRecorder-ZFwt5i6d.js} +2 -2
  226. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-Bvfw9aFJ.js → vennDiagram-DHZGUBPP-DNx6o_ZQ.js} +1 -1
  227. package/payload/server/public/assets/wardley-RL74JXVD-DZp9oCGv.js +1 -0
  228. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-C9h04G6b.js → wardleyDiagram-NUSXRM2D-BZOchqF3.js} +1 -1
  229. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-BMvi2m2q.js → xychartDiagram-5P7HB3ND-ZBEGH6ZI.js} +1 -1
  230. package/payload/server/public/browser.html +4 -4
  231. package/payload/server/public/calendar.html +6 -6
  232. package/payload/server/public/chat.html +14 -14
  233. package/payload/server/public/data.html +11 -11
  234. package/payload/server/public/graph.html +10 -11
  235. package/payload/server/public/index.html +16 -17
  236. package/payload/server/public/operator.html +15 -16
  237. package/payload/server/public/public.html +14 -14
  238. package/payload/server/public/tasks.html +6 -6
  239. package/payload/server/server.js +1836 -998
  240. package/payload/server/{src-JJ6PHO5I.js → src-UYDFMWF4.js} +1 -1
  241. package/payload/server/public/assets/AdminLoginScreens-Dic_fPt6.js +0 -1
  242. package/payload/server/public/assets/AdminShell-JN7rZ8aG.js +0 -2
  243. package/payload/server/public/assets/Checkbox-CB74kINA.js +0 -1
  244. package/payload/server/public/assets/activity-DbfpHg3i.js +0 -1
  245. package/payload/server/public/assets/admin-CThbXaYH.js +0 -1
  246. package/payload/server/public/assets/architecture-YZFGNWBL-CGvD4r9F.js +0 -1
  247. package/payload/server/public/assets/channel-Bpg-ADC0.js +0 -1
  248. package/payload/server/public/assets/chat-DcefIV9q.js +0 -1
  249. package/payload/server/public/assets/chevron-down-Blma_CKl.js +0 -1
  250. package/payload/server/public/assets/chevron-left-xeYDxFiC.js +0 -1
  251. package/payload/server/public/assets/chevron-right-BonbtjqJ.js +0 -1
  252. package/payload/server/public/assets/chunk-426QAEUC-1poG7Nd3.js +0 -1
  253. package/payload/server/public/assets/chunk-QZHKN3VN-GN1tIO8J.js +0 -1
  254. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DMGVq8Jv.js +0 -1
  255. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DfddT8UV.js +0 -1
  256. package/payload/server/public/assets/clone-Cc9vFIEk.js +0 -1
  257. package/payload/server/public/assets/data-D5dp6nj6.js +0 -1
  258. package/payload/server/public/assets/gitGraph-7Q5UKJZL-DtjXE0UI.js +0 -1
  259. package/payload/server/public/assets/graph-labels-DA48HrRR.js +0 -1
  260. package/payload/server/public/assets/info-OMHHGYJF-CmArhGYf.js +0 -1
  261. package/payload/server/public/assets/infoDiagram-42DDH7IO-utWz7oZy.js +0 -2
  262. package/payload/server/public/assets/operator-8NVpNLeD.js +0 -1
  263. package/payload/server/public/assets/packet-4T2RLAQJ-BTfK0Zw4.js +0 -1
  264. package/payload/server/public/assets/pie-ZZUOXDRM-BvtPeMQY.js +0 -1
  265. package/payload/server/public/assets/radar-PYXPWWZC-D2UXvQgn.js +0 -1
  266. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-Ur4eM6rb.js +0 -1
  267. package/payload/server/public/assets/treeView-SZITEDCU-0dE7FvM2.js +0 -1
  268. package/payload/server/public/assets/treemap-W4RFUUIX-BrwlXpr5.js +0 -1
  269. package/payload/server/public/assets/useSubAccountSwitcher-kwhG-aJj.css +0 -1
  270. package/payload/server/public/assets/wardley-RL74JXVD-CO9HdfJ-.js +0 -1
  271. /package/payload/server/public/assets/{_baseFor-BBkmqPEk.js → _baseFor-CMWvz0uA.js} +0 -0
  272. /package/payload/server/public/assets/{array-Bjtb2C_5.js → array-Cny_NT6T.js} +0 -0
  273. /package/payload/server/public/assets/{cytoscape.esm-B1dQ664k.js → cytoscape.esm-De0CCmB2.js} +0 -0
  274. /package/payload/server/public/assets/{defaultLocale-iwYA7ne8.js → defaultLocale-BahRht8U.js} +0 -0
  275. /package/payload/server/public/assets/{dist-CLmrZbY7.js → dist-C9D_xz81.js} +0 -0
  276. /package/payload/server/public/assets/{init-CxnBAgcf.js → init-DoIvjoJ_.js} +0 -0
  277. /package/payload/server/public/assets/{katex-DojHoLIr.js → katex-Bfn1OZEl.js} +0 -0
  278. /package/payload/server/public/assets/{path-JZiojYzQ.js → path-CTezU8L5.js} +0 -0
  279. /package/payload/server/public/assets/{preload-helper-CT1Z6Pdu.js → preload-helper-D_sPnjeL.js} +0 -0
  280. /package/payload/server/public/assets/{rough.esm-D5NinLFK.js → rough.esm-BJo4pvXu.js} +0 -0
  281. /package/payload/server/public/assets/{src-DQ0MSD6q.js → src-BTKk8KAG.js} +0 -0
  282. /package/payload/server/public/assets/{useSubAccountSwitcher-DotTrvX3.js → useSubAccountSwitcher-Q941cZYV.js} +0 -0
@@ -57,14 +57,23 @@ export async function readExposeFolders(accountDir) {
57
57
  }
58
58
 
59
59
  /**
60
- * Files under `exposed` dirs, at any depth. Dot-prefixed entries are skipped at
61
- * every level: `.uploads-tmp/` holds in-flight upload partials (files.ts:25) and
62
- * must never be published as a deliverable.
60
+ * Files AND directories under `exposed` dirs, at any depth, each row carrying
61
+ * `isDir`. Directories are published rather than left to be derived from file
62
+ * prefixes, because a folder holding no files at any depth has no prefix to be
63
+ * derived from and would not exist to the client at all.
64
+ *
65
+ * A directory row is emitted only after its own `readdir` succeeds, so an
66
+ * exposed name that is absent or unreadable on disk still contributes nothing
67
+ * and the portal never renders a folder the device does not have.
68
+ *
69
+ * Dot-prefixed entries are skipped at every level: `.uploads-tmp/` holds
70
+ * in-flight upload partials (files.ts:25) and must never be published as a
71
+ * deliverable, as a file row or as a folder.
63
72
  * @param {string} accountDir @param {string[]} exposed
64
- * @returns {Promise<{relPath: string, sizeBytes: number, modifiedAt: string}[]>}
73
+ * @returns {Promise<{relPath: string, sizeBytes: number, modifiedAt: string, isDir: boolean}[]>}
65
74
  */
66
75
  export async function walkExposed(accountDir, exposed) {
67
- /** @type {{relPath: string, sizeBytes: number, modifiedAt: string}[]} */
76
+ /** @type {{relPath: string, sizeBytes: number, modifiedAt: string, isDir: boolean}[]} */
68
77
  const out = []
69
78
 
70
79
  /** @param {string} rel */
@@ -75,6 +84,14 @@ export async function walkExposed(accountDir, exposed) {
75
84
  } catch {
76
85
  return // absent or unreadable dir contributes nothing
77
86
  }
87
+ try {
88
+ const st = await stat(join(accountDir, rel))
89
+ // sizeBytes 0 rather than the directory inode's own size: the column
90
+ // means "bytes a client can fetch", and a folder has none.
91
+ out.push({ relPath: rel, sizeBytes: 0, modifiedAt: st.mtime.toISOString(), isDir: true })
92
+ } catch {
93
+ // vanished between the readdir and the stat; the next cycle settles it
94
+ }
78
95
  for (const e of entries) {
79
96
  if (e.name.startsWith('.')) continue
80
97
  const childRel = `${rel}/${e.name}`
@@ -83,7 +100,12 @@ export async function walkExposed(accountDir, exposed) {
83
100
  } else if (e.isFile()) {
84
101
  try {
85
102
  const st = await stat(join(accountDir, childRel))
86
- out.push({ relPath: childRel, sizeBytes: st.size, modifiedAt: st.mtime.toISOString() })
103
+ out.push({
104
+ relPath: childRel,
105
+ sizeBytes: st.size,
106
+ modifiedAt: st.mtime.toISOString(),
107
+ isDir: false,
108
+ })
87
109
  } catch {
88
110
  // vanished mid-walk; the next cycle settles it
89
111
  }
@@ -156,22 +178,71 @@ export async function pushAccount(opts) {
156
178
 
157
179
  const rows = await walkExposed(accountDir, r.exposed)
158
180
 
159
- await client.query('DELETE FROM directory WHERE accountId = ?', [accountId])
181
+ // Stage, flip, then sweep (Task 1910, superseding backlog Task 1842).
182
+ //
183
+ // The replace used to be DELETE-then-INSERT, which left the account's tree
184
+ // empty or half-built for the whole insert run. A client listing inside that
185
+ // window saw a portal with nothing in it. At sixty-second cadence that window
186
+ // is hit sixty times more often than it was hourly, which is what turned a
187
+ // known defect into a blocking one.
188
+ //
189
+ // Now every row is written at the NEXT generation, one statement moves the
190
+ // pointer, and only then are superseded rows removed. The flip is the atomic
191
+ // point: a reader sees the old tree or the new one, never a mixture. The
192
+ // sweep afterwards need not be atomic, because nothing reads a generation the
193
+ // pointer does not name.
194
+ const state = await client.query(
195
+ 'SELECT currentGeneration FROM directory_state WHERE accountId = ?',
196
+ [accountId],
197
+ )
198
+ const current = Number(state[0]?.currentGeneration ?? 0)
199
+ const next = current + 1
200
+
160
201
  for (const row of rows) {
161
202
  await client.query(
162
- 'INSERT INTO directory (accountId, relPath, sizeBytes, modifiedAt, indexedAt) VALUES (?, ?, ?, ?, ?)',
163
- [accountId, row.relPath, row.sizeBytes, row.modifiedAt, nowIso],
203
+ 'INSERT INTO directory (accountId, relPath, sizeBytes, modifiedAt, indexedAt, isDir, generation) ' +
204
+ 'VALUES (?, ?, ?, ?, ?, ?, ?)',
205
+ [accountId, row.relPath, row.sizeBytes, row.modifiedAt, nowIso, row.isDir ? 1 : 0, next],
206
+ )
207
+ }
208
+ log(`${TAG} op=stage account=${accountId} generation=${next} inserted=${rows.length}`)
209
+
210
+ // THE atomic point. Upsert rather than insert-or-update, so an account
211
+ // publishing for the first time and one republishing take the same path.
212
+ await client.query(
213
+ 'INSERT INTO directory_state (accountId, currentGeneration) VALUES (?, ?) ' +
214
+ 'ON CONFLICT (accountId) DO UPDATE SET currentGeneration = excluded.currentGeneration',
215
+ [accountId, next],
216
+ )
217
+ log(`${TAG} op=flip account=${accountId} generation=${next}`)
218
+
219
+ // Housekeeping only. A failure here leaves stale rows nothing reads, which is
220
+ // waste rather than incorrectness — so it must not fail the publish that has
221
+ // already succeeded.
222
+ try {
223
+ await client.query('DELETE FROM directory WHERE accountId = ? AND generation != ?', [
224
+ accountId,
225
+ next,
226
+ ])
227
+ } catch (err) {
228
+ log(
229
+ `${TAG} op=sweep account=${accountId} generation=${next} result=failed ` +
230
+ `err="${err instanceof Error ? err.message : String(err)}"`,
164
231
  )
165
232
  }
166
- log(`${TAG} op=replace account=${accountId} deleted=all inserted=${rows.length}`)
167
233
 
168
234
  // Read back rather than trusting the write: "issued the inserts" is not "the
169
- // rows are there", and a half-applied batch is otherwise invisible.
170
- const check = await client.query('SELECT COUNT(*) AS n FROM directory WHERE accountId = ?', [accountId])
235
+ // rows are there", and a half-applied batch is otherwise invisible. Counted
236
+ // through the pointer, so this measures what a client would actually see.
237
+ const check = await client.query(
238
+ 'SELECT COUNT(*) AS n FROM directory d JOIN directory_state s ' +
239
+ 'ON s.accountId = d.accountId AND s.currentGeneration = d.generation WHERE d.accountId = ?',
240
+ [accountId],
241
+ )
171
242
  const after = Number(check[0]?.n ?? -1)
172
243
  log(`${TAG} op=verify account=${accountId} rowsAfter=${after} expected=${rows.length}`)
173
244
 
174
- return { exposed: r.exposed, rows: rows.length, schemaPresent: true }
245
+ return { exposed: r.exposed, rows: rows.length, schemaPresent: true, generation: next }
175
246
  }
176
247
 
177
248
  /**
@@ -301,39 +372,44 @@ function resolveCloudflareCredentials(platformRoot) {
301
372
  return { cfAccountId: house.CLOUDFLARE_ACCOUNT_ID, token }
302
373
  }
303
374
 
304
- async function main() {
305
- const platformRoot = process.env.PLATFORM_ROOT
306
- if (!platformRoot) {
307
- console.error(`${TAG} op=usage reason=no-platform-root PLATFORM_ROOT is required`)
308
- process.exit(1)
309
- }
375
+ /**
376
+ * Publish every portal-serving account's index.
377
+ *
378
+ * Exported so the admin server's 60s loop and this script's CLI entry run the
379
+ * SAME code (Task 1910). The loop replaced an hourly heartbeat spawn whose own
380
+ * header recorded it had never been observed running on a device; two
381
+ * implementations of the publish would have reintroduced exactly that class of
382
+ * problem one layer along.
383
+ *
384
+ * Returns a summary rather than exiting, so a caller inside a long-lived
385
+ * process can report it. Throwing is reserved for a genuine break — an absent
386
+ * house credential where portals exist — which the loop registry surfaces as a
387
+ * failed run rather than a silent one.
388
+ *
389
+ * @param {string} platformRoot
390
+ * @returns {Promise<{accounts: number, pushed: number, failed: number}>}
391
+ */
392
+ export async function pushAllAccounts(platformRoot) {
393
+ const accountsRoot = resolve(platformRoot, '..', 'data', 'accounts')
394
+ const targets = await portalAccounts(accountsRoot)
395
+ console.error(`${TAG} op=enumerate accountsRoot="${accountsRoot}" accounts=${targets.length}`)
396
+ // Before minting a credential, exactly as the pull and the audit do: most
397
+ // installs have neither a portal nor a house credential, and minting
398
+ // unconditionally would make this loop report an error on every one forever.
399
+ if (targets.length === 0) return { accounts: 0, pushed: 0, failed: 0 }
310
400
 
311
401
  const cred = resolveCloudflareCredentials(platformRoot)
312
402
  if ('error' in cred) {
313
- console.error(`${TAG} op=skip reason=${cred.error}`)
314
- process.exit(1)
315
- }
316
-
317
- /** @type {{accountDir: string, accountId: string, dbName: string, exposeFolders: string[]}[]} */ let targets
318
- if (process.argv.includes('--all-accounts')) {
319
- // Same derivation as the platform's own DATA_ROOT (data-path.ts:19), from
320
- // the PLATFORM_ROOT the heartbeat already passes.
321
- const accountsRoot = resolve(platformRoot, '..', 'data', 'accounts')
322
- targets = await portalAccounts(accountsRoot)
323
- console.error(`${TAG} op=enumerate accountsRoot="${accountsRoot}" accounts=${targets.length}`)
324
- } else {
325
- const accountDir = arg('--account-dir')
326
- const accountId = arg('--account')
327
- const dbName = arg('--db')
328
- if (!accountDir || !accountId || !dbName) {
329
- console.error(`${TAG} op=usage --account-dir <dir> --account <id> --db <name>`)
330
- process.exit(1)
331
- }
332
- targets = [{ accountDir, accountId, dbName, exposeFolders: await readExposeFolders(accountDir) }]
403
+ throw new Error(cred.error)
333
404
  }
405
+ return runTargets(platformRoot, cred, targets)
406
+ }
334
407
 
408
+ /** @returns {Promise<{accounts: number, pushed: number, failed: number}>} */
409
+ async function runTargets(platformRoot, cred, targets) {
335
410
  const state = readState(platformRoot)
336
411
  let failed = 0
412
+ let pushed = 0
337
413
  for (const t of targets) {
338
414
  try {
339
415
  // cfAccountId addresses the D1 endpoint; t.accountId scopes the rows.
@@ -360,6 +436,7 @@ async function main() {
360
436
  exposed: r.exposed,
361
437
  })
362
438
  }
439
+ pushed++
363
440
  console.error(`${TAG} op=done account=${t.accountId} rows=${r.rows}`)
364
441
  } catch (err) {
365
442
  // One account's failure must not stop the rest, and it must never be
@@ -370,10 +447,53 @@ async function main() {
370
447
  )
371
448
  }
372
449
  }
373
- if (failed > 0) process.exit(1)
450
+ return { accounts: targets.length, pushed, failed }
451
+ }
452
+
453
+ async function main() {
454
+ const platformRoot = process.env.PLATFORM_ROOT
455
+ if (!platformRoot) {
456
+ console.error(`${TAG} op=usage reason=no-platform-root PLATFORM_ROOT is required`)
457
+ process.exit(1)
458
+ }
459
+
460
+ if (process.argv.includes('--all-accounts')) {
461
+ try {
462
+ const r = await pushAllAccounts(platformRoot)
463
+ if (r.failed > 0) process.exit(1)
464
+ } catch (err) {
465
+ console.error(`${TAG} op=skip reason=${err instanceof Error ? err.message : String(err)}`)
466
+ process.exit(1)
467
+ }
468
+ return
469
+ }
470
+
471
+ const accountDir = arg('--account-dir')
472
+ const accountId = arg('--account')
473
+ const dbName = arg('--db')
474
+ if (!accountDir || !accountId || !dbName) {
475
+ console.error(`${TAG} op=usage --account-dir <dir> --account <id> --db <name>`)
476
+ process.exit(1)
477
+ }
478
+ const cred = resolveCloudflareCredentials(platformRoot)
479
+ if ('error' in cred) {
480
+ console.error(`${TAG} op=skip reason=${cred.error}`)
481
+ process.exit(1)
482
+ }
483
+ const r = await runTargets(platformRoot, cred, [
484
+ { accountDir, accountId, dbName, exposeFolders: await readExposeFolders(accountDir) },
485
+ ])
486
+ if (r.failed > 0) process.exit(1)
374
487
  }
375
488
 
376
489
  // Only when invoked directly, so the module stays importable by tests.
377
- if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
490
+ // The basename check is what survives the UI bundler inlining this file into
491
+ // server.js: when bundled, `process.argv[1]` (server.js) and `import.meta.url`
492
+ // (server.js after esbuild's transform) resolve to the same path, and a pure
493
+ // path-equality guard fires main() at server boot, exiting the payload before
494
+ // its first line served. Matching on the mjs basename keeps this cheap CLI
495
+ // entry point from tripping in-process.
496
+ const invokedPath = process.argv[1] ? resolve(process.argv[1]) : ''
497
+ if (invokedPath && invokedPath.endsWith('/portal-index-push.mjs') && invokedPath === resolve(fileURLToPath(import.meta.url))) {
378
498
  void main()
379
499
  }
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { processDelete } from '../../skills/data-portal/template/functions/api/delete'
3
+ import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
4
+
5
+ /** `row` is what the manifest holds for the key under test, or null when the
6
+ * key is unknown. The fake tracks which of the three writes actually happened
7
+ * so a test can assert the R2 object was never touched. */
8
+ function harness(row: { devicePath: string } | null) {
9
+ const state = { objectDeleted: false, rowDeleted: false, marked: false }
10
+ const lines: string[] = []
11
+
12
+ const env = {
13
+ BUCKET: {
14
+ async delete() {
15
+ state.objectDeleted = true
16
+ },
17
+ async head() {
18
+ return state.objectDeleted ? null : { size: 1 }
19
+ },
20
+ },
21
+ DB: {
22
+ prepare(sql: string) {
23
+ return {
24
+ bind() {
25
+ return {
26
+ async first() {
27
+ if (sql.includes('FROM sessions')) {
28
+ return { ownerId: 'alice', accountId: 'acct', folders: '' }
29
+ }
30
+ if (sql.includes('SELECT devicePath')) return row
31
+ if (sql.includes('SELECT deleted')) return { deleted: state.marked ? 1 : 0 }
32
+ if (sql.includes('SELECT fileId FROM manifest')) {
33
+ return state.rowDeleted || !row ? null : { fileId: 'f1' }
34
+ }
35
+ return null
36
+ },
37
+ async run() {
38
+ if (/^UPDATE manifest/.test(sql)) state.marked = true
39
+ if (/^DELETE FROM manifest/.test(sql)) state.rowDeleted = true
40
+ },
41
+ }
42
+ },
43
+ }
44
+ },
45
+ },
46
+ } as unknown as PortalEnv
47
+
48
+ return { env, state, lines, log: (l: string) => lines.push(l) }
49
+ }
50
+
51
+ describe('delete branches on whether the file has moved', () => {
52
+ it('deletes object and row outright when the file is still in transit', async () => {
53
+ const h = harness({ devicePath: '' })
54
+ const res = await processDelete('s', 'alice/a.pdf', h.env, h.log, () => 1)
55
+ expect(res.status).toBe(200)
56
+ expect(h.state.objectDeleted).toBe(true)
57
+ expect(h.state.rowDeleted).toBe(true)
58
+ expect(h.state.marked).toBe(false)
59
+ })
60
+
61
+ it('marks a moved file for withdrawal instead of touching R2', async () => {
62
+ // The bytes are on the device and the R2 object is already gone. Deleting
63
+ // here would report success while the client's file stayed on the device
64
+ // forever — the exact failure this branch exists to prevent.
65
+ const h = harness({ devicePath: 'jobs/a.pdf' })
66
+ const res = await processDelete('s', 'alice/jobs/a.pdf', h.env, h.log, () => 1)
67
+ expect(res.status).toBe(200)
68
+ expect(h.state.marked).toBe(true)
69
+ expect(h.state.rowDeleted).toBe(false)
70
+ expect(h.state.objectDeleted).toBe(false)
71
+ expect(h.lines.some((l) => l.includes('op=withdraw') && l.includes('result=marked'))).toBe(true)
72
+ })
73
+
74
+ it('tells the client the withdrawal is pending, not complete', async () => {
75
+ const h = harness({ devicePath: 'jobs/a.pdf' })
76
+ const res = await processDelete('s', 'alice/jobs/a.pdf', h.env, h.log, () => 1)
77
+ expect(res.payload).toMatchObject({ ok: true, pending: true })
78
+ })
79
+
80
+ it('reports an unknown key as not found without writing anything', async () => {
81
+ const h = harness(null)
82
+ const res = await processDelete('s', 'alice/nope.pdf', h.env, h.log, () => 1)
83
+ expect(res.status).toBe(404)
84
+ expect(h.state.objectDeleted).toBe(false)
85
+ expect(h.state.marked).toBe(false)
86
+ expect(h.state.rowDeleted).toBe(false)
87
+ })
88
+
89
+ it('still refuses another person key before reading anything', async () => {
90
+ const h = harness({ devicePath: '' })
91
+ const res = await processDelete('s', 'bob/a.pdf', h.env, h.log, () => 1)
92
+ expect(res.status).toBe(403)
93
+ expect(h.state.objectDeleted).toBe(false)
94
+ })
95
+ })
@@ -2,19 +2,28 @@ import { describe, it, expect } from 'vitest'
2
2
  import { processFiles, entriesUnder } from '../../skills/data-portal/template/functions/api/files'
3
3
  import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
4
4
 
5
+ // Mirrors what the push writes: a row per folder as well as per file. `jobs` is
6
+ // exposed and holds nothing, which is the case that had no row at all before.
5
7
  const DIRECTORY = [
6
- { accountId: 'acc-a', relPath: 'output/report.pdf', sizeBytes: 10, modifiedAt: '2026-07-20T10:00:00.000Z' },
7
- { accountId: 'acc-a', relPath: 'quotes/CR2969/quote.pdf', sizeBytes: 20, modifiedAt: '2026-07-20T11:00:00.000Z' },
8
- { accountId: 'acc-a', relPath: 'quotes/CR2974/quote.pdf', sizeBytes: 30, modifiedAt: '2026-07-20T12:00:00.000Z' },
9
- { accountId: 'acc-b', relPath: 'output/secret.pdf', sizeBytes: 40, modifiedAt: '2026-07-20T13:00:00.000Z' },
8
+ { accountId: 'acc-a', relPath: 'output', sizeBytes: 0, modifiedAt: '2026-07-20T10:00:00.000Z', isDir: 1 },
9
+ { accountId: 'acc-a', relPath: 'output/report.pdf', sizeBytes: 10, modifiedAt: '2026-07-20T10:00:00.000Z', isDir: 0 },
10
+ { accountId: 'acc-a', relPath: 'quotes', sizeBytes: 0, modifiedAt: '2026-07-20T12:00:00.000Z', isDir: 1 },
11
+ { accountId: 'acc-a', relPath: 'quotes/CR2969', sizeBytes: 0, modifiedAt: '2026-07-20T11:00:00.000Z', isDir: 1 },
12
+ { accountId: 'acc-a', relPath: 'quotes/CR2969/quote.pdf', sizeBytes: 20, modifiedAt: '2026-07-20T11:00:00.000Z', isDir: 0 },
13
+ { accountId: 'acc-a', relPath: 'quotes/CR2974', sizeBytes: 0, modifiedAt: '2026-07-20T12:00:00.000Z', isDir: 1 },
14
+ { accountId: 'acc-a', relPath: 'quotes/CR2974/quote.pdf', sizeBytes: 30, modifiedAt: '2026-07-20T12:00:00.000Z', isDir: 0 },
15
+ { accountId: 'acc-a', relPath: 'jobs', sizeBytes: 0, modifiedAt: '2026-07-20T14:00:00.000Z', isDir: 1 },
16
+ { accountId: 'acc-b', relPath: 'output/secret.pdf', sizeBytes: 40, modifiedAt: '2026-07-20T13:00:00.000Z', isDir: 0 },
10
17
  ]
11
18
 
12
19
  function makeEnv(
13
20
  sessions: Record<string, { ownerId: string; accountId: string; folders?: string }>,
21
+ onQuery: (query: string) => void = () => {},
14
22
  ) {
15
23
  return {
16
24
  DB: {
17
25
  prepare(query: string) {
26
+ onQuery(query)
18
27
  let bound: unknown[] = []
19
28
  const stmt = {
20
29
  bind(...v: unknown[]) {
@@ -47,9 +56,9 @@ type Entry = { name: string; kind: string; relPath: string; sizeBytes: number |
47
56
  describe('entriesUnder', () => {
48
57
  const rows = DIRECTORY.filter((d) => d.accountId === 'acc-a')
49
58
 
50
- it('synthesises top-level folders from row paths', () => {
59
+ it('lists top-level folders, including one holding no files', () => {
51
60
  const e = entriesUnder(rows, '')
52
- expect(e.map((x) => x.name)).toEqual(['output', 'quotes'])
61
+ expect(e.map((x) => x.name)).toEqual(['jobs', 'output', 'quotes'])
53
62
  expect(e.every((x) => x.kind === 'directory')).toBe(true)
54
63
  })
55
64
 
@@ -57,6 +66,42 @@ describe('entriesUnder', () => {
57
66
  expect(entriesUnder(rows, 'quotes').map((x) => x.name)).toEqual(['CR2969', 'CR2974'])
58
67
  })
59
68
 
69
+ // An empty folder must open, not read as missing. Its own row is what puts it
70
+ // in the parent listing; inside it there is nothing to show.
71
+ it('opens an empty folder to an empty listing', () => {
72
+ expect(entriesUnder(rows, 'jobs')).toEqual([])
73
+ })
74
+
75
+ // The explicit row and the row implied by a nested file name the same folder.
76
+ // Two entries for one folder would render it twice.
77
+ it('collapses an explicit folder row and one implied by a nested file', () => {
78
+ const both = [
79
+ { relPath: 'quotes', sizeBytes: 0, modifiedAt: '2026-07-20T12:00:00.000Z', isDir: 1 },
80
+ { relPath: 'quotes/CR2969/quote.pdf', sizeBytes: 20, modifiedAt: '2026-07-20T11:00:00.000Z', isDir: 0 },
81
+ ]
82
+ const e = entriesUnder(both, '')
83
+ expect(e.length).toBe(1)
84
+ expect(e[0]).toEqual({
85
+ name: 'quotes',
86
+ kind: 'directory',
87
+ relPath: 'quotes',
88
+ sizeBytes: null,
89
+ modifiedAt: null,
90
+ })
91
+ })
92
+
93
+ // Without the isDir check a folder row has no slash left after the prefix and
94
+ // lands in the file list, where clicking it would try to download a folder.
95
+ it('never renders a folder row as a file', () => {
96
+ const e = entriesUnder(
97
+ [{ relPath: 'jobs', sizeBytes: 0, modifiedAt: '2026-07-20T14:00:00.000Z', isDir: 1 }],
98
+ '',
99
+ )
100
+ expect(e).toEqual([
101
+ { name: 'jobs', kind: 'directory', relPath: 'jobs', sizeBytes: null, modifiedAt: null },
102
+ ])
103
+ })
104
+
60
105
  it('lists files inside a leaf folder with size and time', () => {
61
106
  expect(entriesUnder(rows, 'quotes/CR2969')).toEqual([
62
107
  {
@@ -71,9 +116,9 @@ describe('entriesUnder', () => {
71
116
 
72
117
  it('puts folders before files and files newest first', () => {
73
118
  const mixed = [
74
- { relPath: 'output/old.pdf', sizeBytes: 1, modifiedAt: '2026-01-01T00:00:00.000Z' },
75
- { relPath: 'output/new.pdf', sizeBytes: 1, modifiedAt: '2026-09-01T00:00:00.000Z' },
76
- { relPath: 'output/sub/x.pdf', sizeBytes: 1, modifiedAt: '2026-05-01T00:00:00.000Z' },
119
+ { relPath: 'output/old.pdf', sizeBytes: 1, modifiedAt: '2026-01-01T00:00:00.000Z', isDir: 0 },
120
+ { relPath: 'output/new.pdf', sizeBytes: 1, modifiedAt: '2026-09-01T00:00:00.000Z', isDir: 0 },
121
+ { relPath: 'output/sub/x.pdf', sizeBytes: 1, modifiedAt: '2026-05-01T00:00:00.000Z', isDir: 0 },
77
122
  ]
78
123
  expect(entriesUnder(mixed, 'output').map((x) => x.name)).toEqual(['sub', 'new.pdf', 'old.pdf'])
79
124
  })
@@ -85,9 +130,18 @@ describe('entriesUnder', () => {
85
130
  })
86
131
 
87
132
  describe('processFiles directory listing', () => {
88
- it('returns synthesised folders for the session account', async () => {
133
+ it('returns the account folders, including one holding no files', async () => {
89
134
  const r = await processFiles('sess-a', makeEnv(SESSIONS), () => {}, 0, '')
90
- expect((r.payload.entries as Entry[]).map((e) => e.name)).toEqual(['output', 'quotes'])
135
+ expect((r.payload.entries as Entry[]).map((e) => e.name)).toEqual(['jobs', 'output', 'quotes'])
136
+ })
137
+
138
+ // The column is what tells a folder row from a file row; a SELECT that drops
139
+ // it renders every folder as an unopenable file.
140
+ it('reads isDir in the directory select', async () => {
141
+ const queries: string[] = []
142
+ const env = makeEnv(SESSIONS, (q) => queries.push(q))
143
+ await processFiles('sess-a', env, () => {}, 0, '')
144
+ expect(queries.some((q) => /FROM directory/i.test(q) && /isDir/.test(q))).toBe(true)
91
145
  })
92
146
 
93
147
  it('never leaks another account rows', async () => {
@@ -129,15 +183,23 @@ describe('processFiles directory listing', () => {
129
183
  it('an empty grant shows the full set', async () => {
130
184
  const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: '' } })
131
185
  const r = await processFiles('sess-a', env, () => {}, 0, '')
132
- expect((r.payload.entries as Entry[]).map((e) => e.name)).toEqual(['output', 'quotes'])
186
+ expect((r.payload.entries as Entry[]).map((e) => e.name)).toEqual(['jobs', 'output', 'quotes'])
133
187
  })
134
188
 
135
189
  it('a grant naming an absent folder shows nothing', async () => {
136
- const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: 'jobs' } })
190
+ const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: 'parts' } })
137
191
  const r = await processFiles('sess-a', env, () => {}, 0, '')
138
192
  expect(r.payload.entries).toEqual([])
139
193
  })
140
194
 
195
+ // The grant narrows the account's exposed set, and an empty folder is inside
196
+ // that set like any other: outside the grant it stays hidden.
197
+ it('an empty folder outside the grant stays hidden', async () => {
198
+ const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: 'quotes' } })
199
+ const r = await processFiles('sess-a', env, () => {}, 0, '')
200
+ expect((r.payload.entries as Entry[]).map((e) => e.name)).not.toContain('jobs')
201
+ })
202
+
141
203
  it('the grant holds when browsing into a granted folder', async () => {
142
204
  const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: 'quotes' } })
143
205
  const r = await processFiles('sess-a', env, () => {}, 0, 'quotes')