@rubytech/create-maxy-code 0.1.489 → 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.
- package/package.json +1 -1
- package/payload/platform/docs/superpowers/plans/2026-07-22-task-1910-two-way-portal-exchange.md +1716 -0
- package/payload/platform/docs/superpowers/specs/2026-07-22-task-1910-two-way-portal-exchange-design.md +103 -0
- package/payload/platform/lib/storage-broker/dist/audit.d.ts +29 -1
- package/payload/platform/lib/storage-broker/dist/audit.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/audit.js +25 -2
- package/payload/platform/lib/storage-broker/dist/audit.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +13 -0
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.js +22 -0
- package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
- package/payload/platform/lib/storage-broker/src/audit.ts +59 -1
- package/payload/platform/lib/storage-broker/src/cf-exec.ts +48 -0
- package/payload/platform/plugins/admin/skills/agent-builder/references/agent-pattern.md +34 -5
- package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +133 -35
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-delete-withdraw.test.ts +95 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +141 -9
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-moved-download.test.ts +83 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-schema-shape.test.ts +48 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts +62 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-target.test.ts +103 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-upload-multipart.test.ts +211 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-upload-target.test.ts +168 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-uploads-listing.test.ts +138 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +111 -4
- package/payload/platform/plugins/cloudflare/mcp/__tests__/upload-route.test.ts +14 -13
- package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +52 -14
- package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +70 -7
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs +74 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/target.ts +91 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +23 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/delete.ts +45 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +59 -4
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +58 -9
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts +358 -16
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +9 -1
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +45 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +216 -20
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +5 -122
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
- package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
- package/payload/platform/templates/agents/passive/IDENTITY.md +6 -0
- package/payload/platform/templates/agents/public/IDENTITY.md +6 -0
- package/payload/platform/templates/specialists/agents/citation-auditor.md +6 -0
- package/payload/platform/templates/specialists/agents/coding-assistant.md +6 -0
- package/payload/platform/templates/specialists/agents/compiled-truth-rewriter.md +6 -0
- package/payload/platform/templates/specialists/agents/content-producer.md +6 -0
- package/payload/platform/templates/specialists/agents/data-manager.md +6 -0
- package/payload/platform/templates/specialists/agents/database-operator.md +6 -0
- package/payload/platform/templates/specialists/agents/librarian.md +6 -0
- package/payload/platform/templates/specialists/agents/personal-assistant.md +6 -0
- package/payload/platform/templates/specialists/agents/project-manager.md +6 -0
- package/payload/platform/templates/specialists/agents/public-session-reviewer.md +6 -0
- package/payload/platform/templates/specialists/agents/research-assistant.md +7 -1
- package/payload/platform/templates/specialists/agents/typed-edge-classifier.md +6 -0
- package/payload/premium-plugins/writer-craft/agents/writer-craft--manuscript-reviewer.md +6 -0
- package/payload/server/{chunk-UFXRUKXO.js → chunk-KZFE3MOY.js} +49 -3
- package/payload/server/public/activity.html +5 -6
- package/payload/server/public/assets/AdminLoginScreens-DgVFsHtd.js +1 -0
- package/payload/server/public/assets/AdminShell-BwNqwb4k.js +2 -0
- package/payload/server/public/assets/activity-2DL2DwLr.js +1 -0
- package/payload/server/public/assets/admin-COe0dT2U.js +1 -0
- package/payload/server/public/assets/{arc-D-QzyndF.js → arc-CFMJyMm-.js} +1 -1
- package/payload/server/public/assets/architecture-YZFGNWBL-Cypt5qHL.js +1 -0
- package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-W7f0Us4S.js → architectureDiagram-Q4EWVU46-CiSSgdL8.js} +1 -1
- package/payload/server/public/assets/{blockDiagram-DXYQGD6D-CfPS4FXg.js → blockDiagram-DXYQGD6D-_sXtIW5c.js} +1 -1
- package/payload/server/public/assets/{bot-DnuyRsLS.js → bot-CpBuDQlF.js} +1 -1
- package/payload/server/public/assets/{browser-q4EFqKvR.js → browser-CSApuZrv.js} +1 -1
- package/payload/server/public/assets/{c4Diagram-AHTNJAMY-Dq60cLVI.js → c4Diagram-AHTNJAMY-Bh7OAMXs.js} +1 -1
- package/payload/server/public/assets/{calendar-C-q9kWmA.js → calendar-DlvwZnS7.js} +1 -1
- package/payload/server/public/assets/channel-BMyXE4zc.js +1 -0
- package/payload/server/public/assets/chat-DWvYv9jq.js +1 -0
- package/payload/server/public/assets/chevron-down-Dnuuw6Q2.js +1 -0
- package/payload/server/public/assets/chevron-left-CC4zmWOr.js +1 -0
- package/payload/server/public/assets/chevron-right-CG_lHTx_.js +1 -0
- package/payload/server/public/assets/{chunk-2KRD3SAO-CXDjuDN0.js → chunk-2KRD3SAO-D4fktAwW.js} +1 -1
- package/payload/server/public/assets/{chunk-336JU56O-926ox0tj.js → chunk-336JU56O-CcQoKH2G.js} +2 -2
- package/payload/server/public/assets/chunk-426QAEUC-Xu6JKP4d.js +1 -0
- package/payload/server/public/assets/{chunk-4BX2VUAB-C2nrPCfN.js → chunk-4BX2VUAB-C13Wz3zS.js} +1 -1
- package/payload/server/public/assets/{chunk-4TB4RGXK-CuW3FOR6.js → chunk-4TB4RGXK-BL48D7gN.js} +1 -1
- package/payload/server/public/assets/{chunk-55IACEB6-B4_-In90.js → chunk-55IACEB6-TmOKdy8a.js} +1 -1
- package/payload/server/public/assets/{chunk-5FUZZQ4R-DnYGr9CP.js → chunk-5FUZZQ4R-DZ_jSF6N.js} +1 -1
- package/payload/server/public/assets/{chunk-5PVQY5BW-BYCOi9bf.js → chunk-5PVQY5BW-De4G1vfV.js} +1 -1
- package/payload/server/public/assets/{chunk-67CJDMHE-CBxsC4JP.js → chunk-67CJDMHE-Cif9Uu5C.js} +1 -1
- package/payload/server/public/assets/{chunk-7N4EOEYR-CAACaqFr.js → chunk-7N4EOEYR-WjTHCXnd.js} +1 -1
- package/payload/server/public/assets/{chunk-AA7GKIK3-BoamZdS6.js → chunk-AA7GKIK3-CwCfdQmb.js} +1 -1
- package/payload/server/public/assets/{chunk-BSJP7CBP-CHk82eFy.js → chunk-BSJP7CBP-9A67yXwT.js} +1 -1
- package/payload/server/public/assets/{chunk-CIAEETIT-DdeW9zxl.js → chunk-CIAEETIT-DPMiWSBi.js} +1 -1
- package/payload/server/public/assets/{chunk-EDXVE4YY-DJqSRv2w.js → chunk-EDXVE4YY-C2f_Qvx-.js} +1 -1
- package/payload/server/public/assets/{chunk-ENJZ2VHE-BRi4BwM5.js → chunk-ENJZ2VHE-B-Q9EbSQ.js} +1 -1
- package/payload/server/public/assets/{chunk-FMBD7UC4-OJp2CtUE.js → chunk-FMBD7UC4-Bmt_ZRlV.js} +1 -1
- package/payload/server/public/assets/{chunk-FOC6F5B3-BUAQ6spG.js → chunk-FOC6F5B3-DfGJGxpX.js} +1 -1
- package/payload/server/public/assets/{chunk-ICPOFSXX-DeRggD-Q.js → chunk-ICPOFSXX-Wyym1Q8K.js} +2 -2
- package/payload/server/public/assets/{chunk-K5T4RW27-EHiXZ8Py.js → chunk-K5T4RW27-DMpoaqw2.js} +1 -1
- package/payload/server/public/assets/{chunk-KGLVRYIC-tOajPcpn.js → chunk-KGLVRYIC-DYUFgri2.js} +1 -1
- package/payload/server/public/assets/{chunk-LIHQZDEY-uwO6oHIU.js → chunk-LIHQZDEY-x3LmeKmx.js} +1 -1
- package/payload/server/public/assets/{chunk-ORNJ4GCN-Dpa1_BCA.js → chunk-ORNJ4GCN-BeuNAWkW.js} +1 -1
- package/payload/server/public/assets/{chunk-OYMX7WX6-ChdDqaQL.js → chunk-OYMX7WX6-BUWVAfQA.js} +1 -1
- package/payload/server/public/assets/chunk-QZHKN3VN-7_kS3RBy.js +1 -0
- package/payload/server/public/assets/{chunk-U2HBQHQK-5JgWosPv.js → chunk-U2HBQHQK-DKS5adQo.js} +1 -1
- package/payload/server/public/assets/{chunk-X2U36JSP-BGJqysB2.js → chunk-X2U36JSP-CT_UM4IN.js} +1 -1
- package/payload/server/public/assets/{chunk-XPW4576I-GERE2ekx.js → chunk-XPW4576I-ByFnN4JO.js} +1 -1
- package/payload/server/public/assets/{chunk-YZCP3GAM-BskqX1V5.js → chunk-YZCP3GAM-DCetC20J.js} +1 -1
- package/payload/server/public/assets/{chunk-ZZ45TVLE-DG1iY-L9.js → chunk-ZZ45TVLE-Bh1QIv8N.js} +1 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-DJqbNcuC.js +1 -0
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-CGglqfQA.js +1 -0
- package/payload/server/public/assets/clone-BiTMTzp_.js +1 -0
- package/payload/server/public/assets/{cose-bilkent-S5V4N54A-C8Rl4ykh.js → cose-bilkent-S5V4N54A-DgLEaApT.js} +1 -1
- package/payload/server/public/assets/{dagre-KV5264BT-C0YrJPF3.js → dagre-KV5264BT-BfkUPrEV.js} +1 -1
- package/payload/server/public/assets/{dagre-DTlv-FFy.js → dagre-unhMIyVo.js} +1 -1
- package/payload/server/public/assets/data-CJbC_cVy.js +1 -0
- package/payload/server/public/assets/{diagram-5BDNPKRD-Dfk5oepI.js → diagram-5BDNPKRD-BjjwcTD9.js} +1 -1
- package/payload/server/public/assets/{diagram-G4DWMVQ6-QRj55DiB.js → diagram-G4DWMVQ6-BbgWtVSS.js} +1 -1
- package/payload/server/public/assets/{diagram-MMDJMWI5-BAPKsohU.js → diagram-MMDJMWI5-DyAnkWGy.js} +1 -1
- package/payload/server/public/assets/{diagram-TYMM5635-C3iZXPkC.js → diagram-TYMM5635-SAMMoLxq.js} +1 -1
- package/payload/server/public/assets/{erDiagram-SMLLAGMA-DIbI7g_5.js → erDiagram-SMLLAGMA-Cvo-5-fD.js} +1 -1
- package/payload/server/public/assets/{flatten-DlAJeN0L.js → flatten-BXzaHgtL.js} +1 -1
- package/payload/server/public/assets/{flowDiagram-DWJPFMVM-l3eJJr8R.js → flowDiagram-DWJPFMVM-y3o2Ycld.js} +1 -1
- package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-aaEzHxIq.js → ganttDiagram-T4ZO3ILL-BEm_CQaK.js} +1 -1
- package/payload/server/public/assets/gitGraph-7Q5UKJZL-C_-XjxLd.js +1 -0
- package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-Di-1eg_m.js → gitGraphDiagram-UUTBAWPF-CqmOxS7F.js} +1 -1
- package/payload/server/public/assets/{graph-Cyh6FWaV.js → graph-kaxS9a_7.js} +2 -2
- package/payload/server/public/assets/{graph-labels-CUQtXFKW.js → graph-labels-GgejRtgV.js} +1 -1
- package/payload/server/public/assets/{graphlib-D5awC2yp.js → graphlib-_HfbQcBf.js} +1 -1
- package/payload/server/public/assets/info-OMHHGYJF-BH8yDCfL.js +1 -0
- package/payload/server/public/assets/infoDiagram-42DDH7IO-DyDXyJ6U.js +2 -0
- package/payload/server/public/assets/{isEmpty-Dh1dF-tM.js → isEmpty-uB1dEu_Z.js} +1 -1
- package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-CLOd_cNf.js → ishikawaDiagram-UXIWVN3A-C_d0vcZC.js} +1 -1
- package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-cGyfW6Tg.js → journeyDiagram-VCZTEJTY-DsjRBqlW.js} +1 -1
- package/payload/server/public/assets/{kanban-definition-6JOO6SKY-CRTKWPlh.js → kanban-definition-6JOO6SKY-BwtK6LS-.js} +1 -1
- package/payload/server/public/assets/{line-B8Brm0TD.js → line-szBCFOLJ.js} +1 -1
- package/payload/server/public/assets/{linear-BHnSKXBe.js → linear-X3O7lwK3.js} +1 -1
- package/payload/server/public/assets/{maximize-2-k3Fu5hdP.js → maximize-2-CPq3nwZA.js} +1 -1
- package/payload/server/public/assets/{mermaid-parser.core-BtNpdQct.js → mermaid-parser.core-BavvMAhU.js} +2 -2
- package/payload/server/public/assets/{mermaid.core-BY87anQZ.js → mermaid.core-Br2F9Hdm.js} +3 -3
- package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-rUSv9qN1.js → mindmap-definition-QFDTVHPH-4qlwXoJ-.js} +1 -1
- package/payload/server/public/assets/operator-CldXXilj.js +1 -0
- package/payload/server/public/assets/{ordinal-Bnl5GL-x.js → ordinal-CZY9laxg.js} +1 -1
- package/payload/server/public/assets/packet-4T2RLAQJ-Bajcns4r.js +1 -0
- package/payload/server/public/assets/{page-Cno8NNMk.js → page-BE6DkJP9.js} +1 -1
- package/payload/server/public/assets/{page-DhwPG5Zf.js → page-BIwdWXnv.js} +3 -3
- package/payload/server/public/assets/{pdf-render-CrpAi4Ba.js → pdf-render-H884L3EI.js} +1 -1
- package/payload/server/public/assets/pie-ZZUOXDRM-D7hjM5w1.js +1 -0
- package/payload/server/public/assets/{pieDiagram-DEJITSTG-Cjn9IP-C.js → pieDiagram-DEJITSTG-BbXryIg0.js} +1 -1
- package/payload/server/public/assets/{public-BJ5Oo2Fw.js → public-BKQEQGPE.js} +1 -1
- package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-BkSHkSsn.js → quadrantDiagram-34T5L4WZ-BHLjcvh3.js} +1 -1
- package/payload/server/public/assets/radar-PYXPWWZC-B46d5x7a.js +1 -0
- package/payload/server/public/assets/{reduce-Bp5oNw1n.js → reduce-BGQzAenn.js} +1 -1
- package/payload/server/public/assets/{requirementDiagram-MS252O5E-hLwgyB-F.js → requirementDiagram-MS252O5E-BXaVO0lk.js} +1 -1
- package/payload/server/public/assets/{rotate-ccw-D-ov5jM4.js → rotate-ccw-DieZzl10.js} +1 -1
- package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-D6Dl1RIa.js → sankeyDiagram-XADWPNL6-CmN1s17Q.js} +1 -1
- package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-j0QBqkbL.js → sequenceDiagram-FGHM5R23-DC08e5NY.js} +1 -1
- package/payload/server/public/assets/{stateDiagram-FHFEXIEX-TvEBNsuV.js → stateDiagram-FHFEXIEX-DnoQMuOW.js} +1 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CrstH34p.js +1 -0
- package/payload/server/public/assets/{tasks-DHMxTZVD.js → tasks-DYyCGz8W.js} +1 -1
- package/payload/server/public/assets/{time-entry-format-EmV5lz6o.js → time-entry-format-CC_9jFuR.js} +1 -1
- package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-BfivOP-j.js → timeline-definition-GMOUNBTQ-eTn3uRaH.js} +1 -1
- package/payload/server/public/assets/treeView-SZITEDCU-3XQh5xQ1.js +1 -0
- package/payload/server/public/assets/treemap-W4RFUUIX-DwR-_l5z.js +1 -0
- package/payload/server/public/assets/{triangle-alert-CivyPfAL.js → triangle-alert-7mQfCQWP.js} +1 -1
- package/payload/server/public/assets/{useCopyFeedback-hbnPS-r9.js → useCopyFeedback-os7N0yCx.js} +1 -1
- package/payload/server/public/assets/useSubAccountSwitcher-Bgfqig1V.css +1 -0
- package/payload/server/public/assets/{useVoiceRecorder-QN9_GqB_.js → useVoiceRecorder-ZFwt5i6d.js} +2 -2
- package/payload/server/public/assets/{vennDiagram-DHZGUBPP-Bvfw9aFJ.js → vennDiagram-DHZGUBPP-DNx6o_ZQ.js} +1 -1
- package/payload/server/public/assets/wardley-RL74JXVD-DZp9oCGv.js +1 -0
- package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-C9h04G6b.js → wardleyDiagram-NUSXRM2D-BZOchqF3.js} +1 -1
- package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-BMvi2m2q.js → xychartDiagram-5P7HB3ND-ZBEGH6ZI.js} +1 -1
- package/payload/server/public/browser.html +4 -4
- package/payload/server/public/calendar.html +6 -6
- package/payload/server/public/chat.html +14 -14
- package/payload/server/public/data.html +11 -11
- package/payload/server/public/graph.html +10 -11
- package/payload/server/public/index.html +16 -17
- package/payload/server/public/operator.html +15 -16
- package/payload/server/public/public.html +14 -14
- package/payload/server/public/tasks.html +6 -6
- package/payload/server/server.js +1488 -895
- package/payload/server/{src-YJF3UJNR.js → src-UYDFMWF4.js} +1 -1
- package/payload/platform/lib/active-rules/package-lock.json +0 -12
- package/payload/server/public/assets/AdminLoginScreens-Dic_fPt6.js +0 -1
- package/payload/server/public/assets/AdminShell-JN7rZ8aG.js +0 -2
- package/payload/server/public/assets/Checkbox-CB74kINA.js +0 -1
- package/payload/server/public/assets/activity-DbfpHg3i.js +0 -1
- package/payload/server/public/assets/admin-DvQPTxqe.js +0 -1
- package/payload/server/public/assets/architecture-YZFGNWBL-CGvD4r9F.js +0 -1
- package/payload/server/public/assets/channel-Bpg-ADC0.js +0 -1
- package/payload/server/public/assets/chat-DcefIV9q.js +0 -1
- package/payload/server/public/assets/chevron-down-Blma_CKl.js +0 -1
- package/payload/server/public/assets/chevron-left-xeYDxFiC.js +0 -1
- package/payload/server/public/assets/chevron-right-BonbtjqJ.js +0 -1
- package/payload/server/public/assets/chunk-426QAEUC-1poG7Nd3.js +0 -1
- package/payload/server/public/assets/chunk-QZHKN3VN-GN1tIO8J.js +0 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-DMGVq8Jv.js +0 -1
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DfddT8UV.js +0 -1
- package/payload/server/public/assets/clone-Cc9vFIEk.js +0 -1
- package/payload/server/public/assets/data-Bu-5SUmR.js +0 -1
- package/payload/server/public/assets/gitGraph-7Q5UKJZL-DtjXE0UI.js +0 -1
- package/payload/server/public/assets/info-OMHHGYJF-CmArhGYf.js +0 -1
- package/payload/server/public/assets/infoDiagram-42DDH7IO-utWz7oZy.js +0 -2
- package/payload/server/public/assets/operator-8NVpNLeD.js +0 -1
- package/payload/server/public/assets/packet-4T2RLAQJ-BTfK0Zw4.js +0 -1
- package/payload/server/public/assets/pie-ZZUOXDRM-BvtPeMQY.js +0 -1
- package/payload/server/public/assets/radar-PYXPWWZC-D2UXvQgn.js +0 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-Ur4eM6rb.js +0 -1
- package/payload/server/public/assets/treeView-SZITEDCU-0dE7FvM2.js +0 -1
- package/payload/server/public/assets/treemap-W4RFUUIX-BrwlXpr5.js +0 -1
- package/payload/server/public/assets/useSubAccountSwitcher-kwhG-aJj.css +0 -1
- package/payload/server/public/assets/wardley-RL74JXVD-CO9HdfJ-.js +0 -1
- /package/payload/server/public/assets/{_baseFor-BBkmqPEk.js → _baseFor-CMWvz0uA.js} +0 -0
- /package/payload/server/public/assets/{array-Bjtb2C_5.js → array-Cny_NT6T.js} +0 -0
- /package/payload/server/public/assets/{cytoscape.esm-B1dQ664k.js → cytoscape.esm-De0CCmB2.js} +0 -0
- /package/payload/server/public/assets/{defaultLocale-iwYA7ne8.js → defaultLocale-BahRht8U.js} +0 -0
- /package/payload/server/public/assets/{dist-CLmrZbY7.js → dist-C9D_xz81.js} +0 -0
- /package/payload/server/public/assets/{init-CxnBAgcf.js → init-DoIvjoJ_.js} +0 -0
- /package/payload/server/public/assets/{katex-DojHoLIr.js → katex-Bfn1OZEl.js} +0 -0
- /package/payload/server/public/assets/{path-JZiojYzQ.js → path-CTezU8L5.js} +0 -0
- /package/payload/server/public/assets/{preload-helper-CT1Z6Pdu.js → preload-helper-D_sPnjeL.js} +0 -0
- /package/payload/server/public/assets/{rough.esm-D5NinLFK.js → rough.esm-BJo4pvXu.js} +0 -0
- /package/payload/server/public/assets/{src-DQ0MSD6q.js → src-BTKk8KAG.js} +0 -0
- /package/payload/server/public/assets/{useSubAccountSwitcher-DotTrvX3.js → useSubAccountSwitcher-Q941cZYV.js} +0 -0
|
@@ -178,22 +178,71 @@ export async function pushAccount(opts) {
|
|
|
178
178
|
|
|
179
179
|
const rows = await walkExposed(accountDir, r.exposed)
|
|
180
180
|
|
|
181
|
-
|
|
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
|
+
|
|
182
201
|
for (const row of rows) {
|
|
183
202
|
await client.query(
|
|
184
|
-
'INSERT INTO directory (accountId, relPath, sizeBytes, modifiedAt, indexedAt, isDir)
|
|
185
|
-
|
|
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)}"`,
|
|
186
231
|
)
|
|
187
232
|
}
|
|
188
|
-
log(`${TAG} op=replace account=${accountId} deleted=all inserted=${rows.length}`)
|
|
189
233
|
|
|
190
234
|
// Read back rather than trusting the write: "issued the inserts" is not "the
|
|
191
|
-
// rows are there", and a half-applied batch is otherwise invisible.
|
|
192
|
-
|
|
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
|
+
)
|
|
193
242
|
const after = Number(check[0]?.n ?? -1)
|
|
194
243
|
log(`${TAG} op=verify account=${accountId} rowsAfter=${after} expected=${rows.length}`)
|
|
195
244
|
|
|
196
|
-
return { exposed: r.exposed, rows: rows.length, schemaPresent: true }
|
|
245
|
+
return { exposed: r.exposed, rows: rows.length, schemaPresent: true, generation: next }
|
|
197
246
|
}
|
|
198
247
|
|
|
199
248
|
/**
|
|
@@ -323,39 +372,44 @@ function resolveCloudflareCredentials(platformRoot) {
|
|
|
323
372
|
return { cfAccountId: house.CLOUDFLARE_ACCOUNT_ID, token }
|
|
324
373
|
}
|
|
325
374
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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 }
|
|
332
400
|
|
|
333
401
|
const cred = resolveCloudflareCredentials(platformRoot)
|
|
334
402
|
if ('error' in cred) {
|
|
335
|
-
|
|
336
|
-
process.exit(1)
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/** @type {{accountDir: string, accountId: string, dbName: string, exposeFolders: string[]}[]} */ let targets
|
|
340
|
-
if (process.argv.includes('--all-accounts')) {
|
|
341
|
-
// Same derivation as the platform's own DATA_ROOT (data-path.ts:19), from
|
|
342
|
-
// the PLATFORM_ROOT the heartbeat already passes.
|
|
343
|
-
const accountsRoot = resolve(platformRoot, '..', 'data', 'accounts')
|
|
344
|
-
targets = await portalAccounts(accountsRoot)
|
|
345
|
-
console.error(`${TAG} op=enumerate accountsRoot="${accountsRoot}" accounts=${targets.length}`)
|
|
346
|
-
} else {
|
|
347
|
-
const accountDir = arg('--account-dir')
|
|
348
|
-
const accountId = arg('--account')
|
|
349
|
-
const dbName = arg('--db')
|
|
350
|
-
if (!accountDir || !accountId || !dbName) {
|
|
351
|
-
console.error(`${TAG} op=usage --account-dir <dir> --account <id> --db <name>`)
|
|
352
|
-
process.exit(1)
|
|
353
|
-
}
|
|
354
|
-
targets = [{ accountDir, accountId, dbName, exposeFolders: await readExposeFolders(accountDir) }]
|
|
403
|
+
throw new Error(cred.error)
|
|
355
404
|
}
|
|
405
|
+
return runTargets(platformRoot, cred, targets)
|
|
406
|
+
}
|
|
356
407
|
|
|
408
|
+
/** @returns {Promise<{accounts: number, pushed: number, failed: number}>} */
|
|
409
|
+
async function runTargets(platformRoot, cred, targets) {
|
|
357
410
|
const state = readState(platformRoot)
|
|
358
411
|
let failed = 0
|
|
412
|
+
let pushed = 0
|
|
359
413
|
for (const t of targets) {
|
|
360
414
|
try {
|
|
361
415
|
// cfAccountId addresses the D1 endpoint; t.accountId scopes the rows.
|
|
@@ -382,6 +436,7 @@ async function main() {
|
|
|
382
436
|
exposed: r.exposed,
|
|
383
437
|
})
|
|
384
438
|
}
|
|
439
|
+
pushed++
|
|
385
440
|
console.error(`${TAG} op=done account=${t.accountId} rows=${r.rows}`)
|
|
386
441
|
} catch (err) {
|
|
387
442
|
// One account's failure must not stop the rest, and it must never be
|
|
@@ -392,10 +447,53 @@ async function main() {
|
|
|
392
447
|
)
|
|
393
448
|
}
|
|
394
449
|
}
|
|
395
|
-
|
|
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)
|
|
396
487
|
}
|
|
397
488
|
|
|
398
489
|
// Only when invoked directly, so the module stays importable by tests.
|
|
399
|
-
|
|
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))) {
|
|
400
498
|
void main()
|
|
401
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
|
+
})
|
|
@@ -49,12 +49,13 @@ function fakeClient() {
|
|
|
49
49
|
calls,
|
|
50
50
|
async query(sql: string, params: unknown[] = []) {
|
|
51
51
|
calls.push({ sql, params })
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// The open paren is load-bearing: `INSERT INTO directory_state` would
|
|
53
|
+
// otherwise register as a row insert and inflate every count.
|
|
54
|
+
if (/INSERT INTO directory \(/i.test(sql)) {
|
|
55
|
+
rows.add(String(params[1]))
|
|
54
56
|
return []
|
|
55
57
|
}
|
|
56
|
-
if (/
|
|
57
|
-
rows.add(String(params[1]))
|
|
58
|
+
if (/DELETE FROM directory\b/i.test(sql)) {
|
|
58
59
|
return []
|
|
59
60
|
}
|
|
60
61
|
if (/SELECT COUNT/i.test(sql)) return [{ n: rows.size }]
|
|
@@ -140,14 +141,17 @@ describe('walkExposed', () => {
|
|
|
140
141
|
|
|
141
142
|
describe('pushAccount', () => {
|
|
142
143
|
// 7 rows: the 3 files plus output/, quotes/, quotes/CR2969/, quotes/CR2974/.
|
|
143
|
-
it('
|
|
144
|
+
it('stages the current walk, then flips the pointer to it', async () => {
|
|
145
|
+
// Task 1910 replaced delete-then-insert with stage-flip-sweep. The first
|
|
146
|
+
// statement now READS the pointer rather than emptying the table, which is
|
|
147
|
+
// what removes the window where a client saw nothing.
|
|
144
148
|
const c = fakeClient()
|
|
145
149
|
const r = await push(c)
|
|
146
150
|
expect(r.exposed).toEqual(['output', 'quotes'])
|
|
147
151
|
expect(r.rows).toBe(7)
|
|
148
|
-
expect(/
|
|
152
|
+
expect(/SELECT currentGeneration/i.test(c.calls[0].sql)).toBe(true)
|
|
149
153
|
expect(c.calls[0].params).toEqual(['acc-1'])
|
|
150
|
-
expect(c.calls.filter((x) => /INSERT INTO directory/i.test(x.sql)).length).toBe(7)
|
|
154
|
+
expect(c.calls.filter((x) => /INSERT INTO directory \(/i.test(x.sql)).length).toBe(7)
|
|
151
155
|
})
|
|
152
156
|
|
|
153
157
|
// The folder itself survives its last file: emptied is not the same as gone,
|
|
@@ -201,12 +205,13 @@ describe('pushAccount', () => {
|
|
|
201
205
|
expect(c.calls.length).toBe(0)
|
|
202
206
|
})
|
|
203
207
|
|
|
204
|
-
it('logs resolve,
|
|
208
|
+
it('logs resolve, stage, flip and verify lines', async () => {
|
|
205
209
|
const lines: string[] = []
|
|
206
210
|
const c = fakeClient()
|
|
207
211
|
await push(c, { log: (l: string) => lines.push(l) })
|
|
208
212
|
expect(lines.some((l) => l.includes('op=resolve') && l.includes('exposed=output,quotes'))).toBe(true)
|
|
209
|
-
expect(lines.some((l) => l.includes('op=
|
|
213
|
+
expect(lines.some((l) => l.includes('op=stage') && l.includes('inserted=7'))).toBe(true)
|
|
214
|
+
expect(lines.some((l) => l.includes('op=flip') && l.includes('generation=1'))).toBe(true)
|
|
210
215
|
expect(lines.some((l) => l.includes('op=verify') && l.includes('rowsAfter=7') && l.includes('expected=7'))).toBe(true)
|
|
211
216
|
})
|
|
212
217
|
|
|
@@ -265,3 +270,130 @@ describe('pushAccount', () => {
|
|
|
265
270
|
expect(lines.some((l) => /op=resolve .*extraFolders=0/.test(l))).toBe(true)
|
|
266
271
|
})
|
|
267
272
|
})
|
|
273
|
+
|
|
274
|
+
// The atomic replace (Task 1910), superseding backlog Task 1842.
|
|
275
|
+
//
|
|
276
|
+
// A generation-aware client: rows carry a generation, and a reader sees only
|
|
277
|
+
// the generation the pointer names. That is what makes "never a half-built
|
|
278
|
+
// tree" a measurable property rather than an assertion.
|
|
279
|
+
function generationClient() {
|
|
280
|
+
const calls: { sql: string; params: unknown[] }[] = []
|
|
281
|
+
/** @type {{accountId: string, relPath: string, generation: number}[]} */
|
|
282
|
+
const rows: { accountId: string; relPath: string; generation: number }[] = []
|
|
283
|
+
let pointer: number | null = null
|
|
284
|
+
return {
|
|
285
|
+
calls,
|
|
286
|
+
rows,
|
|
287
|
+
/** What a client listing right now would see. */
|
|
288
|
+
visible() {
|
|
289
|
+
return pointer === null ? [] : rows.filter((r) => r.generation === pointer)
|
|
290
|
+
},
|
|
291
|
+
pointerValue() {
|
|
292
|
+
return pointer
|
|
293
|
+
},
|
|
294
|
+
async query(sql: string, params: unknown[] = []) {
|
|
295
|
+
calls.push({ sql, params })
|
|
296
|
+
if (/SELECT\s+currentGeneration/i.test(sql)) {
|
|
297
|
+
return pointer === null ? [] : [{ currentGeneration: pointer }]
|
|
298
|
+
}
|
|
299
|
+
if (/INSERT INTO directory_state|UPDATE directory_state/i.test(sql)) {
|
|
300
|
+
pointer = Number(params[1] ?? params[0])
|
|
301
|
+
return []
|
|
302
|
+
}
|
|
303
|
+
if (/INSERT INTO directory\b/i.test(sql)) {
|
|
304
|
+
rows.push({
|
|
305
|
+
accountId: String(params[0]),
|
|
306
|
+
relPath: String(params[1]),
|
|
307
|
+
generation: Number(params[6]),
|
|
308
|
+
})
|
|
309
|
+
return []
|
|
310
|
+
}
|
|
311
|
+
if (/DELETE FROM directory\b/i.test(sql)) {
|
|
312
|
+
const keep = Number(params[1])
|
|
313
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
314
|
+
if (rows[i].generation !== keep) rows.splice(i, 1)
|
|
315
|
+
}
|
|
316
|
+
return []
|
|
317
|
+
}
|
|
318
|
+
if (/SELECT COUNT/i.test(sql)) {
|
|
319
|
+
return [{ n: rows.filter((r) => r.generation === pointer).length }]
|
|
320
|
+
}
|
|
321
|
+
return []
|
|
322
|
+
},
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
describe('the index replace is atomic', () => {
|
|
327
|
+
it('never issues a delete before the pointer flip', async () => {
|
|
328
|
+
// The delete-then-insert window this replaces was Task 1842: a client
|
|
329
|
+
// listing mid-push saw an empty or half-built tree. At 60s cadence that
|
|
330
|
+
// window would be hit sixty times more often than it was hourly.
|
|
331
|
+
const c = generationClient()
|
|
332
|
+
await push(c)
|
|
333
|
+
const flipAt = c.calls.findIndex((x) => /INSERT INTO directory_state/i.test(x.sql))
|
|
334
|
+
const deleteAt = c.calls.findIndex((x) => /^DELETE FROM directory\b/i.test(x.sql.trim()))
|
|
335
|
+
expect(flipAt).toBeGreaterThanOrEqual(0)
|
|
336
|
+
expect(deleteAt).toBeGreaterThan(flipAt)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('writes every row before the flip', async () => {
|
|
340
|
+
const c = generationClient()
|
|
341
|
+
await push(c)
|
|
342
|
+
const flipAt = c.calls.findIndex((x) => /INSERT INTO directory_state/i.test(x.sql))
|
|
343
|
+
const lastInsert = c.calls.map((x) => /INSERT INTO directory\b/i.test(x.sql)).lastIndexOf(true)
|
|
344
|
+
expect(lastInsert).toBeLessThan(flipAt)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('a reader mid-push sees the old tree, never a partial one', async () => {
|
|
348
|
+
// First publish establishes generation 1.
|
|
349
|
+
const c = generationClient()
|
|
350
|
+
await push(c)
|
|
351
|
+
const first = c.visible().map((r) => r.relPath).sort()
|
|
352
|
+
expect(first.length).toBeGreaterThan(0)
|
|
353
|
+
|
|
354
|
+
// A second publish stages generation 2. Until the flip, `visible()` must
|
|
355
|
+
// still be exactly the first tree — not a mixture, not an empty set.
|
|
356
|
+
const seen: string[][] = []
|
|
357
|
+
const watched = {
|
|
358
|
+
...c,
|
|
359
|
+
async query(sql: string, params: unknown[] = []) {
|
|
360
|
+
const out = await c.query(sql, params)
|
|
361
|
+
seen.push(c.visible().map((r) => r.relPath).sort())
|
|
362
|
+
return out
|
|
363
|
+
},
|
|
364
|
+
}
|
|
365
|
+
await push(watched)
|
|
366
|
+
|
|
367
|
+
const flipIdx = c.calls.findIndex(
|
|
368
|
+
(x, i) => i > first.length && /INSERT INTO directory_state/i.test(x.sql),
|
|
369
|
+
)
|
|
370
|
+
for (let i = 0; i < flipIdx; i++) {
|
|
371
|
+
expect(seen[i]).toEqual(first)
|
|
372
|
+
}
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('advances the generation on each publish', async () => {
|
|
376
|
+
const c = generationClient()
|
|
377
|
+
await push(c)
|
|
378
|
+
expect(c.pointerValue()).toBe(1)
|
|
379
|
+
await push(c)
|
|
380
|
+
expect(c.pointerValue()).toBe(2)
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('deletes superseded generations after the flip', async () => {
|
|
384
|
+
const c = generationClient()
|
|
385
|
+
await push(c)
|
|
386
|
+
await push(c)
|
|
387
|
+
expect(c.rows.every((r) => r.generation === 2)).toBe(true)
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('writes nothing at all when the exposed set is empty', async () => {
|
|
391
|
+
// Fail closed must not delete the account rows: that state would be
|
|
392
|
+
// indistinguishable from a successful publish of an account with no
|
|
393
|
+
// deliverables.
|
|
394
|
+
rmSync(join(dir, 'SCHEMA.md'))
|
|
395
|
+
const c = generationClient()
|
|
396
|
+
await push(c)
|
|
397
|
+
expect(c.calls.filter((x) => /INSERT|DELETE|directory_state/i.test(x.sql))).toEqual([])
|
|
398
|
+
})
|
|
399
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { processDownload } from '../../skills/data-portal/template/functions/api/download'
|
|
3
|
+
import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
|
|
4
|
+
|
|
5
|
+
const ACC = '098a18a3-1741-447f-9778-65470645d57d'
|
|
6
|
+
|
|
7
|
+
function harness(row: { devicePath: string } | null, opts: { configured?: boolean } = {}) {
|
|
8
|
+
const configured = opts.configured ?? true
|
|
9
|
+
const lines: string[] = []
|
|
10
|
+
const env = {
|
|
11
|
+
BUCKET: {
|
|
12
|
+
async get(key: string) {
|
|
13
|
+
// Only an in-transit object exists. A moved row's bytes are gone from
|
|
14
|
+
// R2 entirely, which is what makes reading them here the wrong move.
|
|
15
|
+
return row && !row.devicePath ? { body: null, size: 4, key } : null
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
DB: {
|
|
19
|
+
prepare(sql: string) {
|
|
20
|
+
return {
|
|
21
|
+
bind() {
|
|
22
|
+
return {
|
|
23
|
+
async first() {
|
|
24
|
+
if (sql.includes('FROM sessions')) {
|
|
25
|
+
return { ownerId: 'alice', accountId: ACC, folders: '' }
|
|
26
|
+
}
|
|
27
|
+
if (sql.includes('devicePath')) return row
|
|
28
|
+
return null
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
PORTAL_FETCH_SECRET: configured ? 'test-secret-value' : undefined,
|
|
36
|
+
PORTAL_INSTALL_ORIGIN: configured ? 'https://install.example' : undefined,
|
|
37
|
+
} as unknown as PortalEnv
|
|
38
|
+
return { env, lines, log: (l: string) => lines.push(l) }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('downloading an upload that has moved to the device', () => {
|
|
42
|
+
it('streams R2 bytes while the row is still in transit', async () => {
|
|
43
|
+
const h = harness({ devicePath: '' })
|
|
44
|
+
const res = await processDownload('s', 'alice/a.pdf', h.env, h.log, 1000)
|
|
45
|
+
expect(res.status).toBe(200)
|
|
46
|
+
expect(res.body).not.toBeNull()
|
|
47
|
+
expect(res.url).toBeUndefined()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('mints an own-upload link once the row has moved', async () => {
|
|
51
|
+
// The object is gone from R2. Without this branch the client's own upload
|
|
52
|
+
// would 404 the moment the device collected it.
|
|
53
|
+
const h = harness({ devicePath: 'uploads/alice/a.pdf' })
|
|
54
|
+
const res = await processDownload('s', 'alice/a.pdf', h.env, h.log, 1000)
|
|
55
|
+
expect(res.status).toBe(200)
|
|
56
|
+
expect(res.url).toContain('/api/portal/fetch')
|
|
57
|
+
expect(new URL(res.url!).searchParams.get('owner')).toBe('alice')
|
|
58
|
+
expect(new URL(res.url!).searchParams.get('path')).toBe('uploads/alice/a.pdf')
|
|
59
|
+
expect(h.lines.some((l) => l.includes('op=download') && l.includes('result=moved'))).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('refuses another person key before anything is minted', async () => {
|
|
63
|
+
const h = harness({ devicePath: 'uploads/bob/a.pdf' })
|
|
64
|
+
const res = await processDownload('s', 'bob/a.pdf', h.env, h.log, 1000)
|
|
65
|
+
expect(res.status).toBe(403)
|
|
66
|
+
expect(res.url).toBeUndefined()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('reports device-offline rather than a server error when unconfigured', async () => {
|
|
70
|
+
// Same reasoning as the indexed path: from the client's side the file
|
|
71
|
+
// genuinely is unreachable, and the operator's signal is the log line.
|
|
72
|
+
const h = harness({ devicePath: 'uploads/alice/a.pdf' }, { configured: false })
|
|
73
|
+
const res = await processDownload('s', 'alice/a.pdf', h.env, h.log, 1000)
|
|
74
|
+
expect(res.status).toBe(503)
|
|
75
|
+
expect(h.lines.some((l) => l.includes('result=unconfigured'))).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('reports absent for a key with no manifest row at all', async () => {
|
|
79
|
+
const h = harness(null)
|
|
80
|
+
const res = await processDownload('s', 'alice/gone.pdf', h.env, h.log, 1000)
|
|
81
|
+
expect(res.status).toBe(404)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
// The schema is applied by the skill, not by any code this suite can call, so
|
|
6
|
+
// its shape is asserted textually. A missing column here is not a test failure
|
|
7
|
+
// in some other file — it is an upload naming a column D1 does not have.
|
|
8
|
+
const SQL = readFileSync(
|
|
9
|
+
join(__dirname, '../../skills/data-portal/schema.sql'),
|
|
10
|
+
'utf8',
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
describe('portal schema shape', () => {
|
|
14
|
+
it('carries the manifest lifecycle columns', () => {
|
|
15
|
+
expect(SQL).toMatch(/targetPath\s+TEXT\s+NOT NULL\s+DEFAULT\s+''/)
|
|
16
|
+
expect(SQL).toMatch(/devicePath\s+TEXT\s+NOT NULL\s+DEFAULT\s+''/)
|
|
17
|
+
expect(SQL).toMatch(/deleted\s+INTEGER\s+NOT NULL\s+DEFAULT\s+0/)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('carries the directory generation and its pointer table', () => {
|
|
21
|
+
expect(SQL).toMatch(/generation\s+INTEGER\s+NOT NULL\s+DEFAULT\s+0/)
|
|
22
|
+
expect(SQL).toMatch(/CREATE TABLE IF NOT EXISTS directory_state/)
|
|
23
|
+
expect(SQL).toMatch(/currentGeneration\s+INTEGER\s+NOT NULL/)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('keeps objectKey UNIQUE, which the upsert conflict target still needs', () => {
|
|
27
|
+
// objectKey is now ownerId/targetPath/filename. The upsert still conflicts
|
|
28
|
+
// on it, so this pins that the constraint was not dropped by accident while
|
|
29
|
+
// the key shape changed underneath it.
|
|
30
|
+
expect(SQL).toMatch(/objectKey\s+TEXT\s+UNIQUE\s+NOT NULL/)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('scopes the directory uniqueness by generation', () => {
|
|
34
|
+
// Two generations hold the same relPath at once between the write and the
|
|
35
|
+
// flip. UNIQUE (accountId, relPath) would reject the second write and the
|
|
36
|
+
// push could never stage a new generation at all.
|
|
37
|
+
expect(SQL).toMatch(/UNIQUE\s*\(\s*accountId,\s*relPath,\s*generation\s*\)/)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('documents the hand-run ALTER for every added column', () => {
|
|
41
|
+
// IF NOT EXISTS adds no column to an existing table, so a portal deployed
|
|
42
|
+
// before this needs each one by hand. An undocumented column is an operator
|
|
43
|
+
// reading "no such column" with nothing telling them what to run.
|
|
44
|
+
for (const col of ['targetPath', 'devicePath', 'deleted', 'generation']) {
|
|
45
|
+
expect(SQL).toMatch(new RegExp(`ALTER TABLE \\w+ ADD COLUMN ${col}\\b`))
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
})
|