better-supabase 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/dist/{access-sql-B0dF-SoF.js → access-sql-CrSbK1q-.js} +1 -1
- package/dist/{adapter-iKgjHVPx.js → adapter-CN45ibbV.js} +658 -157
- package/dist/{agents-CVHGd7It.d.ts → agents-BY_LQXtQ.d.ts} +4 -4
- package/dist/{ai-cache-CBWs1W0k.js → ai-cache-Dfgk8nBT.js} +2 -1
- package/dist/{ai-cache-DaDu_G5L.d.ts → ai-cache-DrUMMgp6.d.ts} +4 -4
- package/dist/{ai-chat-B24UI962.js → ai-chat-CVLRWbtS.js} +4 -3
- package/dist/{ai-chat-7amvXjmQ.d.ts → ai-chat-DXkMy74O.d.ts} +5 -5
- package/dist/{ai-files-CSPNswUY.js → ai-files-CioLu5du.js} +5 -4
- package/dist/ai-message-DfaP8sis.js +14 -0
- package/dist/ai-sdk/agents/index.d.ts +4 -4
- package/dist/ai-sdk/batches/index.d.ts +4 -4
- package/dist/ai-sdk/batches/index.js +4 -4
- package/dist/ai-sdk/cache/index.d.ts +2 -2
- package/dist/ai-sdk/cache/index.js +2 -2
- package/dist/ai-sdk/chat/index.d.ts +1 -1
- package/dist/ai-sdk/chat/index.js +8 -20
- package/dist/ai-sdk/embeddings/index.d.ts +2 -2
- package/dist/ai-sdk/files/index.d.ts +3 -3
- package/dist/ai-sdk/files/index.js +4 -4
- package/dist/ai-sdk/index.d.ts +37 -9
- package/dist/ai-sdk/index.js +3 -2
- package/dist/ai-sdk/mcp/index.d.ts +43 -6
- package/dist/ai-sdk/mcp/index.js +36 -7
- package/dist/ai-sdk/memory/index.d.ts +2 -2
- package/dist/ai-sdk/memory/index.js +1 -1
- package/dist/ai-sdk/workflow/index.d.ts +6 -6
- package/dist/ai-sdk/workflow/index.js +13 -24
- package/dist/{ai-tasks-Cd1HMFvB.d.ts → ai-tasks-BRQ6iQLc.d.ts} +4 -4
- package/dist/{announcements-ZRGnScWI.d.ts → announcements-Bouy0Ski.d.ts} +3 -3
- package/dist/{announcements-CiyOs-xP.js → announcements-p_BFofiR.js} +1 -1
- package/dist/api-Dnf2q-Ht.d.ts +370 -0
- package/dist/api-DtHFC037.js +676 -0
- package/dist/{api-keys-CIjghYjV.d.ts → api-keys-C8BlF3L2.d.ts} +4 -4
- package/dist/arazzo/index.d.ts +255 -0
- package/dist/arazzo/index.js +622 -0
- package/dist/{assistant-DwdpDH1q.d.ts → assistant-DBlFDZsi.d.ts} +5 -5
- package/dist/astro/index.d.ts +7 -7
- package/dist/astro/index.js +7 -7
- package/dist/asyncapi/index.d.ts +103 -0
- package/dist/asyncapi/index.js +569 -0
- package/dist/authorizer-ayxUDXXP.js +126 -0
- package/dist/authorizer-eiOjo-3l.d.ts +73 -0
- package/dist/better-result/index.d.ts +118 -0
- package/dist/better-result/index.js +64 -0
- package/dist/{bind-l4kAvYSv.js → bind-ButSzrHe.js} +1 -1
- package/dist/{bind-Bu_ZNa3W.d.ts → bind-CQ5mPyX1.d.ts} +3 -3
- package/dist/{block-events-CrT8r7Fe.js → block-events-DTxU5tfr.js} +18 -3
- package/dist/{block-fields-GFkxl4dP.js → block-fields-CroxUuF2.js} +3 -3
- package/dist/{block-fields-SJ6fEf3o.d.ts → block-fields-D1pfrcIY.d.ts} +2 -2
- package/dist/{block-helpers-CKtbFcRv.d.ts → block-helpers-C0Xj8War.d.ts} +2 -2
- package/dist/{block-helpers-DWHxHsEQ.js → block-helpers-Cy3pB96I.js} +41 -9
- package/dist/{block-hooks-BmjMyXTa.js → block-hooks-BPNMIlLl.js} +3 -3
- package/dist/{block-hooks-plbZ0AbJ.d.ts → block-hooks-DaOVC7SC.d.ts} +3 -3
- package/dist/{block-transport-BLr-ZgLc.js → block-transport-bAKh-dGc.js} +28 -2
- package/dist/{block-transport-DvaoPuZ9.d.ts → block-transport-inD4fFq-.d.ts} +24 -2
- package/dist/blocks/agents/index.d.ts +3 -3
- package/dist/blocks/agents/index.js +5 -4
- package/dist/blocks/ai-cache/index.d.ts +3 -3
- package/dist/blocks/ai-cache/index.js +3 -3
- package/dist/blocks/ai-chat/index.d.ts +9 -4
- package/dist/blocks/ai-chat/index.js +5 -5
- package/dist/blocks/ai-chat/react/index.d.ts +3 -3
- package/dist/blocks/ai-chat/react/index.js +5 -5
- package/dist/blocks/ai-files/index.d.ts +3 -3
- package/dist/blocks/ai-files/index.js +3 -3
- package/dist/blocks/ai-providers/index.d.ts +3 -3
- package/dist/blocks/ai-providers/index.js +8 -7
- package/dist/blocks/ai-tasks/index.d.ts +3 -3
- package/dist/blocks/ai-tasks/index.js +7 -6
- package/dist/blocks/announcements/index.d.ts +3 -3
- package/dist/blocks/announcements/index.js +3 -3
- package/dist/blocks/announcements/react/index.d.ts +3 -3
- package/dist/blocks/announcements/react/index.js +3 -3
- package/dist/blocks/api-keys/index.d.ts +5 -5
- package/dist/blocks/api-keys/index.js +8 -8
- package/dist/blocks/attachments/index.d.ts +12 -8
- package/dist/blocks/attachments/index.js +22 -13
- package/dist/blocks/audit/index.d.ts +21 -10
- package/dist/blocks/audit/index.js +30 -11
- package/dist/blocks/billing/index.d.ts +11 -9
- package/dist/blocks/billing/index.js +15 -15
- package/dist/blocks/comments/index.d.ts +8 -8
- package/dist/blocks/comments/index.js +8 -7
- package/dist/blocks/connectors/index.d.ts +3 -3
- package/dist/blocks/connectors/index.js +7 -6
- package/dist/blocks/data-lifecycle/index.d.ts +12 -8
- package/dist/blocks/data-lifecycle/index.js +13 -8
- package/dist/blocks/entitlements/index.d.ts +3 -3
- package/dist/blocks/entitlements/index.js +1 -1
- package/dist/blocks/flags/index.d.ts +4 -4
- package/dist/blocks/flags/index.js +4 -3
- package/dist/blocks/inbox/index.d.ts +3 -3
- package/dist/blocks/inbox/index.js +4 -5
- package/dist/blocks/inbox/react/index.d.ts +1 -1
- package/dist/blocks/inbox/react/index.js +1 -1
- package/dist/blocks/inbox/react/server.d.ts +1 -1
- package/dist/blocks/index.d.ts +10 -10
- package/dist/blocks/index.js +6 -6
- package/dist/blocks/jobs/index.d.ts +48 -32
- package/dist/blocks/jobs/index.js +44 -287
- package/dist/blocks/knowledge/index.d.ts +3 -3
- package/dist/blocks/knowledge/index.js +3 -3
- package/dist/blocks/memory/index.d.ts +4 -4
- package/dist/blocks/memory/index.js +7 -6
- package/dist/blocks/notifications/index.d.ts +11 -9
- package/dist/blocks/notifications/index.js +20 -25
- package/dist/blocks/notifications/react/index.d.ts +1 -1
- package/dist/blocks/notifications/react/index.js +1 -1
- package/dist/blocks/onboarding/index.d.ts +3 -3
- package/dist/blocks/onboarding/index.js +3 -3
- package/dist/blocks/onboarding/react/index.d.ts +2 -2
- package/dist/blocks/onboarding/react/index.js +2 -2
- package/dist/blocks/onboarding/react/server.d.ts +1 -1
- package/dist/blocks/organizations/index.d.ts +9 -9
- package/dist/blocks/organizations/index.js +11 -18
- package/dist/blocks/outbox/index.d.ts +2 -2
- package/dist/blocks/outbox/index.js +16 -25
- package/dist/blocks/profiles/index.d.ts +7 -7
- package/dist/blocks/profiles/index.js +7 -6
- package/dist/blocks/push/index.d.ts +4 -4
- package/dist/blocks/push/index.js +3 -3
- package/dist/blocks/settings/index.d.ts +4 -4
- package/dist/blocks/settings/index.js +7 -6
- package/dist/blocks/sso/index.d.ts +5 -5
- package/dist/blocks/sso/index.js +6 -5
- package/dist/blocks/usage/index.d.ts +3 -3
- package/dist/blocks/usage/index.js +5 -4
- package/dist/blocks/waitlist/index.d.ts +5 -5
- package/dist/blocks/waitlist/index.js +5 -4
- package/dist/blocks/webhooks/index.d.ts +22 -13
- package/dist/blocks/webhooks/index.js +41 -70
- package/dist/blocks/workflow-builder/index.d.ts +4 -4
- package/dist/blocks/workflow-builder/index.js +3 -3
- package/dist/blocks/workflow-builder/react/index.d.ts +4 -4
- package/dist/blocks/workflow-builder/react/index.js +4 -4
- package/dist/blocks/workflows/index.d.ts +3 -3
- package/dist/blocks/workflows/index.js +3 -3
- package/dist/blocks/workflows/react/index.d.ts +3 -3
- package/dist/blocks/workflows/react/index.js +3 -3
- package/dist/build-hPwk3_X5.js +1296 -0
- package/dist/{build-BXIzE6Xv.js → build-meLuYGri.js} +3 -3
- package/dist/casing-DdFWEtqr.js +16 -0
- package/dist/chat-sdk/index.d.ts +6 -6
- package/dist/chat-sdk/index.js +5 -4
- package/dist/chat-sdk/react/index.d.ts +1 -1
- package/dist/chat-sdk/react/server.d.ts +1 -1
- package/dist/{claims-DDBQT6BK.js → claims-0KZVT804.js} +1 -1
- package/dist/cli/add-1dc5wM5k.js +69 -0
- package/dist/cli/arazzo-1.0-BalU61-_.js +532 -0
- package/dist/cli/arazzo-1.1-Bnfasbun.js +705 -0
- package/dist/cli/asyncapi-3.0.0-C78dBjR6.js +5593 -0
- package/dist/cli/asyncapi-3.1.0-g-7aIfV1.js +5833 -0
- package/dist/cli/bin.js +4 -4
- package/dist/cli/cache-CIfA-jBV.js +483 -0
- package/dist/cli/{codemod-Bl_tQpHn.js → codemod-JVlTQQG2.js} +4 -4
- package/dist/cli/{command-CK14fP91.js → command-DV-StG1x.js} +5 -0
- package/dist/cli/{config-DU7EJImt.js → config-C2Pz2_4A.js} +7 -4
- package/dist/cli/config-DpbF8ML3.js +2 -0
- package/dist/cli/{config-NiPxAmUF.js → config-M-WzL9O2.js} +116 -18
- package/dist/cli/{db-DOBPt660.js → db-BkcfOlyB.js} +2 -1
- package/dist/cli/{diff-SjkO5rNr.js → diff-D1jGdb3Y.js} +1 -1
- package/dist/cli/{doctor-DJk0aeBi.js → doctor-VcDtuusV.js} +316 -221
- package/dist/cli/emit-BHHfwUoT.js +1136 -0
- package/dist/cli/{env-qXgyMV6r.js → env-B--LMhEe.js} +13 -10
- package/dist/cli/env-vzBG90Um.js +2 -0
- package/dist/cli/{env-CJLbUVPB.js → env-zHJtMIBo.js} +11 -3
- package/dist/cli/{errors-4v4DTtda.js → errors-CqTLXe2M.js} +18 -2
- package/dist/cli/{fastest-levenshtein-CFN-lNDv.js → fastest-levenshtein-CF7BCi9l.js} +1 -1
- package/dist/cli/files-BVupXPIC.js +34 -0
- package/dist/cli/gen-DmVj3u4L.js +322 -0
- package/dist/cli/{init-BHuEQrQK.js → glue-C1UE8gQN.js} +16 -313
- package/dist/cli/index.d.ts +59 -31
- package/dist/cli/index.js +8 -6
- package/dist/cli/init-CVZ3s5kl.js +258 -0
- package/dist/cli/{introspect-DzR2nyQb.js → introspect-BToGNfm-.js} +14 -14
- package/dist/cli/{io-DI1mTdSA.js → io-CFzJVJ68.js} +5 -1
- package/dist/cli/{keys-BGCnnb-s.js → keys-CJhtIxJ3.js} +4 -4
- package/dist/cli/metadata-DSRKCaf-.js +133 -0
- package/dist/cli/names-D6LrQnOv.js +6 -0
- package/dist/cli/openapi-3.0-t7xwkuRH.js +928 -0
- package/dist/cli/openapi-3.1-CVze7tBS.js +870 -0
- package/dist/cli/openapi-3.2-CFoMYM01.js +1031 -0
- package/dist/cli/{openapi-BlRJ18s3.js → openapi-DWa4Ir1q.js} +8 -14
- package/dist/cli/prompts-BdeVmVpg.js +2 -0
- package/dist/cli/{prompts-BNcNfAve.js → prompts-DZJB1nTw.js} +6 -6
- package/dist/cli/{rolldown-runtime-BPOCksWG.js → rolldown-runtime-CMFfr-1z.js} +3 -1
- package/dist/cli/run-DA6_lg3w.js +389 -0
- package/dist/cli/run-DuN1MX-7.js +705 -0
- package/dist/cli/scaffold-5DenHSxF.js +292 -0
- package/dist/cli/scaffold-CwQUMFRb.js +2 -0
- package/dist/cli/{seed-DQzqT7LP.js → seed-CzBmrGON.js} +14 -18
- package/dist/cli/seed-DOmit9Hq.js +2 -0
- package/dist/cli/{skills-QQs4q68J.js → skills-CnO8QFS7.js} +7 -6
- package/dist/cli/snapshot-Bstk6ulj.js +287 -0
- package/dist/cli/source-args-BYfq6imV.js +31 -0
- package/dist/cli/spec-CzosTYm3.js +7438 -0
- package/dist/cli/spec-T_0Bcyw7.js +2 -0
- package/dist/cli/{sql-B8csQPyn.js → sql-BKOOQAX3.js} +240 -218
- package/dist/cli/sql-DJrNDxQm.js +3 -0
- package/dist/cli/{style-C0NFdJRL.js → style-CB3vxgh0.js} +3 -3
- package/dist/cli/typegen-Demi5-VQ.js +486 -0
- package/dist/cli/validate-Ca13ktgz.js +7522 -0
- package/dist/cli/{version-59gcepIG.js → version-B4k0aUxS.js} +1 -1
- package/dist/client/index.d.ts +4 -3
- package/dist/client/index.js +60 -3
- package/dist/client/native/index.d.ts +3 -3
- package/dist/client/native/index.js +3 -3
- package/dist/{client-CI0Z2zWX.js → client-wxeTQFtB.js} +36 -3
- package/dist/{clone-BfgmLU90.js → clone-dN9XOL1K.js} +9 -1
- package/dist/{cloud-event-BnmD5fb9.js → cloud-event-CTh9Sr5w.js} +16 -2
- package/dist/{cloud-event-C1JCc1bH.d.ts → cloud-event-Dune0KI7.d.ts} +16 -2
- package/dist/{compiler-DY_wkj4R.d.ts → compiler-V1qvcST1.d.ts} +2 -2
- package/dist/{compose-BIBNgAnq.js → compose-BHej1acw.js} +3 -3
- package/dist/{composite--yqry7uW.d.ts → composite-CWAJTRBF.d.ts} +6 -6
- package/dist/{composite-DXVUibdi.js → composite-zTe0TCqj.js} +4 -4
- package/dist/config/index.d.ts +3 -4
- package/dist/config/index.js +4 -4
- package/dist/config-DlKvtt95.js +540 -0
- package/dist/{connector-Dv8kK0MO.d.ts → connector-CySQEFcW.d.ts} +1 -1
- package/dist/{connectors-CrpknU0P.d.ts → connectors--PGhmiu0.d.ts} +4 -4
- package/dist/{context-BCrWe7ow.d.ts → context-DsV2SueA.d.ts} +11 -2
- package/dist/context-nPDvQ-Zl.js +41 -0
- package/dist/credentials/index.d.ts +5 -5
- package/dist/credentials/index.js +9 -17
- package/dist/{cron-DVPUg5k3.js → cron-D25Ukpxy.js} +1 -1
- package/dist/cron-route-D2AN9SA1.js +24 -0
- package/dist/{csv-DCK-PT1n.js → csv-BZnHA1Py.js} +1 -1
- package/dist/{define-B-TVjC25.js → define-CFQcvM20.js} +263 -229
- package/dist/{define-CJJkkr1-.d.ts → define-DmcfoNXB.d.ts} +136 -111
- package/dist/edge/index.d.ts +66 -13
- package/dist/edge/index.js +65 -39
- package/dist/elysia/index.d.ts +1 -1
- package/dist/elysia/index.js +1 -1
- package/dist/{embeddings-C9KGQnXt.d.ts → embeddings-B4vhS-dY.d.ts} +1 -1
- package/dist/{ended-XjeGQ5XW.d.ts → ended-CcvcRqEd.d.ts} +7 -1
- package/dist/{entitlements-DRl6ZJUm.js → entitlements-BYFf0Cdh.js} +2 -2
- package/dist/{entitlements-BKSVGtrG.d.ts → entitlements-Ds8qoYZ-.d.ts} +1 -1
- package/dist/env/index.d.ts +1 -1
- package/dist/env/index.js +9 -2
- package/dist/{errors-B11Duf73.js → errors-2M6CKMP4.js} +5 -0
- package/dist/{errors-DLjJHPLK.js → errors-rZ7VIqp6.js} +2 -2
- package/dist/{escape-like-ImTK0ps_.d.ts → escape-like-DDWoHzh_.d.ts} +1 -1
- package/dist/eve/index.d.ts +5 -5
- package/dist/eve/index.js +5 -11
- package/dist/{eve-gT2Fpjut.d.ts → eve-CoAIcCkq.d.ts} +2 -2
- package/dist/events/index.d.ts +4 -4
- package/dist/events/index.js +7 -2
- package/dist/{events-DWDhx77e.d.ts → events-D1BOCll8.d.ts} +6 -4
- package/dist/{executor-Bq28caBo.js → executor-B-fD7DLt.js} +52 -35
- package/dist/executor-B_Hfobee.js +162 -0
- package/dist/{executor-BVBFM86G.d.ts → executor-CpxLPCiz.d.ts} +1 -1
- package/dist/{executor-CaWigTyR.d.ts → executor-DfEtf0cq.d.ts} +43 -27
- package/dist/{executor-DvsBbNBS.d.ts → executor-Drzu3dBK.d.ts} +2 -2
- package/dist/{executor-DbvExCFC.d.ts → executor-ngjKL_Pe.d.ts} +2 -2
- package/dist/expo/index.d.ts +4 -4
- package/dist/expo/index.js +6 -6
- package/dist/expo-sqlite/index.d.ts +3 -3
- package/dist/expo-sqlite/index.js +2 -2
- package/dist/extra-CGQ2_q3S.js +10 -0
- package/dist/{field-errors-BFOa7WhB.d.ts → field-errors-DIe9XYWL.d.ts} +17 -7
- package/dist/{field-errors-VF1yb8yI.js → field-errors-DZVRWRkf.js} +1 -1
- package/dist/format-DTXdu8ya.d.ts +70 -0
- package/dist/{framework-BmzgK32X.d.ts → framework-148t1m3C.d.ts} +5 -5
- package/dist/{framework-a7aRCIqv.js → framework-3ey3QmrO.js} +2 -2
- package/dist/function-contract-BcZenXMO.d.ts +8 -0
- package/dist/{gateway-C1ssRHnf.d.ts → gateway-S9vicgeo.d.ts} +4 -4
- package/dist/{guards-BnzDvFrz.js → guards-6F2bCtQR.js} +3 -1
- package/dist/h3/index.d.ts +1 -1
- package/dist/h3/index.js +1 -1
- package/dist/h3/v1/index.d.ts +1 -1
- package/dist/h3/v1/index.js +1 -1
- package/dist/{message-CaKlqdsX.js → history-GH1HJWQV.js} +65 -13
- package/dist/hmac-DCud0myt.js +22 -0
- package/dist/hono/index.d.ts +19 -14
- package/dist/hono/index.js +21 -16
- package/dist/{hooks-1BTCJsiw.js → hooks-Bhza9Hnr.js} +68 -100
- package/dist/http-sbmBe5d2.js +25 -0
- package/dist/{inbox-i7E1ppo6.d.ts → inbox-Cd1KCEBL.d.ts} +3 -3
- package/dist/index-B72Rvv_j.d.ts +89 -0
- package/dist/{index-DunNRdnT.d.ts → index-BCHR4Ucl.d.ts} +4 -4
- package/dist/{index-CLg87OBW.d.ts → index-BUK7jUzR.d.ts} +6 -0
- package/dist/{config-D44Y7PAv.d.ts → index-Ba1SI-uO.d.ts} +625 -278
- package/dist/{index-D48jVS-N.d.ts → index-BfxOaOYs.d.ts} +4 -5
- package/dist/{index-ClmjK_o-.d.ts → index-CicIyqOe.d.ts} +4 -4
- package/dist/{index-ACiNPN4Z.d.ts → index-D69h4QqD.d.ts} +1 -1
- package/dist/index-DG2SUXDQ.d.ts +58 -0
- package/dist/{index-Bepi-Hgg.d.ts → index-DoNVLHOU.d.ts} +3 -3
- package/dist/{index-CdZKQF98.d.ts → index-R274XFlg.d.ts} +1 -1
- package/dist/{index-CewnYSpv.d.ts → index-_4R4b01v.d.ts} +8 -18
- package/dist/index.d.ts +89 -14
- package/dist/index.js +12 -11
- package/dist/{json-schema-CvER4kgT.js → json-schema-CT4tvrkA.js} +148 -102
- package/dist/{kit-Fm-Fv7q5.js → kit-BhHr9T9v.js} +36 -10
- package/dist/{kit-CkNzZUqv.d.ts → kit-W99LCPBS.d.ts} +13 -7
- package/dist/{knowledge-BXBbT0Tr.d.ts → knowledge-CAReFpRA.d.ts} +5 -5
- package/dist/{knowledge-CDmiMLew.js → knowledge-DnHtb64A.js} +4 -3
- package/dist/list/index.d.ts +1 -2
- package/dist/{list-query-BRt-zDF7.d.ts → list-query-D3JQeEL7.d.ts} +4 -4
- package/dist/mcp/index.d.ts +49 -19
- package/dist/mcp/index.js +158 -94
- package/dist/mcp/sdk/index.d.ts +38 -6
- package/dist/mcp/sdk/index.js +88 -6
- package/dist/{message-CfDoR0Y6.d.ts → message-B0fNJgq8.d.ts} +1 -2
- package/dist/{messages-B9-5887d.js → messages-CJoHHCWT.js} +4 -205
- package/dist/{mfa-Daak8ukG.d.ts → mfa-SZc9YYyb.d.ts} +1 -1
- package/dist/model-OgBxvk3N.d.ts +300 -0
- package/dist/model-messages-Cxw_Up4f.js +206 -0
- package/dist/model-messages-D4GZllOD.d.ts +34 -0
- package/dist/modules-DksjZyMW.js +77 -0
- package/dist/nestjs/index.d.ts +2 -2
- package/dist/nestjs/index.js +1 -1
- package/dist/next/index.d.ts +105 -55
- package/dist/next/index.js +204 -170
- package/dist/node/index.d.ts +2 -2
- package/dist/node/index.js +1 -1
- package/dist/{onboarding-BZGHYJks.d.ts → onboarding-LZvlK1P4.d.ts} +3 -3
- package/dist/openapi/index.d.ts +4 -2
- package/dist/openapi/index.js +21 -343
- package/dist/optional-peer-D1pi-skw.js +24 -0
- package/dist/orpc/index.d.ts +8 -8
- package/dist/orpc/index.js +21 -20
- package/dist/otel/index.d.ts +3 -3
- package/dist/otel/index.js +7 -7
- package/dist/{outbox-Cq7pPYvl.d.ts → outbox-DZfAvfzC.d.ts} +6 -6
- package/dist/overlay/index.d.ts +233 -0
- package/dist/overlay/index.js +554 -0
- package/dist/{plugin-FGY8UhYm.d.ts → plugin-CPK2HsbY.d.ts} +42 -3
- package/dist/plugins/actor/index.d.ts +1 -1
- package/dist/plugins/actor/index.js +1 -1
- package/dist/plugins/rules/index.d.ts +2 -2
- package/dist/plugins/rules/index.js +1 -1
- package/dist/plugins/soft-delete/index.d.ts +3 -3
- package/dist/plugins/soft-delete/index.js +9 -2
- package/dist/plugins/tenant/index.d.ts +7 -2
- package/dist/plugins/tenant/index.js +16 -2
- package/dist/plugins/timestamps/index.d.ts +1 -1
- package/dist/plugins/timestamps/index.js +8 -1
- package/dist/plugins/validation/index.d.ts +2 -2
- package/dist/plugins/validation/index.js +4 -4
- package/dist/{pool-CAu2nLrL.d.ts → pool-gkhbGp2E.d.ts} +2 -2
- package/dist/postgres/index.d.ts +4 -4
- package/dist/postgres/index.js +2 -1
- package/dist/{postgrest-BLVdEnfe.d.ts → postgrest-BUqLag2Q.d.ts} +1 -1
- package/dist/powersync/index.d.ts +5 -5
- package/dist/powersync/index.js +4 -4
- package/dist/powersync/react/index.d.ts +3 -3
- package/dist/powersync/react/index.js +1 -1
- package/dist/{problem-BreOl8b8.js → problem-CJvFeGu7.js} +13 -2
- package/dist/{problem-DD8LZFjc.d.ts → problem-TvGdxO1t.d.ts} +7 -1
- package/dist/{provider-Di3sRutW.js → provider-DxRTex4x.js} +1 -1
- package/dist/{provider-D-UEMvSD.d.ts → provider-ib9wzUDz.d.ts} +2 -2
- package/dist/query/index.d.ts +2 -2
- package/dist/query/index.js +1 -1
- package/dist/{query-BQ-NaZon.js → query-Ctz-qKRx.js} +1 -1
- package/dist/rate-limit-Bw7HgmFj.js +260 -0
- package/dist/react/index.d.ts +9 -9
- package/dist/react/index.js +4 -4
- package/dist/react/native/index.d.ts +1 -1
- package/dist/react/native/index.js +2 -2
- package/dist/react/server.d.ts +10 -10
- package/dist/react/server.js +3 -3
- package/dist/react/session.d.ts +2 -2
- package/dist/{react-F80mF4-t.js → react-Bmpo59D7.js} +4 -4
- package/dist/react-router/index.d.ts +6 -6
- package/dist/react-router/index.js +9 -11
- package/dist/{read-set-DuKWC9Mj.js → read-set-DAeWhqPf.js} +2 -2
- package/dist/realtime/index.d.ts +3 -3
- package/dist/realtime/index.js +30 -36
- package/dist/{refusal-DhSU2Vvr.d.ts → refusal-CFI8-5nf.d.ts} +4 -1
- package/dist/{refusal-CeJWxmkH.js → refusal-K78A1Vmq.js} +5 -5
- package/dist/{resolve-tJZY3FEa.js → resolve-Bvgf3ut_.js} +131 -42
- package/dist/{resolve-ChTYeTEl.d.ts → resolve-MxXVWyhd.d.ts} +12 -8
- package/dist/resource--vTrBe6I.d.ts +103 -0
- package/dist/resource-BqNqlYg9.js +91 -0
- package/dist/resource-DA811u_4.js +463 -0
- package/dist/resource-execute-rbKvuCsY.d.ts +12 -0
- package/dist/resource-schemas-BJ518zFH.js +79 -0
- package/dist/resource-types-CqB6GWGT.d.ts +141 -0
- package/dist/resources-BVYPnuLo.js +56 -0
- package/dist/resources-DZSk8tXJ.d.ts +7 -0
- package/dist/{respond-DprrgyXX.d.ts → respond-Cw-zanCm.d.ts} +3 -3
- package/dist/{respond-BUgsXNgk.js → respond-volM3ByS.js} +4 -4
- package/dist/{result-D7y2fT87.d.ts → result-BchM1XZn.d.ts} +7 -1
- package/dist/{result-DzUbqgdW.js → result-DEqHCMKP.js} +1 -1
- package/dist/server/index.d.ts +81 -19
- package/dist/server/index.js +16 -14
- package/dist/{server-Bh0EVSwU.d.ts → server-types-CrAmZ8jv.d.ts} +92 -36
- package/dist/{shared-CxWmcyqB.js → shared-DHjhfRdV.js} +2 -15
- package/dist/shared-DTneRoxi.js +14 -0
- package/dist/{sign-in-Cb1mwv6X.js → sign-in-D_yoP0PF.js} +1 -1
- package/dist/signatures-BRS_p3WD.d.ts +34 -0
- package/dist/signatures-DsWv_bYR.js +103 -0
- package/dist/{signers-4y62_VIi.d.ts → signers-cpdmZYrv.d.ts} +1 -1
- package/dist/solid/index.d.ts +9 -18
- package/dist/solid/index.js +2 -2
- package/dist/solid-start/index.d.ts +6 -6
- package/dist/solid-start/index.js +7 -7
- package/dist/spec/index.d.ts +134 -0
- package/dist/spec/index.js +353 -0
- package/dist/{spec-pins-C89s6DyW.d.ts → spec-pins-CwzDY25w.d.ts} +18 -1
- package/dist/{spec-pins--j6mTok4.js → spec-pins-DsLfUH7l.js} +14 -1
- package/dist/sql/index.d.ts +145 -119
- package/dist/sql/index.js +19921 -19798
- package/dist/{executor-De1Of3m3.js → sql-CwuqaQ4Y.js} +34 -169
- package/dist/ssr/index.d.ts +4 -4
- package/dist/ssr/index.js +3 -2
- package/dist/{standard-BK0cqJ7y.js → standard-DkzZUneg.js} +2 -2
- package/dist/{standard-CYsm7xpT.d.ts → standard-lmkbOtPB.d.ts} +1 -1
- package/dist/{stats-D_AD9GGu.js → stats-DhU78Ctf.js} +8 -4
- package/dist/storage/index.d.ts +4 -6
- package/dist/storage/index.js +16 -17
- package/dist/{bucket-Cl46E3jS.d.ts → storage-api-CB7Zl1Ci.d.ts} +243 -8
- package/dist/{store-CGjgWu5n.d.ts → store-DsIYEBnv.d.ts} +1 -1
- package/dist/streams/index.d.ts +4 -4
- package/dist/streams/index.js +5 -4
- package/dist/streams/redis/index.d.ts +1 -1
- package/dist/streams/redis/index.js +5 -11
- package/dist/{stripe-BMj5LtsT.js → stripe-v-Y2lmop.js} +2 -8
- package/dist/supabase-mcp-C6tMoEjn.d.ts +43 -0
- package/dist/supabase-mcp-Cpn5A4i-.js +28 -0
- package/dist/{support-ig_K4PvS.js → support-D1pFFouF.js} +71 -35
- package/dist/{support-view-CVOJJLpc.d.ts → support-view-scqMgtOo.d.ts} +1 -1
- package/dist/svelte/index.d.ts +8 -17
- package/dist/svelte/index.js +2 -2
- package/dist/sveltekit/index.d.ts +7 -7
- package/dist/sveltekit/index.js +10 -12
- package/dist/table-schema-htM_SbCW.js +286 -0
- package/dist/{tags-Cx-LnIQP.d.ts → tags-BO9LjtWG.d.ts} +1 -1
- package/dist/tanstack-db/index.d.ts +2 -2
- package/dist/tanstack-db/supabase/index.d.ts +52 -0
- package/dist/tanstack-db/supabase/index.js +97 -0
- package/dist/tanstack-start/index.d.ts +6 -6
- package/dist/tanstack-start/index.js +8 -12
- package/dist/{tee-CoEppCER.js → tee-Cb98Vn3b.js} +1 -1
- package/dist/{template--jDkl3UP.js → template-lPVh-EwW.js} +11 -2
- package/dist/{temporal-required-BVvW7SYJ.js → temporal-required-BtynQtM-.js} +1 -1
- package/dist/{tenant-BxNFk9bx.d.ts → tenant-CM368LXb.d.ts} +2 -2
- package/dist/testing/index.d.ts +249 -118
- package/dist/testing/index.js +1418 -870
- package/dist/ttl-cache-XzZ-Q2eX.js +29 -0
- package/dist/types-BRTSpOIh.js +5 -0
- package/dist/{types-C05bxW-W.d.ts → types-Bb_4-vdW.d.ts} +4 -4
- package/dist/{types-AYOvcyep.d.ts → types-CD6yCDx3.d.ts} +5 -5
- package/dist/{types-6rocye-y.d.ts → types-CbOUc5IH.d.ts} +4 -4
- package/dist/{types-6BdhkmZe.d.ts → types-CoE5Zwy4.d.ts} +8 -0
- package/dist/{types-DGs5L0VN.d.ts → types-DSJ3-m9X.d.ts} +4 -4
- package/dist/{queue-CLJurhdy.d.ts → types-D__3EN0q.d.ts} +17 -19
- package/dist/{usage-CrvrL2vw.d.ts → usage-D0NNUFfr.d.ts} +3 -3
- package/dist/{vault-wlbBhqm7.d.ts → vault-BpmApxxW.d.ts} +3 -3
- package/dist/vercel-connect/index.d.ts +1 -1
- package/dist/vercel-connect/index.js +5 -11
- package/dist/{verify-DPuWngD4.d.ts → verify-C91Wyp40.d.ts} +5 -35
- package/dist/verify-CJbYfADA.js +124 -0
- package/dist/{version-C6OfRAIn.js → version-Cq8gfDbd.js} +1 -1
- package/dist/{view-BeFcRV6G.js → view-D5ryq3gz.js} +1 -1
- package/dist/{view-CF7ihnkg.d.ts → view-TpXEUcVN.d.ts} +3 -3
- package/dist/vue/index.d.ts +9 -18
- package/dist/vue/index.js +2 -2
- package/dist/{watch-Xuxrvxq-.js → watch-BcvtAHOd.js} +2 -2
- package/dist/{watch-DI34G86j.d.ts → watch-COzRWvVN.d.ts} +23 -9
- package/dist/{wire-DtZQ4sdE.js → wire-Df3EWkG8.js} +2 -2
- package/dist/{workflow-builder-DJ1oHn8g.js → workflow-builder-CHqHpAVt.js} +6 -5
- package/dist/workflow-sdk/builder/index.d.ts +3 -3
- package/dist/workflow-sdk/index.d.ts +1 -1
- package/dist/workflow-sdk/world/index.d.ts +23 -17
- package/dist/workflow-sdk/world/index.js +59 -76
- package/dist/{workflow-sdk-B3yLD6Br.d.ts → workflow-sdk-DTZyrvNo.d.ts} +3 -3
- package/dist/{workflow-sdk-world-D-prir6O.js → workflow-sdk-world-CMV1ahFW.js} +528 -526
- package/dist/{workflows-B0j9WKPu.d.ts → workflows-CcpCBfIe.d.ts} +3 -3
- package/dist/{workflows-wu1WWBn7.js → workflows-IvxzG2cl.js} +5 -4
- package/package.json +54 -3
- package/schemas/config-v1.json +243 -0
- package/schemas/doctor-report-v1.json +4 -1
- package/schemas/spec-manifest-v1.json +95 -0
- package/skills/better-supabase/SKILL.md +8 -4
- package/skills/better-supabase/references/troubleshooting.md +20 -20
- package/skills/better-supabase-api/references/expo.md +6 -3
- package/skills/better-supabase-api/references/powersync.md +2 -1
- package/dist/cli/config-B9HkL8nF.js +0 -2
- package/dist/cli/gen-CCMc39Dx.js +0 -3100
- package/dist/cli/gen-DRmzPI75.js +0 -2
- package/dist/cli/prompts-BTYQGunc.js +0 -2
- package/dist/cli/run-BqDPh9UH.js +0 -361
- package/dist/config-Dn7F5k9A.js +0 -202
- package/dist/history-BZih6SFP.js +0 -64
- package/dist/index-BkWT_uBO.d.ts +0 -51
- package/dist/index-Bxih3l0u.d.ts +0 -81
- package/dist/index-CaxrMD1A.d.ts +0 -1
- package/dist/index-DQh4VCc9.d.ts +0 -98
- package/dist/keys-4MnkWTTh.js +0 -35
- package/dist/messages-DYP0sUTe.d.ts +0 -59
- package/dist/modules-dxrVK3ID.js +0 -228
- package/dist/path-Cv__-OTj.d.ts +0 -17
- package/dist/resource-BsvzCMlX.js +0 -212
- package/dist/resource-IPS5xi9q.d.ts +0 -74
- package/dist/template-CwO38Do8.d.ts +0 -6
- package/dist/verify-DL41hbvY.js +0 -232
- package/dist/cli/{read-sets-CETOSFx4.js → read-sets-DjJrm8Wj.js} +1 -1
package/dist/testing/index.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
import { t as DbException } from "../db-exception-CnWcYPxL.js";
|
|
2
|
-
import { n as dbError } from "../errors-
|
|
2
|
+
import { n as dbError } from "../errors-2M6CKMP4.js";
|
|
3
3
|
import { d as temporalText } from "../temporal-BJGitm-h.js";
|
|
4
|
-
import { t as temporal } from "../temporal-required-
|
|
5
|
-
import { a as ok } from "../result-
|
|
6
|
-
import { t as BetterSupabase } from "../define-
|
|
4
|
+
import { t as temporal } from "../temporal-required-BtynQtM-.js";
|
|
5
|
+
import { a as ok } from "../result-DEqHCMKP.js";
|
|
6
|
+
import { t as BetterSupabase } from "../define-CFQcvM20.js";
|
|
7
|
+
import { t as isList } from "../guards-6F2bCtQR.js";
|
|
7
8
|
import { a as toBase64Url, r as fromBase64, t as base64ToText } from "../base64-D2gsioCY.js";
|
|
8
|
-
import {
|
|
9
|
-
import { a as resolveConfig } from "../modules-dxrVK3ID.js";
|
|
9
|
+
import { o as resolveConfig } from "../config-DlKvtt95.js";
|
|
10
10
|
import { ENV_VARIABLES } from "../env/index.js";
|
|
11
|
-
import { n as postgresExecutor } from "../executor-
|
|
11
|
+
import { n as postgresExecutor } from "../executor-B_Hfobee.js";
|
|
12
12
|
import "../replicas-CwrYp5Zj.js";
|
|
13
|
-
import { a as tenantCredentialRef } from "../provider-
|
|
13
|
+
import { a as tenantCredentialRef } from "../provider-DxRTex4x.js";
|
|
14
|
+
import { i as requestOf, n as checkPermission, r as defineAuthorizer } from "../authorizer-ayxUDXXP.js";
|
|
14
15
|
import { n as parseDbStats } from "../timing-CyTQPubY.js";
|
|
15
|
-
import {
|
|
16
|
-
import { a as isScopeName, c as templateFunctions, l as templatePlaceholders, n as TEMPLATE_PLACEHOLDERS, s as scopeProblems, u as templateProblems } from "../access-sql-
|
|
16
|
+
import { n as rawError } from "../block-transport-bAKh-dGc.js";
|
|
17
|
+
import { a as isScopeName, c as templateFunctions, l as templatePlaceholders, n as TEMPLATE_PLACEHOLDERS, s as scopeProblems, u as templateProblems } from "../access-sql-CrSbK1q-.js";
|
|
17
18
|
import { toCloudEvents } from "../events/index.js";
|
|
19
|
+
import { t as buildApiModel } from "../build-hPwk3_X5.js";
|
|
18
20
|
import { timestamps } from "../plugins/timestamps/index.js";
|
|
19
21
|
import { softDelete } from "../plugins/soft-delete/index.js";
|
|
20
22
|
import { tenant } from "../plugins/tenant/index.js";
|
|
@@ -230,7 +232,10 @@ async function asUser(betterSupabase, claims, stack = {}) {
|
|
|
230
232
|
},
|
|
231
233
|
claims: full
|
|
232
234
|
};
|
|
233
|
-
const supabase = createClient(url, publishableKey, {
|
|
235
|
+
const supabase = createClient(url, publishableKey, {
|
|
236
|
+
accessToken: () => Promise.resolve(token),
|
|
237
|
+
...stack.fetch ? { global: { fetch: stack.fetch } } : {}
|
|
238
|
+
});
|
|
234
239
|
return {
|
|
235
240
|
token,
|
|
236
241
|
claims: full,
|
|
@@ -240,6 +245,141 @@ async function asUser(betterSupabase, claims, stack = {}) {
|
|
|
240
245
|
};
|
|
241
246
|
}
|
|
242
247
|
//#endregion
|
|
248
|
+
//#region src/lite/runtime.ts
|
|
249
|
+
const LITE_INSTALL = "Supabase Lite is an optional peer: install it with `pnpm add -D @supabase/lite`";
|
|
250
|
+
/**
|
|
251
|
+
* `@supabase/lite` is an optional peer: variable specifiers keep bundlers
|
|
252
|
+
* from resolving it, and a missing install returns `undefined` so callers
|
|
253
|
+
* can say how to add it.
|
|
254
|
+
*/
|
|
255
|
+
async function loadLite() {
|
|
256
|
+
const root = "@supabase/lite";
|
|
257
|
+
const pglite = "@supabase/lite/pglite";
|
|
258
|
+
const sqlite = "@supabase/lite/sqlite";
|
|
259
|
+
try {
|
|
260
|
+
const [{ App }, pg, sq] = await Promise.all([
|
|
261
|
+
import(root),
|
|
262
|
+
import(pglite),
|
|
263
|
+
import(sqlite)
|
|
264
|
+
]);
|
|
265
|
+
return {
|
|
266
|
+
App,
|
|
267
|
+
createPgliteConnection: () => pg.createPgliteConnection(),
|
|
268
|
+
createSqliteConnection: (url) => sq.createConnection({ url })
|
|
269
|
+
};
|
|
270
|
+
} catch {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/** Applies migrations, then declarative schemas, with Lite's migrator. */
|
|
275
|
+
async function migrateLite(connection, sql) {
|
|
276
|
+
const ddl = sql.filter((part) => part.trim() !== "").join("\n;\n");
|
|
277
|
+
if (ddl !== "") await connection.createMigrator(ddl).migrate();
|
|
278
|
+
}
|
|
279
|
+
/** Runs seed SQL: as is on PGlite, translated to SQLite on `sqlite-postgres`. */
|
|
280
|
+
async function seedLite(target, sql) {
|
|
281
|
+
if (sql.trim() === "") return;
|
|
282
|
+
if (target.driver === "pglite") {
|
|
283
|
+
await target.connection.driver.exec(sql);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const { connection } = target;
|
|
287
|
+
const translated = await connection.translateDdl(sql);
|
|
288
|
+
const statements = typeof translated === "string" ? translated : typeof translated === "object" && translated !== null && "ddl" in translated && typeof translated.ddl === "string" ? translated.ddl : "";
|
|
289
|
+
if (statements !== "") await connection.transaction([statements]);
|
|
290
|
+
}
|
|
291
|
+
//#endregion
|
|
292
|
+
//#region src/testing/lite.ts
|
|
293
|
+
const LITE_URL = "http://localhost:54321";
|
|
294
|
+
function randomToken(bytes) {
|
|
295
|
+
return toBase64Url(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
296
|
+
}
|
|
297
|
+
function list(value) {
|
|
298
|
+
if (value === void 0) return [];
|
|
299
|
+
return typeof value === "string" ? [value] : [...value];
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Supabase Lite in this process, for tests without Docker: an in-memory
|
|
303
|
+
* database with your schema, Auth with fresh API keys, and supabase-js
|
|
304
|
+
* clients whose requests go straight to Lite's `fetch` handler (no port).
|
|
305
|
+
*
|
|
306
|
+
* ```ts
|
|
307
|
+
* await using stack = await liteStack({ schemas: [await readFile("supabase/schemas/notes.sql", "utf8")] });
|
|
308
|
+
* const alice = await stack.asUser(betterSupabase, { sub: aliceId });
|
|
309
|
+
* expect(await alice.db.notes.count().orThrow()).toBe(1);
|
|
310
|
+
* ```
|
|
311
|
+
*/
|
|
312
|
+
async function liteStack(options = {}) {
|
|
313
|
+
const lite = await loadLite();
|
|
314
|
+
if (!lite) throw new Error(`liteStack() needs @supabase/lite. ${LITE_INSTALL}`);
|
|
315
|
+
const driver = options.driver ?? "sqlite-postgres";
|
|
316
|
+
const jwtSecret = options.jwtSecret ?? randomToken(32);
|
|
317
|
+
const publishableKey = `sb_publishable_${randomToken(16)}`;
|
|
318
|
+
const secretKey = `sb_secret_${randomToken(16)}`;
|
|
319
|
+
let target;
|
|
320
|
+
if (driver === "pglite") target = {
|
|
321
|
+
driver,
|
|
322
|
+
connection: await lite.createPgliteConnection()
|
|
323
|
+
};
|
|
324
|
+
else target = {
|
|
325
|
+
driver,
|
|
326
|
+
connection: await lite.createSqliteConnection(":memory:")
|
|
327
|
+
};
|
|
328
|
+
const { connection } = target;
|
|
329
|
+
try {
|
|
330
|
+
const app = new lite.App({
|
|
331
|
+
connection,
|
|
332
|
+
auth: {
|
|
333
|
+
enabled: true,
|
|
334
|
+
jwt_secret: jwtSecret,
|
|
335
|
+
publishable_key: publishableKey,
|
|
336
|
+
secret_key: secretKey
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
await app.init();
|
|
340
|
+
await migrateLite(connection, [...options.migrations ?? [], ...options.schemas ?? []]);
|
|
341
|
+
for (const seed of list(options.seed)) await seedLite(target, seed);
|
|
342
|
+
const fetch = (input, init) => app.fetch(new Request(input, init));
|
|
343
|
+
const stateless = {
|
|
344
|
+
auth: {
|
|
345
|
+
persistSession: false,
|
|
346
|
+
autoRefreshToken: false
|
|
347
|
+
},
|
|
348
|
+
global: { fetch }
|
|
349
|
+
};
|
|
350
|
+
const close = () => connection.close();
|
|
351
|
+
return {
|
|
352
|
+
url: LITE_URL,
|
|
353
|
+
publishableKey,
|
|
354
|
+
secretKey,
|
|
355
|
+
jwtSecret,
|
|
356
|
+
fetch,
|
|
357
|
+
env: {
|
|
358
|
+
url: LITE_URL,
|
|
359
|
+
publishableKey,
|
|
360
|
+
secretKey,
|
|
361
|
+
secretKeys: { default: secretKey },
|
|
362
|
+
jwksUrl: new URL(`${LITE_URL}/auth/v1/.well-known/jwks.json`),
|
|
363
|
+
jwtSecret
|
|
364
|
+
},
|
|
365
|
+
supabase: createClient(LITE_URL, publishableKey, stateless),
|
|
366
|
+
admin: createClient(LITE_URL, secretKey, stateless),
|
|
367
|
+
asUser: (betterSupabase, claims) => asUser(betterSupabase, claims, {
|
|
368
|
+
url: LITE_URL,
|
|
369
|
+
publishableKey,
|
|
370
|
+
alg: "HS256",
|
|
371
|
+
jwtSecret,
|
|
372
|
+
fetch
|
|
373
|
+
}),
|
|
374
|
+
close,
|
|
375
|
+
[Symbol.asyncDispose]: close
|
|
376
|
+
};
|
|
377
|
+
} catch (error) {
|
|
378
|
+
await connection.close();
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
//#endregion
|
|
243
383
|
//#region src/testing/budget.ts
|
|
244
384
|
function isPrefetch(response) {
|
|
245
385
|
const headers = response.request().headers();
|
|
@@ -449,905 +589,932 @@ function deepFreeze(value) {
|
|
|
449
589
|
}
|
|
450
590
|
const frozenCopy = (value) => deepFreeze(structuredClone(value));
|
|
451
591
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
rows: [],
|
|
458
|
-
count: 0
|
|
459
|
-
}));
|
|
460
|
-
}
|
|
461
|
-
});
|
|
462
|
-
/** The operation `run` sends to the executor, built by `betterSupabase` without touching a database. */
|
|
463
|
-
async function captureOp(betterSupabase, run) {
|
|
464
|
-
const ops = [];
|
|
465
|
-
await run(betterSupabase.connect(recorder(ops)));
|
|
466
|
-
const [op] = ops;
|
|
467
|
-
if (!op) throw new Error("the repository call did not reach the executor");
|
|
468
|
-
return op;
|
|
592
|
+
//#endregion
|
|
593
|
+
//#region src/testing/isolation.ts
|
|
594
|
+
const scalar = (value) => typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
595
|
+
function keyOf(table, row) {
|
|
596
|
+
return Object.fromEntries(table.primaryKey.map((column) => [column, row[column]]));
|
|
469
597
|
}
|
|
470
|
-
function
|
|
471
|
-
const
|
|
472
|
-
if (!
|
|
473
|
-
return
|
|
598
|
+
async function rowsOf(outcome) {
|
|
599
|
+
const result = await outcome;
|
|
600
|
+
if (!result.ok) throw new Error(result.error.message);
|
|
601
|
+
return Array.isArray(result.data) ? result.data : [result.data];
|
|
602
|
+
}
|
|
603
|
+
function env(names) {
|
|
604
|
+
if (typeof process === "undefined") return void 0;
|
|
605
|
+
for (const name of names) {
|
|
606
|
+
const value = process.env[name];
|
|
607
|
+
if (value) return value;
|
|
608
|
+
}
|
|
474
609
|
}
|
|
475
|
-
const hasName = (subject) => ["has a name", () => {
|
|
476
|
-
expect(typeof subject.name === "string" && subject.name.length > 0, "name must be a non-empty string");
|
|
477
|
-
}];
|
|
478
610
|
/**
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
611
|
+
* Checks row-level security between two tenants against a running stack: for
|
|
612
|
+
* each table, a user of one tenant must not select, insert, update or delete
|
|
613
|
+
* the other tenant's rows. Runs without `betterSupabase`'s plugins, so the `tenant()`
|
|
614
|
+
* plugin can't hide a missing policy. Throws a `ConformanceError` naming each
|
|
615
|
+
* table and command that leaks; removes every row it created.
|
|
483
616
|
*
|
|
484
617
|
* ```ts
|
|
485
|
-
*
|
|
618
|
+
* await expectTenantIsolation(betterSupabase, {
|
|
619
|
+
* tenants: [
|
|
620
|
+
* { id: ACME, claims: { sub: alice, tenant_id: ACME } },
|
|
621
|
+
* { id: GLOBEX, claims: { sub: bob, tenant_id: GLOBEX } },
|
|
622
|
+
* ],
|
|
623
|
+
* tables: {
|
|
624
|
+
* tags: {
|
|
625
|
+
* row: (tenant, n) => ({ organizationId: tenant.id, name: `iso-${n}` }),
|
|
626
|
+
* update: { name: 'changed' },
|
|
627
|
+
* },
|
|
628
|
+
* },
|
|
629
|
+
* });
|
|
486
630
|
* ```
|
|
487
631
|
*/
|
|
488
|
-
function
|
|
489
|
-
const
|
|
490
|
-
const
|
|
491
|
-
const
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
}],
|
|
516
|
-
["returns an aborted error for an aborted signal", async () => {
|
|
517
|
-
const controller = new AbortController();
|
|
518
|
-
controller.abort();
|
|
519
|
-
const result = await executor.execute(await read(), {
|
|
520
|
-
...context,
|
|
521
|
-
signal: controller.signal
|
|
522
|
-
});
|
|
523
|
-
expect(!result.ok, "an aborted request must fail");
|
|
524
|
-
expect(result.error.kind === "aborted", `expected kind "aborted", got "${result.error.kind}"`);
|
|
525
|
-
}],
|
|
526
|
-
["returns failures as results instead of throwing", async () => {
|
|
527
|
-
const op = await read();
|
|
528
|
-
const missing = {
|
|
529
|
-
...op,
|
|
530
|
-
table: {
|
|
531
|
-
...op.table,
|
|
532
|
-
name: "__better_supabase_missing__"
|
|
533
|
-
}
|
|
534
|
-
};
|
|
535
|
-
const result = await executor.execute(missing, context);
|
|
536
|
-
expect(!result.ok, "a missing table must fail");
|
|
537
|
-
expect(typeof result.error.kind === "string" && typeof result.error.message === "string", "errors must be DbErrors");
|
|
538
|
-
}],
|
|
539
|
-
executor.rpc && ["returns rpc failures as results", async () => {
|
|
540
|
-
expect(!(await executor.rpc("__better_supabase_missing__", {}, {
|
|
541
|
-
...context,
|
|
542
|
-
schema: "public"
|
|
543
|
-
})).ok, "calling a missing function must fail");
|
|
544
|
-
}],
|
|
545
|
-
executor.functionSources && ["reads from SelectOp.source instead of the table", async () => {
|
|
546
|
-
const op = await read();
|
|
547
|
-
if (op.kind !== "select") return;
|
|
548
|
-
expect(!(await executor.execute({
|
|
549
|
-
...op,
|
|
550
|
-
source: {
|
|
551
|
-
schema: op.table.schema,
|
|
552
|
-
name: "__better_supabase_missing__",
|
|
553
|
-
args: {}
|
|
554
|
-
}
|
|
555
|
-
}, context)).ok, "a missing source function must fail; reading the table ignores source");
|
|
556
|
-
}],
|
|
557
|
-
executor.batch && ["batch returns one result per operation, in order", async () => {
|
|
558
|
-
const rows = await read();
|
|
559
|
-
const count = await captureOp(betterSupabase, (db) => db[table]["count"]());
|
|
560
|
-
const missing = {
|
|
561
|
-
...rows,
|
|
562
|
-
table: {
|
|
563
|
-
...rows.table,
|
|
564
|
-
name: "__better_supabase_missing__"
|
|
565
|
-
}
|
|
566
|
-
};
|
|
567
|
-
const results = await executor.batch([
|
|
568
|
-
rows,
|
|
569
|
-
count,
|
|
570
|
-
missing
|
|
571
|
-
], context);
|
|
572
|
-
expect(results.length === 3, `expected 3 results, got ${results.length}`);
|
|
573
|
-
const [first, second, third] = results;
|
|
574
|
-
expect(first?.ok === true && Array.isArray(first.data.rows), "the first operation must return rows");
|
|
575
|
-
expect(second?.ok === true && typeof second.data.count === "number", "the second operation must return a count");
|
|
576
|
-
expect(third?.ok === false, "a failing operation must fail on its own, as a result");
|
|
577
|
-
}],
|
|
578
|
-
create && ["round-trips a write", async () => {
|
|
579
|
-
const repository = betterSupabase.connect(executor)[table];
|
|
580
|
-
const created = await repository["create"](create);
|
|
581
|
-
expect(created.ok, `create failed: ${JSON.stringify(created.error)}`);
|
|
582
|
-
const row = created.data;
|
|
583
|
-
for (const [key, value] of Object.entries(create)) expect(same(row[key], value), `created row has ${key} = ${JSON.stringify(row[key])}`);
|
|
584
|
-
const [primary] = betterSupabase.meta.tables[table].primaryKey;
|
|
585
|
-
expect(primary, "write checks need a table with a primary key");
|
|
586
|
-
const removed = await repository["delete"](row[primary]);
|
|
587
|
-
expect(removed.ok, `delete failed: ${JSON.stringify(removed.error)}`);
|
|
588
|
-
const again = await repository["findFirst"]({ where: { [primary]: row[primary] } });
|
|
589
|
-
expect(again.ok && again.data === null, "the deleted row is still readable");
|
|
590
|
-
}],
|
|
591
|
-
create && ["refuses a write past maxAffected and writes nothing", async () => {
|
|
592
|
-
const repository = betterSupabase.connect(executor)[table];
|
|
593
|
-
const [primary] = betterSupabase.meta.tables[table].primaryKey;
|
|
594
|
-
expect(primary, "write checks need a table with a primary key");
|
|
595
|
-
const created = await repository["create"](create);
|
|
596
|
-
expect(created.ok, `create failed: ${JSON.stringify(created.error)}`);
|
|
597
|
-
const id = created.data[primary];
|
|
598
|
-
try {
|
|
599
|
-
const refused = await repository["deleteMany"]({
|
|
600
|
-
where: { [primary]: id },
|
|
601
|
-
maxAffected: 0
|
|
602
|
-
});
|
|
603
|
-
expect(!refused.ok, "deleteMany past maxAffected must fail");
|
|
604
|
-
const kind = refused.error?.kind;
|
|
605
|
-
expect(kind === "max_affected", `expected kind "max_affected", got "${String(kind)}"`);
|
|
606
|
-
const kept = await repository["findFirst"]({ where: { [primary]: id } });
|
|
607
|
-
expect(kept.ok && kept.data !== null, "a refused deleteMany must not delete the row");
|
|
608
|
-
const allowed = await repository["deleteMany"]({
|
|
609
|
-
where: { [primary]: id },
|
|
610
|
-
maxAffected: 1
|
|
611
|
-
});
|
|
612
|
-
expect(allowed.ok, `deleteMany within maxAffected failed: ${JSON.stringify(allowed.error)}`);
|
|
613
|
-
} finally {
|
|
614
|
-
await repository["delete"](id);
|
|
632
|
+
async function expectTenantIsolation(betterSupabase, options) {
|
|
633
|
+
const stack = options.stack ?? {};
|
|
634
|
+
const bare = new BetterSupabase(betterSupabase.schema);
|
|
635
|
+
const url = stack.url ?? env(ENV_VARIABLES.url) ?? "http://127.0.0.1:54321";
|
|
636
|
+
const secretKey = stack.secretKey ?? env(ENV_VARIABLES.secretKey);
|
|
637
|
+
if (!secretKey) throw new TypeError("expectTenantIsolation needs stack.secretKey or $SUPABASE_SECRET_KEY to seed rows.");
|
|
638
|
+
const admin = bare.connect(createClient(url, secretKey, { auth: {
|
|
639
|
+
persistSession: false,
|
|
640
|
+
autoRefreshToken: false
|
|
641
|
+
} }));
|
|
642
|
+
await options.seed?.();
|
|
643
|
+
const tenants = options.tenants;
|
|
644
|
+
const users = await Promise.all(tenants.map(async (tenant) => (await asUser(bare, tenant.claims, stack)).db));
|
|
645
|
+
const label = (tenant) => tenant.name ?? tenant.id;
|
|
646
|
+
const cleanup = [];
|
|
647
|
+
const reads = [];
|
|
648
|
+
const deletes = [];
|
|
649
|
+
try {
|
|
650
|
+
for (const [key, spec] of Object.entries(options.tables)) {
|
|
651
|
+
const table = bare.meta.tables[key];
|
|
652
|
+
if (!table || table.primaryKey.length === 0) throw new TypeError(`expectTenantIsolation: "${key}" is not a table with a primary key`);
|
|
653
|
+
const seeded = [];
|
|
654
|
+
for (const tenant of tenants) {
|
|
655
|
+
const [row] = await rowsOf(admin[key].create(spec.row(tenant, 0)));
|
|
656
|
+
const where = keyOf(table, row);
|
|
657
|
+
cleanup.push(() => admin[key].deleteMany({ where }));
|
|
658
|
+
seeded.push(row);
|
|
615
659
|
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
table: "__better_supabase_unknown__",
|
|
653
|
-
tables: ["__better_supabase_unknown__"],
|
|
654
|
-
ids: []
|
|
655
|
-
})],
|
|
656
|
-
["invalidates tables reached through foreign keys", () => invalidate({
|
|
657
|
-
table,
|
|
658
|
-
tables: [table, `${table}_children`],
|
|
659
|
-
ids: ["1"]
|
|
660
|
-
})]
|
|
661
|
-
]);
|
|
662
|
-
}
|
|
663
|
-
/** Proves an `EventSink` accepts empty and full batches without mutating them, and delivers them when readable. */
|
|
664
|
-
function testEventSink(sink, options = {}) {
|
|
665
|
-
const events = toCloudEvents({
|
|
666
|
-
table: "customers",
|
|
667
|
-
kind: "insert",
|
|
668
|
-
rows: [{
|
|
669
|
-
id: "1",
|
|
670
|
-
name: "Acme"
|
|
671
|
-
}, {
|
|
672
|
-
id: "2",
|
|
673
|
-
name: "Globex"
|
|
674
|
-
}],
|
|
675
|
-
context: { tenant: "organization_1" }
|
|
676
|
-
}, { source: "/better-supabase/conformance" });
|
|
677
|
-
return conform("EventSink", [
|
|
678
|
-
["accepts an empty batch", () => sink.send(deepFreeze([]))],
|
|
679
|
-
["accepts a batch without mutating it", () => sink.send(frozenCopy(events))],
|
|
680
|
-
options.received && ["delivers every event", async () => {
|
|
681
|
-
const ids = new Set((await options.received()).map((event) => event.id));
|
|
682
|
-
const missing = events.filter((event) => !ids.has(event.id));
|
|
683
|
-
expect(missing.length === 0, `${missing.length} of ${events.length} events were not delivered`);
|
|
684
|
-
}]
|
|
685
|
-
]);
|
|
686
|
-
}
|
|
687
|
-
function claimedJob(queue, row) {
|
|
688
|
-
const instant = temporal().Instant.fromEpochMilliseconds(0);
|
|
689
|
-
return {
|
|
690
|
-
id: Number(row.id),
|
|
691
|
-
queue,
|
|
692
|
-
payload: row.message?.payload,
|
|
693
|
-
attempts: row.attempts,
|
|
694
|
-
maxAttempts: row.message?.max_attempts ?? 5,
|
|
695
|
-
enqueuedAt: instant,
|
|
696
|
-
visibleUntil: instant,
|
|
697
|
-
lastError: row.message?.last_error ?? null,
|
|
698
|
-
context: {}
|
|
699
|
-
};
|
|
700
|
-
}
|
|
701
|
-
/**
|
|
702
|
-
* Proves a `QueueBackend` round-trips payloads, hides claimed messages for
|
|
703
|
-
* their lease, counts attempts, retries and dead-letters on `fail`, and
|
|
704
|
-
* (when it has `leases`) rejects a stale attempt and extends a lease. It
|
|
705
|
-
* writes to `options.queue` on the backend's real store.
|
|
706
|
-
*/
|
|
707
|
-
function testQueueBackend(backend, options) {
|
|
708
|
-
const { queue } = options;
|
|
709
|
-
const claimOne = async () => {
|
|
710
|
-
const [row] = await backend.read(queue, 30, 1);
|
|
711
|
-
return row && claimedJob(queue, row);
|
|
712
|
-
};
|
|
713
|
-
return conform(`QueueBackend "${backend.name}"`, [
|
|
714
|
-
hasName(backend),
|
|
715
|
-
["has apiVersion 1", () => {
|
|
716
|
-
const version = backend.apiVersion;
|
|
717
|
-
expect(version === 1, "apiVersion must be 1");
|
|
718
|
-
}],
|
|
719
|
-
["round-trips a payload and leases it", async () => {
|
|
720
|
-
const id = await backend.send(queue, { n: 1 }, 0, 3, void 0);
|
|
721
|
-
expect(Number.isFinite(id), "send must return a numeric id");
|
|
722
|
-
const job = await claimOne();
|
|
723
|
-
expect(job?.id === id, "read must return the sent message");
|
|
724
|
-
expect(same(job.payload, { n: 1 }), "the payload must round-trip");
|
|
725
|
-
expect(job.attempts === 1, "the first claim must be attempt 1");
|
|
726
|
-
expect(job.maxAttempts === 3, "max_attempts must round-trip");
|
|
727
|
-
expect(await claimOne() === void 0, "a leased message must stay hidden from other reads");
|
|
728
|
-
expect(await backend.complete(job), "complete must return true");
|
|
729
|
-
}],
|
|
730
|
-
backend.leases && ["rejects a stale attempt and extends a lease", async () => {
|
|
731
|
-
await backend.send(queue, { n: 2 }, 0, 3, void 0);
|
|
732
|
-
const job = await claimOne();
|
|
733
|
-
expect(job, "read must return the sent message");
|
|
734
|
-
expect(!await backend.complete({
|
|
735
|
-
...job,
|
|
736
|
-
attempts: job.attempts + 1
|
|
737
|
-
}), "complete with another attempt must return false");
|
|
738
|
-
expect(await backend.extend(job, 60), "extend must return true");
|
|
739
|
-
expect(await backend.complete(job), "complete must return true");
|
|
740
|
-
}],
|
|
741
|
-
["retries, then dead-letters at max attempts", async () => {
|
|
742
|
-
await backend.send(queue, { n: 3 }, 0, 2, void 0);
|
|
743
|
-
const first = await claimOne();
|
|
744
|
-
expect(first, "read must return the sent message");
|
|
745
|
-
expect(await backend.fail(first, "boom", 0) === "queued", "fail before max_attempts must return queued");
|
|
746
|
-
const second = await claimOne();
|
|
747
|
-
expect(second?.attempts === 2, "a retried message must be attempt 2");
|
|
748
|
-
expect(await backend.fail(second, "boom", 0) === "dead", "fail at max_attempts must return dead");
|
|
749
|
-
expect(await claimOne() === void 0, "a dead message must not be claimed again");
|
|
750
|
-
}],
|
|
751
|
-
backend.stats && backend.listDead && ["counts and lists dead letters", async () => {
|
|
752
|
-
expect(((await backend.stats?.(queue))?.dead ?? 0) >= 1, "stats must count the dead letter");
|
|
753
|
-
const [dead] = await backend.listDead?.(queue, 10, void 0) ?? [];
|
|
754
|
-
expect(dead, "listDead must return the dead letter");
|
|
755
|
-
expect(same(dead.message?.payload, { n: 3 }), "listDead must return the newest dead letter first");
|
|
756
|
-
if (backend.retryDead) {
|
|
757
|
-
expect(await backend.retryDead(queue, [Number(dead.id)], 10) === 1, "retryDead must enqueue the dead letter again");
|
|
758
|
-
const job = await claimOne();
|
|
759
|
-
expect(job, "the retried job must be claimable");
|
|
760
|
-
expect(same(job.payload, { n: 3 }), "the retried job must keep its payload");
|
|
761
|
-
await backend.complete(job);
|
|
660
|
+
for (const [self, other] of [[0, 1], [1, 0]]) {
|
|
661
|
+
const user = users[self][key];
|
|
662
|
+
const who = label(tenants[self]);
|
|
663
|
+
const whose = `${label(tenants[other])}'s`;
|
|
664
|
+
const own = keyOf(table, seeded[self]);
|
|
665
|
+
const target = keyOf(table, seeded[other]);
|
|
666
|
+
const current = async () => (await rowsOf(admin[key].findMany({ where: target })))[0];
|
|
667
|
+
reads.push([`${key}: ${who} reads its own row`, async () => {
|
|
668
|
+
expect((await rowsOf(user.findMany({ where: own }))).length === 1, `${who} can't read its own row, so the other checks prove nothing; check its claims and memberships`);
|
|
669
|
+
}], [`${key}: ${who} can't select ${whose} rows`, async () => {
|
|
670
|
+
expect((await rowsOf(user.findMany({ where: target }))).length === 0, `select returned ${whose} row`);
|
|
671
|
+
}], [`${key}: ${who} can't insert ${whose} rows`, async () => {
|
|
672
|
+
const row = spec.row(tenants[other], 1);
|
|
673
|
+
const result = await user.create(row, { returning: false });
|
|
674
|
+
if (result.ok) {
|
|
675
|
+
const where = Object.fromEntries(Object.entries(row).filter(([, value]) => scalar(value)));
|
|
676
|
+
cleanup.push(() => admin[key].deleteMany({ where }));
|
|
677
|
+
}
|
|
678
|
+
expect(!result.ok, `insert of a row for ${whose} tenant succeeded`);
|
|
679
|
+
expect(result.error.kind === "forbidden", `insert failed with ${result.error.kind} (${result.error.message}), not an RLS error; check the row factory`);
|
|
680
|
+
}], [`${key}: ${who} can't update ${whose} rows`, async () => {
|
|
681
|
+
const before = await current();
|
|
682
|
+
const patch = spec.update;
|
|
683
|
+
expect(Object.entries(patch).some(([column, value]) => before?.[column] !== value), "update must change the row to detect a leak");
|
|
684
|
+
await user.updateMany({
|
|
685
|
+
where: target,
|
|
686
|
+
data: patch,
|
|
687
|
+
returning: false
|
|
688
|
+
});
|
|
689
|
+
const after = await current();
|
|
690
|
+
expect(Object.entries(patch).every(([column]) => after?.[column] === before?.[column]), `update changed ${whose} row`);
|
|
691
|
+
}]);
|
|
692
|
+
deletes.push([`${key}: ${who} can't delete ${whose} rows`, async () => {
|
|
693
|
+
await user.deleteMany({ where: target });
|
|
694
|
+
expect(await current() !== void 0, `delete removed ${whose} row`);
|
|
695
|
+
}]);
|
|
762
696
|
}
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
await backend.complete(job);
|
|
769
|
-
}]
|
|
770
|
-
]);
|
|
697
|
+
}
|
|
698
|
+
return await conform("Tenant isolation", [...reads, ...deletes]);
|
|
699
|
+
} finally {
|
|
700
|
+
for (const remove of cleanup.reverse()) await remove();
|
|
701
|
+
}
|
|
771
702
|
}
|
|
703
|
+
//#endregion
|
|
704
|
+
//#region src/testing/claims-fixtures.ts
|
|
705
|
+
const base = {
|
|
706
|
+
sub: "6f1c2c1e-5d0a-4d9e-9a51-6b1f0e7c2a10",
|
|
707
|
+
aud: "authenticated",
|
|
708
|
+
role: "authenticated",
|
|
709
|
+
iss: "https://project.supabase.co/auth/v1",
|
|
710
|
+
aal: "aal1",
|
|
711
|
+
session_id: "b7a8f9c0-1111-4222-8333-944455556666",
|
|
712
|
+
is_anonymous: false
|
|
713
|
+
};
|
|
714
|
+
const admin = "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d";
|
|
715
|
+
const supportSessionId = "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b";
|
|
716
|
+
const client = "5f0e4d3c-2b1a-4098-8776-655443322110";
|
|
717
|
+
const chain = {
|
|
718
|
+
sub: "agent-runner",
|
|
719
|
+
act: { sub: "mcp-client-42" }
|
|
720
|
+
};
|
|
772
721
|
/**
|
|
773
|
-
*
|
|
774
|
-
*
|
|
775
|
-
*
|
|
776
|
-
*
|
|
722
|
+
* `supportSession` and `supportSessionReadOnly` are what `supportClaims`
|
|
723
|
+
* mints (`act.kind: "support"`), `impersonation` what `actingAs` mints
|
|
724
|
+
* (`act.kind: "impersonation"`), `oauthClient` a Supabase OAuth server token
|
|
725
|
+
* and `agentChain` an RFC 8693 chain without `kind`.
|
|
777
726
|
*/
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
metadata: { ticket: "T-1" }
|
|
789
|
-
});
|
|
790
|
-
return conform(`SupportSessionStore "${store.name}"`, [
|
|
791
|
-
hasName(store),
|
|
792
|
-
["has apiVersion 1", () => {
|
|
793
|
-
const version = store.apiVersion;
|
|
794
|
-
expect(version === 1, "apiVersion must be 1");
|
|
795
|
-
}],
|
|
796
|
-
["starts a session with its input and shows it to its admin only", async () => {
|
|
797
|
-
const session = await start(first);
|
|
798
|
-
try {
|
|
799
|
-
expect(session.id.length > 0, "start must return an id");
|
|
800
|
-
expect(session.adminId === admin.id, "adminId must round-trip");
|
|
801
|
-
expect(session.targetUserId === first, "targetUserId must round-trip");
|
|
802
|
-
expect(session.reason === "conformance", "reason must round-trip");
|
|
803
|
-
expect(session.readOnly, "readOnly must round-trip");
|
|
804
|
-
expect(same(session.metadata, { ticket: "T-1" }), "metadata must round-trip");
|
|
805
|
-
const seconds = (session.expiresAt.epochMilliseconds - session.startedAt.epochMilliseconds) / 1e3;
|
|
806
|
-
expect(Math.abs(seconds - 600) < 5, "expiresAt must be ttlSeconds after startedAt");
|
|
807
|
-
expect((await store.get(session.id, admin.id))?.id === session.id, "get must return the running session");
|
|
808
|
-
expect(await store.get(session.id, first) === void 0, "get must hide a session from anyone but its admin");
|
|
809
|
-
} finally {
|
|
810
|
-
await store.end(session.id, "admin");
|
|
727
|
+
const supabaseClaimFixtures = {
|
|
728
|
+
supportSession: {
|
|
729
|
+
claims: {
|
|
730
|
+
...base,
|
|
731
|
+
act: {
|
|
732
|
+
kind: "support",
|
|
733
|
+
sub: admin,
|
|
734
|
+
reason: "ticket 42",
|
|
735
|
+
session_id: supportSessionId,
|
|
736
|
+
read_only: false
|
|
811
737
|
}
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
738
|
+
},
|
|
739
|
+
expect: {
|
|
740
|
+
actor: {
|
|
741
|
+
kind: "support",
|
|
742
|
+
id: admin,
|
|
743
|
+
sessionId: supportSessionId,
|
|
744
|
+
readOnly: false,
|
|
745
|
+
reason: "ticket 42"
|
|
746
|
+
},
|
|
747
|
+
impersonator: {
|
|
748
|
+
kind: "support",
|
|
749
|
+
id: admin,
|
|
750
|
+
sessionId: supportSessionId,
|
|
751
|
+
readOnly: false,
|
|
752
|
+
reason: "ticket 42"
|
|
821
753
|
}
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
754
|
+
}
|
|
755
|
+
},
|
|
756
|
+
supportSessionReadOnly: {
|
|
757
|
+
claims: {
|
|
758
|
+
...base,
|
|
759
|
+
act: {
|
|
760
|
+
kind: "support",
|
|
761
|
+
sub: admin,
|
|
762
|
+
reason: "ticket 42",
|
|
763
|
+
session_id: supportSessionId,
|
|
764
|
+
read_only: true
|
|
765
|
+
}
|
|
766
|
+
},
|
|
767
|
+
expect: {
|
|
768
|
+
actor: {
|
|
769
|
+
kind: "support",
|
|
770
|
+
id: admin,
|
|
771
|
+
sessionId: supportSessionId,
|
|
772
|
+
readOnly: true,
|
|
773
|
+
reason: "ticket 42"
|
|
774
|
+
},
|
|
775
|
+
impersonator: {
|
|
776
|
+
kind: "support",
|
|
777
|
+
id: admin,
|
|
778
|
+
sessionId: supportSessionId,
|
|
779
|
+
readOnly: true,
|
|
780
|
+
reason: "ticket 42"
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
},
|
|
784
|
+
impersonation: {
|
|
785
|
+
claims: {
|
|
786
|
+
...base,
|
|
787
|
+
act: {
|
|
788
|
+
kind: "impersonation",
|
|
789
|
+
sub: admin,
|
|
790
|
+
reason: "ticket 42"
|
|
791
|
+
}
|
|
792
|
+
},
|
|
793
|
+
expect: {
|
|
794
|
+
actor: {
|
|
795
|
+
kind: "impersonation",
|
|
796
|
+
id: admin,
|
|
797
|
+
reason: "ticket 42"
|
|
798
|
+
},
|
|
799
|
+
impersonator: {
|
|
800
|
+
kind: "impersonation",
|
|
801
|
+
id: admin,
|
|
802
|
+
reason: "ticket 42"
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
},
|
|
806
|
+
oauthClient: {
|
|
807
|
+
claims: {
|
|
808
|
+
...base,
|
|
809
|
+
client_id: client,
|
|
810
|
+
scope: "openid email posts:read"
|
|
811
|
+
},
|
|
812
|
+
expect: {
|
|
813
|
+
actor: {
|
|
814
|
+
kind: "oauth-client",
|
|
815
|
+
id: client
|
|
816
|
+
},
|
|
817
|
+
delegation: { scopes: [
|
|
818
|
+
"openid",
|
|
819
|
+
"email",
|
|
820
|
+
"posts:read"
|
|
821
|
+
] }
|
|
822
|
+
}
|
|
823
|
+
},
|
|
824
|
+
agentChain: {
|
|
825
|
+
claims: {
|
|
826
|
+
...base,
|
|
827
|
+
scope: "posts:read",
|
|
828
|
+
act: chain
|
|
829
|
+
},
|
|
830
|
+
expect: {
|
|
831
|
+
actor: {
|
|
832
|
+
kind: "oauth-client",
|
|
833
|
+
id: "agent-runner",
|
|
834
|
+
chain
|
|
835
|
+
},
|
|
836
|
+
delegation: {
|
|
837
|
+
scopes: ["posts:read"],
|
|
838
|
+
chain
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
//#endregion
|
|
844
|
+
//#region src/testing/seed.ts
|
|
845
|
+
const SEED = Symbol.for("better-supabase.seed");
|
|
846
|
+
function isSeed(value) {
|
|
847
|
+
return typeof value === "object" && value !== null && SEED in value;
|
|
840
848
|
}
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
849
|
+
const quote = (text) => `'${text.replaceAll("'", "''")}'`;
|
|
850
|
+
function arrayLiteral(values) {
|
|
851
|
+
return `{${values.map((value) => {
|
|
852
|
+
if (value === null) return "NULL";
|
|
853
|
+
if (Array.isArray(value)) return arrayLiteral(value);
|
|
854
|
+
return `"${(temporalText(value) ?? String(value)).replaceAll(/[\\"]/g, "\\$&")}"`;
|
|
855
|
+
}).join(",")}}`;
|
|
856
|
+
}
|
|
857
|
+
function literal(value, column, where) {
|
|
858
|
+
if (value === null) return "null";
|
|
859
|
+
if (column.json) return quote(JSON.stringify(value));
|
|
860
|
+
if (Array.isArray(value)) return quote(arrayLiteral(value));
|
|
861
|
+
const temporalValue = temporalText(value);
|
|
862
|
+
if (temporalValue !== void 0) return quote(temporalValue);
|
|
863
|
+
const kind = typeof value;
|
|
864
|
+
switch (kind) {
|
|
865
|
+
case "string": return quote(value);
|
|
866
|
+
case "boolean":
|
|
867
|
+
case "bigint": return String(value);
|
|
868
|
+
case "number":
|
|
869
|
+
if (!Number.isFinite(value)) throw new TypeError(`${where}: ${String(value)} is not a finite number`);
|
|
870
|
+
return String(value);
|
|
871
|
+
case "object":
|
|
872
|
+
case "function":
|
|
873
|
+
case "symbol":
|
|
874
|
+
case "undefined": throw new TypeError(`${where}: unsupported value of type ${kind}`);
|
|
875
|
+
default: throw new TypeError(`${where}: unsupported value of type ${String(kind)}`);
|
|
846
876
|
}
|
|
847
877
|
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
])
|
|
878
|
+
const ident = (name) => `"${name.replaceAll("\"", "\"\"")}"`;
|
|
879
|
+
/** Parents first (by forward relations), otherwise in the order given. */
|
|
880
|
+
function order(meta, keys) {
|
|
881
|
+
const sorted = [];
|
|
882
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
883
|
+
const visit = (key) => {
|
|
884
|
+
if (sorted.includes(key) || visiting.has(key)) return;
|
|
885
|
+
visiting.add(key);
|
|
886
|
+
for (const relation of Object.values(meta.tables[key]?.relations ?? {})) if (relation.direction === "forward" && relation.table !== key && keys.includes(relation.table)) visit(relation.table);
|
|
887
|
+
visiting.delete(key);
|
|
888
|
+
sorted.push(key);
|
|
889
|
+
};
|
|
890
|
+
for (const key of keys) visit(key);
|
|
891
|
+
return sorted;
|
|
892
|
+
}
|
|
893
|
+
function statement(table, rows) {
|
|
894
|
+
const entries = Object.entries(rows);
|
|
895
|
+
if (entries.length === 0) return void 0;
|
|
896
|
+
const columns = [];
|
|
897
|
+
for (const [, row] of entries) for (const [key, value] of Object.entries(row)) {
|
|
898
|
+
if (value === void 0 || columns.includes(key)) continue;
|
|
899
|
+
const column = table.columns[key];
|
|
900
|
+
if (!column) throw new TypeError(`seed ${table.key}: unknown column "${key}"`);
|
|
901
|
+
if (column.generated) throw new TypeError(`seed ${table.key}: "${key}" is a generated column`);
|
|
902
|
+
columns.push(key);
|
|
903
|
+
}
|
|
904
|
+
const values = entries.map(([name, row]) => {
|
|
905
|
+
return ` (${columns.map((key) => row[key] === void 0 ? "default" : literal(row[key], table.columns[key], `seed ${table.key}.${name}.${key}`)).join(", ")})`;
|
|
906
|
+
});
|
|
907
|
+
return `insert into ${`${ident(table.schema)}.${ident(table.name)}`} (${columns.map((key) => ident(table.columns[key].db)).join(", ")}) values\n${values.join(",\n")}\non conflict do nothing;`;
|
|
875
908
|
}
|
|
876
909
|
/**
|
|
877
|
-
*
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
*
|
|
910
|
+
* Typed fixtures shared by `supabase/seeds` and your tests. Rows use app
|
|
911
|
+
* casing and Insert types; `better-supabase seed` renders them to SQL.
|
|
912
|
+
*
|
|
913
|
+
* ```ts
|
|
914
|
+
* export const seed = defineSeed(betterSupabase, {
|
|
915
|
+
* organizations: { acme: { id: ACME, name: 'Acme' } },
|
|
916
|
+
* customers: { first: { organizationId: ACME, name: 'First customer' } },
|
|
917
|
+
* });
|
|
918
|
+
* ```
|
|
882
919
|
*/
|
|
883
|
-
function
|
|
884
|
-
const
|
|
885
|
-
const
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
expect(enforce === void 0 || enforce === "first" || enforce === "pre" || enforce === "post", `enforce is ${String(enforce)}; use "first", "pre", "post" or leave it out`);
|
|
907
|
-
}],
|
|
908
|
-
["installs next to the first-party plugins in either order", () => {
|
|
909
|
-
const others = [
|
|
910
|
-
timestamps(),
|
|
911
|
-
softDelete(),
|
|
912
|
-
tenant(),
|
|
913
|
-
actor()
|
|
914
|
-
].filter((other) => other.name !== plugin.name);
|
|
915
|
-
let before = betterSupabase;
|
|
916
|
-
for (const other of others) before = before.use(other);
|
|
917
|
-
let after = betterSupabase.use(plugin);
|
|
918
|
-
for (const other of others) after = after.use(other);
|
|
919
|
-
for (const installed of [before.use(plugin), after]) {
|
|
920
|
-
const db = installed.connect(recorder([]), context);
|
|
921
|
-
for (const key of Object.keys(betterSupabase.meta.tables)) expect(db[key], `db.${key} is missing`);
|
|
922
|
-
}
|
|
923
|
-
}],
|
|
924
|
-
plugin.repository && ["repository adds functions and keeps the base methods", () => {
|
|
925
|
-
const db = betterSupabase.connect(recorder([]), context);
|
|
926
|
-
for (const [key, table] of Object.entries(betterSupabase.meta.tables)) {
|
|
927
|
-
const base = db[key];
|
|
928
|
-
const added = plugin.repository({
|
|
929
|
-
table,
|
|
930
|
-
base
|
|
931
|
-
});
|
|
932
|
-
if (added === void 0) continue;
|
|
933
|
-
for (const [name, method] of Object.entries(added)) {
|
|
934
|
-
expect(typeof method === "function", `${key}.${name} is not a function`);
|
|
935
|
-
expect(!(name in base), `${key}.${name} replaces a base method`);
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
}],
|
|
939
|
-
plugin.transformQuery && ["transformQuery is pure and deterministic", async () => {
|
|
940
|
-
const op = await captureOp(betterSupabase, (db) => db[table]["findMany"]({ limit: 1 }));
|
|
941
|
-
const first = plugin.transformQuery(frozenCopy(op), hook(op));
|
|
942
|
-
const second = plugin.transformQuery(frozenCopy(op), hook(op));
|
|
943
|
-
expect(typeof first === "object" && first !== null && first.kind === op.kind, "must return an operation of the same kind");
|
|
944
|
-
expect(same(first, second), "two calls with the same input differ");
|
|
945
|
-
}],
|
|
946
|
-
plugin.beforeMutation && create && ["beforeMutation is pure and deterministic", async () => {
|
|
947
|
-
const op = await captureOp(betterSupabase, (db) => db[table]["create"](create));
|
|
948
|
-
if (op.kind === "select") throw new Violation("create did not produce a mutation");
|
|
949
|
-
const first = await plugin.beforeMutation(frozenCopy(op), hook(op));
|
|
950
|
-
const second = await plugin.beforeMutation(frozenCopy(op), hook(op));
|
|
951
|
-
expect(first.kind === op.kind, "must return a mutation of the same kind");
|
|
952
|
-
expect(same(first, second), "two calls with the same input differ");
|
|
953
|
-
}],
|
|
954
|
-
plugin.afterMutation && create && ["afterMutation cannot change the result", async () => {
|
|
955
|
-
const row = {
|
|
956
|
-
marker: "conformance",
|
|
957
|
-
nested: { kept: true }
|
|
958
|
-
};
|
|
959
|
-
const result = await betterSupabase.use(plugin).connect({
|
|
960
|
-
name: "rows",
|
|
961
|
-
execute: () => Promise.resolve(ok({
|
|
962
|
-
rows: [structuredClone(row)],
|
|
963
|
-
count: 1
|
|
964
|
-
}))
|
|
965
|
-
}, context)[table]["create"](create);
|
|
966
|
-
expect(result.ok, "create failed");
|
|
967
|
-
expect(same(result.data, row), "afterMutation changed the returned row");
|
|
968
|
-
}],
|
|
969
|
-
plugin.wrapExecutor && ["wrapExecutor keeps results intact", async () => {
|
|
970
|
-
const op = await captureOp(betterSupabase, (db) => db[table]["findMany"]({ limit: 1 }));
|
|
971
|
-
const data = {
|
|
972
|
-
rows: [{ marker: "conformance" }],
|
|
973
|
-
count: 1
|
|
974
|
-
};
|
|
975
|
-
const wrapped = plugin.wrapExecutor({
|
|
976
|
-
name: "inner",
|
|
977
|
-
execute: () => Promise.resolve(ok(data))
|
|
978
|
-
});
|
|
979
|
-
expect(typeof wrapped.name === "string", "the wrapped executor needs a name");
|
|
980
|
-
const result = await wrapped.execute(op, { errorMappers: [] });
|
|
981
|
-
expect(result.ok && same(result.data, data), "the wrapped executor changed the result");
|
|
982
|
-
}],
|
|
983
|
-
plugin.context && ["context is pure and deterministic", () => {
|
|
984
|
-
const args = { schema: betterSupabase.meta };
|
|
985
|
-
const first = plugin.context(frozenCopy(context), args);
|
|
986
|
-
const second = plugin.context(frozenCopy(context), args);
|
|
987
|
-
expect(typeof first === "object" && first !== null, "must return a context object");
|
|
988
|
-
expect(same(first, second), "two calls with the same input differ");
|
|
989
|
-
}],
|
|
990
|
-
plugin.mapError && ["mapError returns a DbError or undefined", () => {
|
|
991
|
-
const fallback = dbError("unexpected", "internal error");
|
|
992
|
-
const mapped = plugin.mapError({
|
|
993
|
-
code: "XX000",
|
|
994
|
-
message: "internal error"
|
|
995
|
-
}, fallback);
|
|
996
|
-
expect(mapped === void 0 || typeof mapped === "object" && typeof mapped.kind === "string", "must return a DbError or undefined");
|
|
997
|
-
}]
|
|
998
|
-
]);
|
|
920
|
+
function defineSeed(betterSupabase, fixtures) {
|
|
921
|
+
const meta = betterSupabase.meta;
|
|
922
|
+
const statements = () => {
|
|
923
|
+
const keys = Object.keys(fixtures).filter((key) => fixtures[key] !== void 0);
|
|
924
|
+
for (const key of keys) {
|
|
925
|
+
const table = meta.tables[key];
|
|
926
|
+
if (!table) throw new TypeError(`seed: unknown table "${key}"`);
|
|
927
|
+
if (table.kind === "view") throw new TypeError(`seed: "${key}" is a view`);
|
|
928
|
+
}
|
|
929
|
+
return order(meta, keys).flatMap((key) => {
|
|
930
|
+
const sql = statement(meta.tables[key], fixtures[key]);
|
|
931
|
+
return sql ? [sql] : [];
|
|
932
|
+
});
|
|
933
|
+
};
|
|
934
|
+
return {
|
|
935
|
+
[SEED]: true,
|
|
936
|
+
rows: fixtures,
|
|
937
|
+
statements,
|
|
938
|
+
sql: () => statements().join("\n\n"),
|
|
939
|
+
async insert(sql) {
|
|
940
|
+
for (const text of statements()) await sql.queryRaw(text);
|
|
941
|
+
}
|
|
942
|
+
};
|
|
999
943
|
}
|
|
1000
944
|
//#endregion
|
|
1001
|
-
//#region src/testing/
|
|
1002
|
-
const
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
}
|
|
1011
|
-
function env(names) {
|
|
1012
|
-
if (typeof process === "undefined") return void 0;
|
|
1013
|
-
for (const name of names) {
|
|
1014
|
-
const value = process.env[name];
|
|
1015
|
-
if (value) return value;
|
|
945
|
+
//#region src/testing/kits/executor.ts
|
|
946
|
+
const recorder = (ops) => ({
|
|
947
|
+
name: "recorder",
|
|
948
|
+
execute: (op) => {
|
|
949
|
+
ops.push(op);
|
|
950
|
+
return Promise.resolve(ok({
|
|
951
|
+
rows: [],
|
|
952
|
+
count: 0
|
|
953
|
+
}));
|
|
1016
954
|
}
|
|
955
|
+
});
|
|
956
|
+
/** The operation `run` sends to the executor, built by `betterSupabase` without touching a database. */
|
|
957
|
+
async function captureOp(betterSupabase, run) {
|
|
958
|
+
const ops = [];
|
|
959
|
+
await run(betterSupabase.connect(recorder(ops)));
|
|
960
|
+
const [op] = ops;
|
|
961
|
+
if (!op) throw new Error("the repository call did not reach the executor");
|
|
962
|
+
return op;
|
|
1017
963
|
}
|
|
964
|
+
function tableKey(betterSupabase, table) {
|
|
965
|
+
const key = table ?? Object.keys(betterSupabase.meta.tables)[0];
|
|
966
|
+
if (!key || !(key in betterSupabase.meta.tables)) throw new TypeError(`better-supabase: unknown table "${String(key)}"`);
|
|
967
|
+
return key;
|
|
968
|
+
}
|
|
969
|
+
const hasName = (subject) => ["has a name", () => {
|
|
970
|
+
expect(typeof subject.name === "string" && subject.name.length > 0, "name must be a non-empty string");
|
|
971
|
+
}];
|
|
1018
972
|
/**
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
1023
|
-
* table and command that leaks; removes every row it created.
|
|
973
|
+
* Proves an `Executor` meets the contract: rows keyed by the selection's
|
|
974
|
+
* aliases, counts, `aborted` errors, failures returned as `Result`s rather
|
|
975
|
+
* than thrown, and (with `create`) a write round trip and a `deleteMany`
|
|
976
|
+
* that `maxAffected` refuses without deleting anything.
|
|
1024
977
|
*
|
|
1025
978
|
* ```ts
|
|
1026
|
-
*
|
|
1027
|
-
* tenants: [
|
|
1028
|
-
* { id: ACME, claims: { sub: alice, tenant_id: ACME } },
|
|
1029
|
-
* { id: GLOBEX, claims: { sub: bob, tenant_id: GLOBEX } },
|
|
1030
|
-
* ],
|
|
1031
|
-
* tables: {
|
|
1032
|
-
* tags: {
|
|
1033
|
-
* row: (tenant, n) => ({ organizationId: tenant.id, name: `iso-${n}` }),
|
|
1034
|
-
* update: { name: 'changed' },
|
|
1035
|
-
* },
|
|
1036
|
-
* },
|
|
1037
|
-
* });
|
|
979
|
+
* it('conforms', () => testExecutor(myExecutor, { betterSupabase, table: 'tags', create: { name: 'x' } }));
|
|
1038
980
|
* ```
|
|
1039
981
|
*/
|
|
1040
|
-
|
|
1041
|
-
const
|
|
1042
|
-
const
|
|
1043
|
-
const
|
|
1044
|
-
const
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
if (!table || table.primaryKey.length === 0) throw new TypeError(`expectTenantIsolation: "${key}" is not a table with a primary key`);
|
|
1061
|
-
const seeded = [];
|
|
1062
|
-
for (const tenant of tenants) {
|
|
1063
|
-
const [row] = await rowsOf(admin[key].create(spec.row(tenant, 0)));
|
|
1064
|
-
const where = keyOf(table, row);
|
|
1065
|
-
cleanup.push(() => admin[key].deleteMany({ where }));
|
|
1066
|
-
seeded.push(row);
|
|
982
|
+
function testExecutor(executor, options) {
|
|
983
|
+
const { betterSupabase } = options;
|
|
984
|
+
const table = tableKey(betterSupabase, options.table);
|
|
985
|
+
const context = { errorMappers: [] };
|
|
986
|
+
const read = () => captureOp(betterSupabase, (db) => db[table]["findMany"]({ limit: 2 }));
|
|
987
|
+
const create = options.create;
|
|
988
|
+
return conform(`Executor "${executor.name}"`, [
|
|
989
|
+
hasName(executor),
|
|
990
|
+
["reads rows keyed by the selection aliases", async () => {
|
|
991
|
+
const op = await read();
|
|
992
|
+
const result = await executor.execute(op, context);
|
|
993
|
+
expect(result.ok, `select failed: ${result.ok ? "" : result.error.message}`);
|
|
994
|
+
expect(isList(result.data.rows), "rows must be an array");
|
|
995
|
+
expect(result.data.rows.length <= 2, `limit 2 returned ${result.data.rows.length} rows`);
|
|
996
|
+
expect(result.data.count === null || typeof result.data.count === "number", "count must be a number or null");
|
|
997
|
+
if (op.kind !== "select") return;
|
|
998
|
+
const aliases = new Set(op.selection.columns.map((column) => column.alias));
|
|
999
|
+
for (const row of result.data.rows) {
|
|
1000
|
+
const extra = Object.keys(row).filter((key) => !aliases.has(key));
|
|
1001
|
+
expect(extra.length === 0, `rows must use selection aliases; got ${extra.join(", ")}`);
|
|
1067
1002
|
}
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1003
|
+
}],
|
|
1004
|
+
["counts rows", async () => {
|
|
1005
|
+
const op = await captureOp(betterSupabase, (db) => db[table]["count"]());
|
|
1006
|
+
const result = await executor.execute(op, context);
|
|
1007
|
+
expect(result.ok, `count failed: ${result.ok ? "" : result.error.message}`);
|
|
1008
|
+
expect(typeof result.data.count === "number" && result.data.count >= 0, "count must be a number");
|
|
1009
|
+
}],
|
|
1010
|
+
["returns an aborted error for an aborted signal", async () => {
|
|
1011
|
+
const controller = new AbortController();
|
|
1012
|
+
controller.abort();
|
|
1013
|
+
const result = await executor.execute(await read(), {
|
|
1014
|
+
...context,
|
|
1015
|
+
signal: controller.signal
|
|
1016
|
+
});
|
|
1017
|
+
expect(!result.ok, "an aborted request must fail");
|
|
1018
|
+
expect(result.error.kind === "aborted", `expected kind "aborted", got "${result.error.kind}"`);
|
|
1019
|
+
}],
|
|
1020
|
+
["returns failures as results instead of throwing", async () => {
|
|
1021
|
+
const op = await read();
|
|
1022
|
+
const missing = {
|
|
1023
|
+
...op,
|
|
1024
|
+
table: {
|
|
1025
|
+
...op.table,
|
|
1026
|
+
name: "__better_supabase_missing__"
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
const result = await executor.execute(missing, context);
|
|
1030
|
+
expect(!result.ok, "a missing table must fail");
|
|
1031
|
+
expect(typeof result.error.kind === "string" && typeof result.error.message === "string", "errors must be DbErrors");
|
|
1032
|
+
}],
|
|
1033
|
+
executor.rpc && ["returns rpc failures as results", async () => {
|
|
1034
|
+
expect(!(await executor.rpc("__better_supabase_missing__", {}, {
|
|
1035
|
+
...context,
|
|
1036
|
+
schema: "public"
|
|
1037
|
+
})).ok, "calling a missing function must fail");
|
|
1038
|
+
}],
|
|
1039
|
+
executor.functionSources && ["reads from SelectOp.source instead of the table", async () => {
|
|
1040
|
+
const op = await read();
|
|
1041
|
+
if (op.kind !== "select") return;
|
|
1042
|
+
expect(!(await executor.execute({
|
|
1043
|
+
...op,
|
|
1044
|
+
source: {
|
|
1045
|
+
schema: op.table.schema,
|
|
1046
|
+
name: "__better_supabase_missing__",
|
|
1047
|
+
args: {}
|
|
1048
|
+
}
|
|
1049
|
+
}, context)).ok, "a missing source function must fail; reading the table ignores source");
|
|
1050
|
+
}],
|
|
1051
|
+
executor.batch && ["batch returns one result per operation, in order", async () => {
|
|
1052
|
+
const rows = await read();
|
|
1053
|
+
const count = await captureOp(betterSupabase, (db) => db[table]["count"]());
|
|
1054
|
+
const missing = {
|
|
1055
|
+
...rows,
|
|
1056
|
+
table: {
|
|
1057
|
+
...rows.table,
|
|
1058
|
+
name: "__better_supabase_missing__"
|
|
1059
|
+
}
|
|
1060
|
+
};
|
|
1061
|
+
const results = await executor.batch([
|
|
1062
|
+
rows,
|
|
1063
|
+
count,
|
|
1064
|
+
missing
|
|
1065
|
+
], context);
|
|
1066
|
+
expect(results.length === 3, `expected 3 results, got ${results.length}`);
|
|
1067
|
+
const [first, second, third] = results;
|
|
1068
|
+
expect(first?.ok === true && Array.isArray(first.data.rows), "the first operation must return rows");
|
|
1069
|
+
expect(second?.ok === true && typeof second.data.count === "number", "the second operation must return a count");
|
|
1070
|
+
expect(third?.ok === false, "a failing operation must fail on its own, as a result");
|
|
1071
|
+
}],
|
|
1072
|
+
create && ["round-trips a write", async () => {
|
|
1073
|
+
const repository = betterSupabase.connect(executor)[table];
|
|
1074
|
+
const created = await repository["create"](create);
|
|
1075
|
+
expect(created.ok, `create failed: ${JSON.stringify(created.error)}`);
|
|
1076
|
+
const row = created.data;
|
|
1077
|
+
for (const [key, value] of Object.entries(create)) expect(same(row[key], value), `created row has ${key} = ${JSON.stringify(row[key])}`);
|
|
1078
|
+
const [primary] = betterSupabase.meta.tables[table].primaryKey;
|
|
1079
|
+
expect(primary, "write checks need a table with a primary key");
|
|
1080
|
+
const removed = await repository["delete"](row[primary]);
|
|
1081
|
+
expect(removed.ok, `delete failed: ${JSON.stringify(removed.error)}`);
|
|
1082
|
+
const again = await repository["findFirst"]({ where: { [primary]: row[primary] } });
|
|
1083
|
+
expect(again.ok && again.data === null, "the deleted row is still readable");
|
|
1084
|
+
}],
|
|
1085
|
+
create && ["refuses a write past maxAffected and writes nothing", async () => {
|
|
1086
|
+
const repository = betterSupabase.connect(executor)[table];
|
|
1087
|
+
const [primary] = betterSupabase.meta.tables[table].primaryKey;
|
|
1088
|
+
expect(primary, "write checks need a table with a primary key");
|
|
1089
|
+
const created = await repository["create"](create);
|
|
1090
|
+
expect(created.ok, `create failed: ${JSON.stringify(created.error)}`);
|
|
1091
|
+
const id = created.data[primary];
|
|
1092
|
+
try {
|
|
1093
|
+
const refused = await repository["deleteMany"]({
|
|
1094
|
+
where: { [primary]: id },
|
|
1095
|
+
maxAffected: 0
|
|
1096
|
+
});
|
|
1097
|
+
expect(!refused.ok, "deleteMany past maxAffected must fail");
|
|
1098
|
+
const kind = refused.error?.kind;
|
|
1099
|
+
expect(kind === "max_affected", `expected kind "max_affected", got "${String(kind)}"`);
|
|
1100
|
+
const kept = await repository["findFirst"]({ where: { [primary]: id } });
|
|
1101
|
+
expect(kept.ok && kept.data !== null, "a refused deleteMany must not delete the row");
|
|
1102
|
+
const allowed = await repository["deleteMany"]({
|
|
1103
|
+
where: { [primary]: id },
|
|
1104
|
+
maxAffected: 1
|
|
1105
|
+
});
|
|
1106
|
+
expect(allowed.ok, `deleteMany within maxAffected failed: ${JSON.stringify(allowed.error)}`);
|
|
1107
|
+
} finally {
|
|
1108
|
+
await repository["delete"](id);
|
|
1104
1109
|
}
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
} finally {
|
|
1108
|
-
for (const remove of cleanup.reverse()) await remove();
|
|
1109
|
-
}
|
|
1110
|
+
}]
|
|
1111
|
+
]);
|
|
1110
1112
|
}
|
|
1111
1113
|
//#endregion
|
|
1112
|
-
//#region src/testing/
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
is_anonymous: false
|
|
1121
|
-
};
|
|
1122
|
-
const admin = "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d";
|
|
1123
|
-
const supportSessionId = "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b";
|
|
1124
|
-
const client = "5f0e4d3c-2b1a-4098-8776-655443322110";
|
|
1125
|
-
const chain = {
|
|
1126
|
-
sub: "agent-runner",
|
|
1127
|
-
act: { sub: "mcp-client-42" }
|
|
1128
|
-
};
|
|
1114
|
+
//#region src/testing/kits/auth-resolver.ts
|
|
1115
|
+
async function resolveSafely(resolver, request) {
|
|
1116
|
+
try {
|
|
1117
|
+
return await resolver.resolve(request);
|
|
1118
|
+
} catch (cause) {
|
|
1119
|
+
throw new Violation(`resolve() threw: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1129
1122
|
/**
|
|
1130
|
-
*
|
|
1131
|
-
*
|
|
1132
|
-
* (`act.kind: "impersonation"`), `oauthClient` a Supabase OAuth server token
|
|
1133
|
-
* and `agentChain` an RFC 8693 chain without `kind`.
|
|
1123
|
+
* Proves an `AuthResolver` passes on requests it doesn't understand, fails
|
|
1124
|
+
* closed (`invalid`, never anon) on bad credentials, and never throws.
|
|
1134
1125
|
*/
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
actor: {
|
|
1149
|
-
kind: "support",
|
|
1150
|
-
id: admin,
|
|
1151
|
-
sessionId: supportSessionId,
|
|
1152
|
-
readOnly: false,
|
|
1153
|
-
reason: "ticket 42"
|
|
1154
|
-
},
|
|
1155
|
-
impersonator: {
|
|
1156
|
-
kind: "support",
|
|
1157
|
-
id: admin,
|
|
1158
|
-
sessionId: supportSessionId,
|
|
1159
|
-
readOnly: false,
|
|
1160
|
-
reason: "ticket 42"
|
|
1161
|
-
}
|
|
1162
|
-
}
|
|
1163
|
-
},
|
|
1164
|
-
supportSessionReadOnly: {
|
|
1165
|
-
claims: {
|
|
1166
|
-
...base,
|
|
1167
|
-
act: {
|
|
1168
|
-
kind: "support",
|
|
1169
|
-
sub: admin,
|
|
1170
|
-
reason: "ticket 42",
|
|
1171
|
-
session_id: supportSessionId,
|
|
1172
|
-
read_only: true
|
|
1173
|
-
}
|
|
1174
|
-
},
|
|
1175
|
-
expect: {
|
|
1176
|
-
actor: {
|
|
1177
|
-
kind: "support",
|
|
1178
|
-
id: admin,
|
|
1179
|
-
sessionId: supportSessionId,
|
|
1180
|
-
readOnly: true,
|
|
1181
|
-
reason: "ticket 42"
|
|
1182
|
-
},
|
|
1183
|
-
impersonator: {
|
|
1184
|
-
kind: "support",
|
|
1185
|
-
id: admin,
|
|
1186
|
-
sessionId: supportSessionId,
|
|
1187
|
-
readOnly: true,
|
|
1188
|
-
reason: "ticket 42"
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1191
|
-
},
|
|
1192
|
-
impersonation: {
|
|
1193
|
-
claims: {
|
|
1194
|
-
...base,
|
|
1195
|
-
act: {
|
|
1196
|
-
kind: "impersonation",
|
|
1197
|
-
sub: admin,
|
|
1198
|
-
reason: "ticket 42"
|
|
1126
|
+
function testAuthResolver(resolver, options) {
|
|
1127
|
+
return conform(`AuthResolver "${resolver.name}"`, [
|
|
1128
|
+
hasName(resolver),
|
|
1129
|
+
["passes on requests without its credentials", async () => {
|
|
1130
|
+
const state = await resolveSafely(resolver, options.unrelated ?? new Request("https://example.com/"));
|
|
1131
|
+
expect(state === void 0, `expected undefined, got kind "${state?.kind}"`);
|
|
1132
|
+
}],
|
|
1133
|
+
["fails closed on invalid credentials", async () => {
|
|
1134
|
+
expect(options.invalid.length > 0, "pass at least one invalid request");
|
|
1135
|
+
for (const request of options.invalid) {
|
|
1136
|
+
const state = await resolveSafely(resolver, request);
|
|
1137
|
+
expect(state?.kind === "invalid", `expected kind "invalid", got "${state?.kind ?? "undefined"}"`);
|
|
1138
|
+
expect(typeof state.error.kind === "string", "invalid states carry a DbError");
|
|
1199
1139
|
}
|
|
1200
|
-
},
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
},
|
|
1207
|
-
impersonator: {
|
|
1208
|
-
kind: "impersonation",
|
|
1209
|
-
id: admin,
|
|
1210
|
-
reason: "ticket 42"
|
|
1140
|
+
}],
|
|
1141
|
+
options.valid && ["resolves valid credentials", async () => {
|
|
1142
|
+
for (const { request, userId } of options.valid) {
|
|
1143
|
+
const state = await resolveSafely(resolver, request);
|
|
1144
|
+
expect(state?.kind === "user" || state?.kind === "service", `expected a user or service, got "${state?.kind}"`);
|
|
1145
|
+
if (userId !== void 0) expect(state.kind === "user" && state.user.id === userId, `expected user ${userId}`);
|
|
1211
1146
|
}
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
oauthClient: {
|
|
1215
|
-
claims: {
|
|
1216
|
-
...base,
|
|
1217
|
-
client_id: client,
|
|
1218
|
-
scope: "openid email posts:read"
|
|
1219
|
-
},
|
|
1220
|
-
expect: {
|
|
1221
|
-
actor: {
|
|
1222
|
-
kind: "oauth-client",
|
|
1223
|
-
id: client
|
|
1224
|
-
},
|
|
1225
|
-
delegation: { scopes: [
|
|
1226
|
-
"openid",
|
|
1227
|
-
"email",
|
|
1228
|
-
"posts:read"
|
|
1229
|
-
] }
|
|
1230
|
-
}
|
|
1231
|
-
},
|
|
1232
|
-
agentChain: {
|
|
1233
|
-
claims: {
|
|
1234
|
-
...base,
|
|
1235
|
-
scope: "posts:read",
|
|
1236
|
-
act: chain
|
|
1237
|
-
},
|
|
1238
|
-
expect: {
|
|
1239
|
-
actor: {
|
|
1240
|
-
kind: "oauth-client",
|
|
1241
|
-
id: "agent-runner",
|
|
1242
|
-
chain
|
|
1243
|
-
},
|
|
1244
|
-
delegation: {
|
|
1245
|
-
scopes: ["posts:read"],
|
|
1246
|
-
chain
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1249
|
-
}
|
|
1250
|
-
};
|
|
1251
|
-
//#endregion
|
|
1252
|
-
//#region src/testing/seed.ts
|
|
1253
|
-
const SEED = Symbol.for("better-supabase.seed");
|
|
1254
|
-
function isSeed(value) {
|
|
1255
|
-
return typeof value === "object" && value !== null && SEED in value;
|
|
1256
|
-
}
|
|
1257
|
-
const quote = (text) => `'${text.replaceAll("'", "''")}'`;
|
|
1258
|
-
function arrayLiteral(values) {
|
|
1259
|
-
return `{${values.map((value) => {
|
|
1260
|
-
if (value === null) return "NULL";
|
|
1261
|
-
if (Array.isArray(value)) return arrayLiteral(value);
|
|
1262
|
-
return `"${(temporalText(value) ?? String(value)).replaceAll(/[\\"]/g, "\\$&")}"`;
|
|
1263
|
-
}).join(",")}}`;
|
|
1147
|
+
}]
|
|
1148
|
+
]);
|
|
1264
1149
|
}
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1150
|
+
//#endregion
|
|
1151
|
+
//#region src/testing/kits/cache-adapter.ts
|
|
1152
|
+
/**
|
|
1153
|
+
* Proves a `CacheAdapter` accepts table and row targets, repeated and
|
|
1154
|
+
* concurrent calls, and never mutates its input.
|
|
1155
|
+
*/
|
|
1156
|
+
function testCacheAdapter(adapter, options = {}) {
|
|
1157
|
+
const table = options.table ?? "customers";
|
|
1158
|
+
const invalidate = (target) => Promise.resolve(adapter.invalidate(deepFreeze(target)));
|
|
1159
|
+
return conform(`CacheAdapter "${adapter.name}"`, [
|
|
1160
|
+
hasName(adapter),
|
|
1161
|
+
["invalidates a table", () => invalidate({
|
|
1162
|
+
table,
|
|
1163
|
+
tables: [table],
|
|
1164
|
+
ids: []
|
|
1165
|
+
})],
|
|
1166
|
+
["invalidates rows of a tenant", () => invalidate({
|
|
1167
|
+
table,
|
|
1168
|
+
tables: [table],
|
|
1169
|
+
ids: ["1", "a,b"],
|
|
1170
|
+
tenant: "organization_1"
|
|
1171
|
+
})],
|
|
1172
|
+
["accepts repeated and concurrent calls", async () => {
|
|
1173
|
+
await invalidate({
|
|
1174
|
+
table,
|
|
1175
|
+
tables: [table],
|
|
1176
|
+
ids: ["1"]
|
|
1177
|
+
});
|
|
1178
|
+
await Promise.all(Array.from({ length: 10 }, (_, index) => invalidate({
|
|
1179
|
+
table,
|
|
1180
|
+
tables: [table],
|
|
1181
|
+
ids: [String(index)]
|
|
1182
|
+
})));
|
|
1183
|
+
}],
|
|
1184
|
+
["accepts tables it has never seen", () => invalidate({
|
|
1185
|
+
table: "__better_supabase_unknown__",
|
|
1186
|
+
tables: ["__better_supabase_unknown__"],
|
|
1187
|
+
ids: []
|
|
1188
|
+
})],
|
|
1189
|
+
["invalidates tables reached through foreign keys", () => invalidate({
|
|
1190
|
+
table,
|
|
1191
|
+
tables: [table, `${table}_children`],
|
|
1192
|
+
ids: ["1"]
|
|
1193
|
+
})]
|
|
1194
|
+
]);
|
|
1285
1195
|
}
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
const
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1196
|
+
//#endregion
|
|
1197
|
+
//#region src/testing/kits/event-sink.ts
|
|
1198
|
+
/** Proves an `EventSink` accepts empty and full batches without mutating them, and delivers them when readable. */
|
|
1199
|
+
function testEventSink(sink, options = {}) {
|
|
1200
|
+
const events = toCloudEvents({
|
|
1201
|
+
table: "customers",
|
|
1202
|
+
kind: "insert",
|
|
1203
|
+
rows: [{
|
|
1204
|
+
id: "1",
|
|
1205
|
+
name: "Acme"
|
|
1206
|
+
}, {
|
|
1207
|
+
id: "2",
|
|
1208
|
+
name: "Globex"
|
|
1209
|
+
}],
|
|
1210
|
+
context: { tenant: "organization_1" }
|
|
1211
|
+
}, { source: "/better-supabase/conformance" });
|
|
1212
|
+
return conform("EventSink", [
|
|
1213
|
+
["accepts an empty batch", () => sink.send(deepFreeze([]))],
|
|
1214
|
+
["accepts a batch without mutating it", () => sink.send(frozenCopy(events))],
|
|
1215
|
+
options.received && ["delivers every event", async () => {
|
|
1216
|
+
const ids = new Set((await options.received()).map((event) => event.id));
|
|
1217
|
+
const missing = events.filter((event) => !ids.has(event.id));
|
|
1218
|
+
expect(missing.length === 0, `${missing.length} of ${events.length} events were not delivered`);
|
|
1219
|
+
}]
|
|
1220
|
+
]);
|
|
1300
1221
|
}
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
}
|
|
1312
|
-
const
|
|
1313
|
-
|
|
1222
|
+
//#endregion
|
|
1223
|
+
//#region src/testing/kits/plugin.ts
|
|
1224
|
+
/**
|
|
1225
|
+
* Proves a `Plugin` targets API v1, installs cleanly on its own and next to
|
|
1226
|
+
* the first-party plugins in either order, adds repository methods without
|
|
1227
|
+
* replacing base ones, keeps `context`, `transformQuery` and
|
|
1228
|
+
* `beforeMutation` pure and deterministic, and keeps executors'
|
|
1229
|
+
* results intact when it wraps them.
|
|
1230
|
+
*/
|
|
1231
|
+
function testPlugin(plugin, options) {
|
|
1232
|
+
const { betterSupabase } = options;
|
|
1233
|
+
const table = tableKey(betterSupabase, options.table);
|
|
1234
|
+
const context = options.context ?? {};
|
|
1235
|
+
const hook = (op) => ({
|
|
1236
|
+
table: op.table,
|
|
1237
|
+
schema: betterSupabase.meta,
|
|
1238
|
+
context,
|
|
1239
|
+
options: {},
|
|
1240
|
+
now: () => temporal().Instant.fromEpochMilliseconds(0)
|
|
1314
1241
|
});
|
|
1315
|
-
|
|
1242
|
+
const create = options.create;
|
|
1243
|
+
return conform(`Plugin "${plugin.name}"`, [
|
|
1244
|
+
hasName(plugin),
|
|
1245
|
+
["targets plugin API v1", () => {
|
|
1246
|
+
expect(plugin.apiVersion === 1, `apiVersion is ${String(plugin.apiVersion)}`);
|
|
1247
|
+
}],
|
|
1248
|
+
["installs and builds repositories", () => {
|
|
1249
|
+
const db = betterSupabase.use(plugin).connect(recorder([]), context);
|
|
1250
|
+
for (const key of Object.keys(betterSupabase.meta.tables)) expect(db[key], `db.${key} is missing`);
|
|
1251
|
+
}],
|
|
1252
|
+
["declares a known hook order", () => {
|
|
1253
|
+
const enforce = plugin.enforce;
|
|
1254
|
+
expect(enforce === void 0 || enforce === "first" || enforce === "pre" || enforce === "post", `enforce is ${String(enforce)}; use "first", "pre", "post" or leave it out`);
|
|
1255
|
+
}],
|
|
1256
|
+
["installs next to the first-party plugins in either order", () => {
|
|
1257
|
+
const others = [
|
|
1258
|
+
timestamps(),
|
|
1259
|
+
softDelete(),
|
|
1260
|
+
tenant(),
|
|
1261
|
+
actor()
|
|
1262
|
+
].filter((other) => other.name !== plugin.name);
|
|
1263
|
+
let before = betterSupabase;
|
|
1264
|
+
for (const other of others) before = before.use(other);
|
|
1265
|
+
let after = betterSupabase.use(plugin);
|
|
1266
|
+
for (const other of others) after = after.use(other);
|
|
1267
|
+
for (const installed of [before.use(plugin), after]) {
|
|
1268
|
+
const db = installed.connect(recorder([]), context);
|
|
1269
|
+
for (const key of Object.keys(betterSupabase.meta.tables)) expect(db[key], `db.${key} is missing`);
|
|
1270
|
+
}
|
|
1271
|
+
}],
|
|
1272
|
+
plugin.repository && ["repository adds functions and keeps the base methods", () => {
|
|
1273
|
+
const db = betterSupabase.connect(recorder([]), context);
|
|
1274
|
+
for (const [key, table] of Object.entries(betterSupabase.meta.tables)) {
|
|
1275
|
+
const base = db[key];
|
|
1276
|
+
const added = plugin.repository({
|
|
1277
|
+
table,
|
|
1278
|
+
base
|
|
1279
|
+
});
|
|
1280
|
+
if (added === void 0) continue;
|
|
1281
|
+
for (const [name, method] of Object.entries(added)) {
|
|
1282
|
+
expect(typeof method === "function", `${key}.${name} is not a function`);
|
|
1283
|
+
expect(!(name in base), `${key}.${name} replaces a base method`);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}],
|
|
1287
|
+
plugin.transformQuery && ["transformQuery is pure and deterministic", async () => {
|
|
1288
|
+
const op = await captureOp(betterSupabase, (db) => db[table]["findMany"]({ limit: 1 }));
|
|
1289
|
+
const first = plugin.transformQuery(frozenCopy(op), hook(op));
|
|
1290
|
+
const second = plugin.transformQuery(frozenCopy(op), hook(op));
|
|
1291
|
+
expect(typeof first === "object" && first !== null && first.kind === op.kind, "must return an operation of the same kind");
|
|
1292
|
+
expect(same(first, second), "two calls with the same input differ");
|
|
1293
|
+
}],
|
|
1294
|
+
plugin.beforeMutation && create && ["beforeMutation is pure and deterministic", async () => {
|
|
1295
|
+
const op = await captureOp(betterSupabase, (db) => db[table]["create"](create));
|
|
1296
|
+
if (op.kind === "select") throw new Violation("create did not produce a mutation");
|
|
1297
|
+
const first = await plugin.beforeMutation(frozenCopy(op), hook(op));
|
|
1298
|
+
const second = await plugin.beforeMutation(frozenCopy(op), hook(op));
|
|
1299
|
+
expect(first.kind === op.kind, "must return a mutation of the same kind");
|
|
1300
|
+
expect(same(first, second), "two calls with the same input differ");
|
|
1301
|
+
}],
|
|
1302
|
+
plugin.afterMutation && create && ["afterMutation cannot change the result", async () => {
|
|
1303
|
+
const row = {
|
|
1304
|
+
marker: "conformance",
|
|
1305
|
+
nested: { kept: true }
|
|
1306
|
+
};
|
|
1307
|
+
const result = await betterSupabase.use(plugin).connect({
|
|
1308
|
+
name: "rows",
|
|
1309
|
+
execute: () => Promise.resolve(ok({
|
|
1310
|
+
rows: [structuredClone(row)],
|
|
1311
|
+
count: 1
|
|
1312
|
+
}))
|
|
1313
|
+
}, context)[table]["create"](create);
|
|
1314
|
+
expect(result.ok, "create failed");
|
|
1315
|
+
expect(same(result.data, row), "afterMutation changed the returned row");
|
|
1316
|
+
}],
|
|
1317
|
+
plugin.wrapExecutor && ["wrapExecutor keeps results intact", async () => {
|
|
1318
|
+
const op = await captureOp(betterSupabase, (db) => db[table]["findMany"]({ limit: 1 }));
|
|
1319
|
+
const data = {
|
|
1320
|
+
rows: [{ marker: "conformance" }],
|
|
1321
|
+
count: 1
|
|
1322
|
+
};
|
|
1323
|
+
const wrapped = plugin.wrapExecutor({
|
|
1324
|
+
name: "inner",
|
|
1325
|
+
execute: () => Promise.resolve(ok(data))
|
|
1326
|
+
});
|
|
1327
|
+
expect(typeof wrapped.name === "string", "the wrapped executor needs a name");
|
|
1328
|
+
const result = await wrapped.execute(op, { errorMappers: [] });
|
|
1329
|
+
expect(result.ok && same(result.data, data), "the wrapped executor changed the result");
|
|
1330
|
+
}],
|
|
1331
|
+
plugin.context && ["context is pure and deterministic", () => {
|
|
1332
|
+
const args = { schema: betterSupabase.meta };
|
|
1333
|
+
const first = plugin.context(frozenCopy(context), args);
|
|
1334
|
+
const second = plugin.context(frozenCopy(context), args);
|
|
1335
|
+
expect(typeof first === "object" && first !== null, "must return a context object");
|
|
1336
|
+
expect(same(first, second), "two calls with the same input differ");
|
|
1337
|
+
}],
|
|
1338
|
+
plugin.mapError && ["mapError returns a DbError or undefined", () => {
|
|
1339
|
+
const fallback = dbError("unexpected", "internal error");
|
|
1340
|
+
const mapped = plugin.mapError({
|
|
1341
|
+
code: "XX000",
|
|
1342
|
+
message: "internal error"
|
|
1343
|
+
}, fallback);
|
|
1344
|
+
expect(mapped === void 0 || typeof mapped === "object" && typeof mapped.kind === "string", "must return a DbError or undefined");
|
|
1345
|
+
}]
|
|
1346
|
+
]);
|
|
1347
|
+
}
|
|
1348
|
+
//#endregion
|
|
1349
|
+
//#region src/testing/kits/queue-backend.ts
|
|
1350
|
+
function claimedJob(queue, row) {
|
|
1351
|
+
const instant = temporal().Instant.fromEpochMilliseconds(0);
|
|
1352
|
+
return {
|
|
1353
|
+
id: Number(row.id),
|
|
1354
|
+
queue,
|
|
1355
|
+
payload: row.message?.payload,
|
|
1356
|
+
attempts: row.attempts,
|
|
1357
|
+
maxAttempts: row.message?.max_attempts ?? 5,
|
|
1358
|
+
enqueuedAt: instant,
|
|
1359
|
+
visibleUntil: instant,
|
|
1360
|
+
lastError: row.message?.last_error ?? null,
|
|
1361
|
+
context: {}
|
|
1362
|
+
};
|
|
1316
1363
|
}
|
|
1317
1364
|
/**
|
|
1318
|
-
*
|
|
1319
|
-
*
|
|
1320
|
-
*
|
|
1321
|
-
*
|
|
1322
|
-
* export const seed = defineSeed(betterSupabase, {
|
|
1323
|
-
* organizations: { acme: { id: ACME, name: 'Acme' } },
|
|
1324
|
-
* customers: { first: { organizationId: ACME, name: 'First customer' } },
|
|
1325
|
-
* });
|
|
1326
|
-
* ```
|
|
1365
|
+
* Proves a `QueueBackend` round-trips payloads, hides claimed messages for
|
|
1366
|
+
* their lease, counts attempts, retries and dead-letters on `fail`, and
|
|
1367
|
+
* (when it has `leases`) rejects a stale attempt and extends a lease. It
|
|
1368
|
+
* writes to `options.queue` on the backend's real store.
|
|
1327
1369
|
*/
|
|
1328
|
-
function
|
|
1329
|
-
const
|
|
1330
|
-
const
|
|
1331
|
-
const
|
|
1332
|
-
|
|
1333
|
-
const table = meta.tables[key];
|
|
1334
|
-
if (!table) throw new TypeError(`seed: unknown table "${key}"`);
|
|
1335
|
-
if (table.kind === "view") throw new TypeError(`seed: "${key}" is a view`);
|
|
1336
|
-
}
|
|
1337
|
-
return order(meta, keys).flatMap((key) => {
|
|
1338
|
-
const sql = statement(meta.tables[key], fixtures[key]);
|
|
1339
|
-
return sql ? [sql] : [];
|
|
1340
|
-
});
|
|
1341
|
-
};
|
|
1342
|
-
return {
|
|
1343
|
-
[SEED]: true,
|
|
1344
|
-
rows: fixtures,
|
|
1345
|
-
statements,
|
|
1346
|
-
sql: () => statements().join("\n\n"),
|
|
1347
|
-
async insert(sql) {
|
|
1348
|
-
for (const text of statements()) await sql.queryRaw(text);
|
|
1349
|
-
}
|
|
1370
|
+
function testQueueBackend(backend, options) {
|
|
1371
|
+
const { queue } = options;
|
|
1372
|
+
const claimOne = async () => {
|
|
1373
|
+
const [row] = await backend.read(queue, 30, 1);
|
|
1374
|
+
return row && claimedJob(queue, row);
|
|
1350
1375
|
};
|
|
1376
|
+
return conform(`QueueBackend "${backend.name}"`, [
|
|
1377
|
+
hasName(backend),
|
|
1378
|
+
["has apiVersion 1", () => {
|
|
1379
|
+
const version = backend.apiVersion;
|
|
1380
|
+
expect(version === 1, "apiVersion must be 1");
|
|
1381
|
+
}],
|
|
1382
|
+
["round-trips a payload and leases it", async () => {
|
|
1383
|
+
const id = await backend.send(queue, { n: 1 }, 0, 3, void 0);
|
|
1384
|
+
expect(Number.isFinite(id), "send must return a numeric id");
|
|
1385
|
+
const job = await claimOne();
|
|
1386
|
+
expect(job?.id === id, "read must return the sent message");
|
|
1387
|
+
expect(same(job.payload, { n: 1 }), "the payload must round-trip");
|
|
1388
|
+
expect(job.attempts === 1, "the first claim must be attempt 1");
|
|
1389
|
+
expect(job.maxAttempts === 3, "max_attempts must round-trip");
|
|
1390
|
+
expect(await claimOne() === void 0, "a leased message must stay hidden from other reads");
|
|
1391
|
+
expect(await backend.complete(job), "complete must return true");
|
|
1392
|
+
}],
|
|
1393
|
+
backend.leases && ["rejects a stale attempt and extends a lease", async () => {
|
|
1394
|
+
await backend.send(queue, { n: 2 }, 0, 3, void 0);
|
|
1395
|
+
const job = await claimOne();
|
|
1396
|
+
expect(job, "read must return the sent message");
|
|
1397
|
+
expect(!await backend.complete({
|
|
1398
|
+
...job,
|
|
1399
|
+
attempts: job.attempts + 1
|
|
1400
|
+
}), "complete with another attempt must return false");
|
|
1401
|
+
expect(await backend.extend(job, 60), "extend must return true");
|
|
1402
|
+
expect(await backend.complete(job), "complete must return true");
|
|
1403
|
+
}],
|
|
1404
|
+
["retries, then dead-letters at max attempts", async () => {
|
|
1405
|
+
await backend.send(queue, { n: 3 }, 0, 2, void 0);
|
|
1406
|
+
const first = await claimOne();
|
|
1407
|
+
expect(first, "read must return the sent message");
|
|
1408
|
+
expect(await backend.fail(first, "boom", 0) === "queued", "fail before max_attempts must return queued");
|
|
1409
|
+
const second = await claimOne();
|
|
1410
|
+
expect(second?.attempts === 2, "a retried message must be attempt 2");
|
|
1411
|
+
expect(await backend.fail(second, "boom", 0) === "dead", "fail at max_attempts must return dead");
|
|
1412
|
+
expect(await claimOne() === void 0, "a dead message must not be claimed again");
|
|
1413
|
+
}],
|
|
1414
|
+
backend.stats && backend.listDead && ["counts and lists dead letters", async () => {
|
|
1415
|
+
expect(((await backend.stats?.(queue))?.dead ?? 0) >= 1, "stats must count the dead letter");
|
|
1416
|
+
const [dead] = await backend.listDead?.(queue, 10, void 0) ?? [];
|
|
1417
|
+
expect(dead, "listDead must return the dead letter");
|
|
1418
|
+
expect(same(dead.message?.payload, { n: 3 }), "listDead must return the newest dead letter first");
|
|
1419
|
+
if (backend.retryDead) {
|
|
1420
|
+
expect(await backend.retryDead(queue, [Number(dead.id)], 10) === 1, "retryDead must enqueue the dead letter again");
|
|
1421
|
+
const job = await claimOne();
|
|
1422
|
+
expect(job, "the retried job must be claimable");
|
|
1423
|
+
expect(same(job.payload, { n: 3 }), "the retried job must keep its payload");
|
|
1424
|
+
await backend.complete(job);
|
|
1425
|
+
}
|
|
1426
|
+
}],
|
|
1427
|
+
(options.dedupe ?? backend.leases) && ["returns the first id for a repeated dedupe key", async () => {
|
|
1428
|
+
expect(await backend.send(queue, { n: 4 }, 0, 3, "same") === await backend.send(queue, { n: 4 }, 0, 3, "same"), "a waiting dedupe key must return its id");
|
|
1429
|
+
const job = await claimOne();
|
|
1430
|
+
expect(job, "read must return the sent message");
|
|
1431
|
+
await backend.complete(job);
|
|
1432
|
+
}]
|
|
1433
|
+
]);
|
|
1434
|
+
}
|
|
1435
|
+
//#endregion
|
|
1436
|
+
//#region src/testing/kits/support-session-store.ts
|
|
1437
|
+
/**
|
|
1438
|
+
* Proves a `SupportSessionStore` starts sessions with the input it was
|
|
1439
|
+
* given, shows a running session only to its admin, ends the admin's
|
|
1440
|
+
* previous session on start, ends a session once, lists sessions and, with
|
|
1441
|
+
* `expire`, leaves running sessions alone.
|
|
1442
|
+
* It writes sessions to the store; they are all ended when it returns.
|
|
1443
|
+
*/
|
|
1444
|
+
function testSupportSessionStore(store, options) {
|
|
1445
|
+
const { admin } = options;
|
|
1446
|
+
const [first, second] = options.targets;
|
|
1447
|
+
const start = (targetUserId) => store.start({
|
|
1448
|
+
adminId: admin.id,
|
|
1449
|
+
adminClaims: admin.claims,
|
|
1450
|
+
targetUserId,
|
|
1451
|
+
reason: "conformance",
|
|
1452
|
+
ttlSeconds: 600,
|
|
1453
|
+
readOnly: true,
|
|
1454
|
+
metadata: { ticket: "T-1" }
|
|
1455
|
+
});
|
|
1456
|
+
return conform(`SupportSessionStore "${store.name}"`, [
|
|
1457
|
+
hasName(store),
|
|
1458
|
+
["has apiVersion 1", () => {
|
|
1459
|
+
const version = store.apiVersion;
|
|
1460
|
+
expect(version === 1, "apiVersion must be 1");
|
|
1461
|
+
}],
|
|
1462
|
+
["starts a session with its input and shows it to its admin only", async () => {
|
|
1463
|
+
const session = await start(first);
|
|
1464
|
+
try {
|
|
1465
|
+
expect(session.id.length > 0, "start must return an id");
|
|
1466
|
+
expect(session.adminId === admin.id, "adminId must round-trip");
|
|
1467
|
+
expect(session.targetUserId === first, "targetUserId must round-trip");
|
|
1468
|
+
expect(session.reason === "conformance", "reason must round-trip");
|
|
1469
|
+
expect(session.readOnly, "readOnly must round-trip");
|
|
1470
|
+
expect(same(session.metadata, { ticket: "T-1" }), "metadata must round-trip");
|
|
1471
|
+
const seconds = (session.expiresAt.epochMilliseconds - session.startedAt.epochMilliseconds) / 1e3;
|
|
1472
|
+
expect(Math.abs(seconds - 600) < 5, "expiresAt must be ttlSeconds after startedAt");
|
|
1473
|
+
expect((await store.get(session.id, admin.id))?.id === session.id, "get must return the running session");
|
|
1474
|
+
expect(await store.get(session.id, first) === void 0, "get must hide a session from anyone but its admin");
|
|
1475
|
+
} finally {
|
|
1476
|
+
await store.end(session.id, "admin");
|
|
1477
|
+
}
|
|
1478
|
+
}],
|
|
1479
|
+
["ends the admin's previous session on start", async () => {
|
|
1480
|
+
const older = await start(first);
|
|
1481
|
+
const newer = await start(second);
|
|
1482
|
+
try {
|
|
1483
|
+
expect(await store.get(older.id, admin.id) === void 0, "starting a session must end the admin's previous one");
|
|
1484
|
+
expect((await store.get(newer.id, admin.id))?.id === newer.id, "the new session must be running");
|
|
1485
|
+
} finally {
|
|
1486
|
+
await store.end(newer.id, "admin");
|
|
1487
|
+
}
|
|
1488
|
+
}],
|
|
1489
|
+
["ends a session once and lists it", async () => {
|
|
1490
|
+
const session = await start(first);
|
|
1491
|
+
expect(await store.end(session.id, "admin"), "end must return true");
|
|
1492
|
+
expect(!await store.end(session.id, "admin"), "ending an ended session must return false");
|
|
1493
|
+
expect(await store.get(session.id, admin.id) === void 0, "get must not return an ended session");
|
|
1494
|
+
const entry = (await store.list({
|
|
1495
|
+
adminId: admin.id,
|
|
1496
|
+
active: false
|
|
1497
|
+
})).find((item) => item.id === session.id);
|
|
1498
|
+
expect(entry, "list must include the ended session");
|
|
1499
|
+
expect(entry.endedAt !== void 0, "an ended session has endedAt");
|
|
1500
|
+
expect((await store.list({
|
|
1501
|
+
adminId: admin.id,
|
|
1502
|
+
active: true
|
|
1503
|
+
})).every((item) => item.id !== session.id), "list({ active: true }) must leave out ended sessions");
|
|
1504
|
+
}],
|
|
1505
|
+
["expires only sessions past their expiry, when it has expire", async () => {
|
|
1506
|
+
if (!store.expire) return;
|
|
1507
|
+
const session = await start(first);
|
|
1508
|
+
try {
|
|
1509
|
+
const expired = await store.expire();
|
|
1510
|
+
expect(expired.every((item) => item.id !== session.id), "expire must leave a running session alone");
|
|
1511
|
+
expect(expired.every((item) => item.endedBy === "expired"), "expire must end sessions with endedBy expired");
|
|
1512
|
+
expect((await store.get(session.id, admin.id))?.id === session.id, "the running session must still be running after expire");
|
|
1513
|
+
} finally {
|
|
1514
|
+
await store.end(session.id, "admin");
|
|
1515
|
+
}
|
|
1516
|
+
}]
|
|
1517
|
+
]);
|
|
1351
1518
|
}
|
|
1352
1519
|
//#endregion
|
|
1353
1520
|
//#region src/testing/adapter.ts
|
|
@@ -1484,13 +1651,13 @@ function testBlockTransportMiddleware(middleware) {
|
|
|
1484
1651
|
}
|
|
1485
1652
|
//#endregion
|
|
1486
1653
|
//#region src/testing/authorization-provider.ts
|
|
1487
|
-
function isPlainJson(value) {
|
|
1654
|
+
function isPlainJson$1(value) {
|
|
1488
1655
|
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
1489
1656
|
if (typeof value === "number") return Number.isFinite(value);
|
|
1490
|
-
if (Array.isArray(value)) return value.every(isPlainJson);
|
|
1657
|
+
if (Array.isArray(value)) return value.every(isPlainJson$1);
|
|
1491
1658
|
if (typeof value !== "object") return false;
|
|
1492
1659
|
const prototype = Object.getPrototypeOf(value);
|
|
1493
|
-
return (prototype === Object.prototype || prototype === null) && Object.values(value).every((entry) => entry === void 0 || isPlainJson(entry));
|
|
1660
|
+
return (prototype === Object.prototype || prototype === null) && Object.values(value).every((entry) => entry === void 0 || isPlainJson$1(entry));
|
|
1494
1661
|
}
|
|
1495
1662
|
const QUALIFIED = /^[a-z_][\w$]*\.[a-z_][\w${}]*$/i;
|
|
1496
1663
|
const TABLE = /^[a-z_][\w$]*\.[a-z_][\w$]*$/i;
|
|
@@ -1520,7 +1687,7 @@ function testAuthorizationProvider(provider) {
|
|
|
1520
1687
|
expect(provider.apiVersion === 1, `apiVersion is ${String(provider.apiVersion)}`);
|
|
1521
1688
|
}],
|
|
1522
1689
|
["is plain JSON", () => {
|
|
1523
|
-
expect(isPlainJson(provider), "it doesn't survive a JSON round trip");
|
|
1690
|
+
expect(isPlainJson$1(provider), "it doesn't survive a JSON round trip");
|
|
1524
1691
|
}],
|
|
1525
1692
|
["names valid, unique scopes", () => {
|
|
1526
1693
|
expect(scopes.size > 0, "scopes is empty");
|
|
@@ -1603,6 +1770,387 @@ function testAuthorizationProvider(provider) {
|
|
|
1603
1770
|
]);
|
|
1604
1771
|
}
|
|
1605
1772
|
//#endregion
|
|
1773
|
+
//#region src/testing/authorizer.ts
|
|
1774
|
+
const OUTCOMES = /* @__PURE__ */ new Set([
|
|
1775
|
+
"granted",
|
|
1776
|
+
"denied",
|
|
1777
|
+
"approval-required"
|
|
1778
|
+
]);
|
|
1779
|
+
const isOutcome = (value) => typeof value === "object" && value !== null && "outcome" in value && typeof value.outcome === "string" && OUTCOMES.has(value.outcome);
|
|
1780
|
+
/** Input that claims a privileged caller; the subject must decide, never this. */
|
|
1781
|
+
const FORGED = {
|
|
1782
|
+
subject: {
|
|
1783
|
+
type: "service",
|
|
1784
|
+
id: "service"
|
|
1785
|
+
},
|
|
1786
|
+
actor: {
|
|
1787
|
+
id: "service",
|
|
1788
|
+
kind: "service",
|
|
1789
|
+
role: "service_role"
|
|
1790
|
+
},
|
|
1791
|
+
role: "service_role",
|
|
1792
|
+
user_id: "00000000-0000-4000-8000-000000000000",
|
|
1793
|
+
memberships: [{
|
|
1794
|
+
tenant: "conformance",
|
|
1795
|
+
role: "owner"
|
|
1796
|
+
}],
|
|
1797
|
+
claims: {
|
|
1798
|
+
role: "service_role",
|
|
1799
|
+
user_role: "admin",
|
|
1800
|
+
roles: ["admin"],
|
|
1801
|
+
memberships: [{
|
|
1802
|
+
tenant: "conformance",
|
|
1803
|
+
role: "owner"
|
|
1804
|
+
}]
|
|
1805
|
+
}
|
|
1806
|
+
};
|
|
1807
|
+
/**
|
|
1808
|
+
* Proves an `Authorizer` targets API v1, keys every permission to the same
|
|
1809
|
+
* string each time (and after a JSON round trip, so references are plain
|
|
1810
|
+
* JSON), answers every request with a known outcome without mutating it,
|
|
1811
|
+
* keeps the request order in `evaluations`, decides from the subject rather
|
|
1812
|
+
* than the input (forged roles, claims and memberships in the resource
|
|
1813
|
+
* properties or the context never turn a denial into a grant), never grants an unknown permission, and grants
|
|
1814
|
+
* `options.granted` through `checkPermission`.
|
|
1815
|
+
*/
|
|
1816
|
+
function testAuthorizer(authorizer, options) {
|
|
1817
|
+
const { subject, context } = requestOf(void 0);
|
|
1818
|
+
const requestFor = (action, id, properties) => ({
|
|
1819
|
+
subject,
|
|
1820
|
+
action,
|
|
1821
|
+
resource: {
|
|
1822
|
+
type: "conformance",
|
|
1823
|
+
id,
|
|
1824
|
+
...properties ? { properties } : {}
|
|
1825
|
+
},
|
|
1826
|
+
context
|
|
1827
|
+
});
|
|
1828
|
+
const requests = options.permissions.flatMap((permission) => [
|
|
1829
|
+
"a",
|
|
1830
|
+
"b",
|
|
1831
|
+
"c"
|
|
1832
|
+
].map((id) => requestFor(permission, id)));
|
|
1833
|
+
return conform(`Authorizer "${authorizer.name}"`, [
|
|
1834
|
+
hasName(authorizer),
|
|
1835
|
+
["targets API v1", () => {
|
|
1836
|
+
const version = authorizer.apiVersion;
|
|
1837
|
+
expect(version === 1, `apiVersion is ${String(version)}`);
|
|
1838
|
+
expect(options.permissions.length > 0, "options.permissions lists no permission to check");
|
|
1839
|
+
}],
|
|
1840
|
+
["keys every permission stably, as plain JSON", () => {
|
|
1841
|
+
for (const permission of options.permissions) {
|
|
1842
|
+
const key = authorizer.key(permission);
|
|
1843
|
+
expect(typeof key === "string" && key.length > 0, "key() must return a non-empty string");
|
|
1844
|
+
expect(authorizer.key(permission) === key, `key() gave two keys for ${key}`);
|
|
1845
|
+
const copy = JSON.parse(JSON.stringify(permission));
|
|
1846
|
+
expect(same(copy, permission) && authorizer.key(copy) === key, `the reference for ${key} is not plain JSON`);
|
|
1847
|
+
}
|
|
1848
|
+
}],
|
|
1849
|
+
["answers every request with a known outcome, without mutating it", async () => {
|
|
1850
|
+
for (const request of requests) {
|
|
1851
|
+
const outcome = await authorizer.evaluate(frozenCopy(request));
|
|
1852
|
+
expect(isOutcome(outcome), `evaluate() gave no known outcome for ${authorizer.key(request.action)}`);
|
|
1853
|
+
}
|
|
1854
|
+
}],
|
|
1855
|
+
authorizer.evaluations !== void 0 && ["keeps the request order in evaluations", async () => {
|
|
1856
|
+
const evaluations = authorizer.evaluations.bind(authorizer);
|
|
1857
|
+
const single = await Promise.all(requests.map((request) => Promise.resolve(authorizer.evaluate(request))));
|
|
1858
|
+
const batch = await evaluations(frozenCopy(requests));
|
|
1859
|
+
expect(batch.length === requests.length, `evaluations() gave ${batch.length} outcomes for ${requests.length} requests`);
|
|
1860
|
+
expect(same(batch.map((outcome) => outcome.outcome), single.map((outcome) => outcome.outcome)), "evaluations() disagrees with evaluate() for the same requests");
|
|
1861
|
+
const reversed = await evaluations(frozenCopy(requests.toReversed()));
|
|
1862
|
+
expect(same(reversed.toReversed().map((outcome) => outcome.outcome), batch.map((outcome) => outcome.outcome)), "evaluations() does not keep the request order");
|
|
1863
|
+
}],
|
|
1864
|
+
["decides from the subject, never from the input", async () => {
|
|
1865
|
+
for (const permission of options.permissions) {
|
|
1866
|
+
const plain = await authorizer.evaluate(requestFor(permission, "a"));
|
|
1867
|
+
const forgedResource = await authorizer.evaluate(requestFor(permission, "a", FORGED));
|
|
1868
|
+
const forgedContext = await authorizer.evaluate({
|
|
1869
|
+
...requestFor(permission, "a"),
|
|
1870
|
+
context: {
|
|
1871
|
+
...context,
|
|
1872
|
+
...FORGED
|
|
1873
|
+
}
|
|
1874
|
+
});
|
|
1875
|
+
for (const forged of [forgedResource, forgedContext]) expect(forged.outcome !== "granted" || plain.outcome === "granted", `input claiming a service caller was granted ${authorizer.key(permission)}`);
|
|
1876
|
+
}
|
|
1877
|
+
}],
|
|
1878
|
+
options.unknown !== void 0 && ["never grants an unknown permission", async () => {
|
|
1879
|
+
expect(!(await checkPermission({
|
|
1880
|
+
authorizer,
|
|
1881
|
+
permission: options.unknown,
|
|
1882
|
+
resource: { type: "conformance" },
|
|
1883
|
+
context: options.granted?.context
|
|
1884
|
+
})).ok, "an unknown permission was granted");
|
|
1885
|
+
}],
|
|
1886
|
+
options.granted !== void 0 && ["grants the granted permission through checkPermission", async () => {
|
|
1887
|
+
const checked = await checkPermission({
|
|
1888
|
+
authorizer,
|
|
1889
|
+
permission: options.granted.permission,
|
|
1890
|
+
resource: { type: "conformance" },
|
|
1891
|
+
context: options.granted.context
|
|
1892
|
+
});
|
|
1893
|
+
expect(checked.ok, checked.ok ? "" : `the permission was refused: ${checked.error.message}`);
|
|
1894
|
+
}]
|
|
1895
|
+
]);
|
|
1896
|
+
}
|
|
1897
|
+
/**
|
|
1898
|
+
* An in-memory `Authorizer` for tests and examples: permissions come from
|
|
1899
|
+
* the subject's `role` and id only, never from the request's resource or
|
|
1900
|
+
* context, and it ignores `subject.properties.claims`.
|
|
1901
|
+
*/
|
|
1902
|
+
function staticAuthorizer(options) {
|
|
1903
|
+
const approvals = new Set(options.approvals ?? []);
|
|
1904
|
+
const evaluate = (request) => {
|
|
1905
|
+
const { subject, action } = request;
|
|
1906
|
+
if (approvals.has(action)) return {
|
|
1907
|
+
outcome: "approval-required",
|
|
1908
|
+
approval: { id: `approval:${action}` }
|
|
1909
|
+
};
|
|
1910
|
+
const role = subject.properties?.["role"];
|
|
1911
|
+
return [...options.roles?.[typeof role === "string" ? role : subject.type] ?? [], ...options.grants?.[subject.id] ?? []].includes(action) ? { outcome: "granted" } : {
|
|
1912
|
+
outcome: "denied",
|
|
1913
|
+
reason: `Permission ${action} is required`
|
|
1914
|
+
};
|
|
1915
|
+
};
|
|
1916
|
+
return defineAuthorizer({
|
|
1917
|
+
name: "static",
|
|
1918
|
+
key: (ref) => ref,
|
|
1919
|
+
evaluate,
|
|
1920
|
+
evaluations: (requests) => Promise.resolve(requests.map(evaluate))
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
//#endregion
|
|
1924
|
+
//#region src/testing/document-format.ts
|
|
1925
|
+
const STATUSES$1 = /* @__PURE__ */ new Set([
|
|
1926
|
+
"stable",
|
|
1927
|
+
"legacy",
|
|
1928
|
+
"preview"
|
|
1929
|
+
]);
|
|
1930
|
+
const SEVERITIES = /* @__PURE__ */ new Set([
|
|
1931
|
+
"error",
|
|
1932
|
+
"warning",
|
|
1933
|
+
"info"
|
|
1934
|
+
]);
|
|
1935
|
+
const CODE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
1936
|
+
const jsonSchema = (schema) => ({ "~standard": {
|
|
1937
|
+
version: 1,
|
|
1938
|
+
vendor: "better-supabase-conformance",
|
|
1939
|
+
validate: (value) => ({ value }),
|
|
1940
|
+
jsonSchema: {
|
|
1941
|
+
input: () => schema,
|
|
1942
|
+
output: () => schema
|
|
1943
|
+
}
|
|
1944
|
+
} });
|
|
1945
|
+
function sampleModel() {
|
|
1946
|
+
const customer = {
|
|
1947
|
+
type: "object",
|
|
1948
|
+
required: ["id", "name"],
|
|
1949
|
+
properties: {
|
|
1950
|
+
id: {
|
|
1951
|
+
type: "string",
|
|
1952
|
+
format: "uuid"
|
|
1953
|
+
},
|
|
1954
|
+
name: { type: "string" },
|
|
1955
|
+
note: { type: ["string", "null"] }
|
|
1956
|
+
}
|
|
1957
|
+
};
|
|
1958
|
+
return buildApiModel({ meta: {
|
|
1959
|
+
version: 1,
|
|
1960
|
+
casing: "camel",
|
|
1961
|
+
tables: {},
|
|
1962
|
+
enums: {},
|
|
1963
|
+
functions: {}
|
|
1964
|
+
} }, {
|
|
1965
|
+
info: {
|
|
1966
|
+
title: "Conformance",
|
|
1967
|
+
version: "1.0.0"
|
|
1968
|
+
},
|
|
1969
|
+
servers: [{
|
|
1970
|
+
url: "https://api.test",
|
|
1971
|
+
name: "test"
|
|
1972
|
+
}],
|
|
1973
|
+
tags: [{
|
|
1974
|
+
name: "customers",
|
|
1975
|
+
summary: "Customers"
|
|
1976
|
+
}],
|
|
1977
|
+
basePath: "/api",
|
|
1978
|
+
routes: [
|
|
1979
|
+
{
|
|
1980
|
+
method: "get",
|
|
1981
|
+
path: "/customers/{id}",
|
|
1982
|
+
operationId: "getCustomer",
|
|
1983
|
+
tags: ["customers"],
|
|
1984
|
+
output: jsonSchema(customer)
|
|
1985
|
+
},
|
|
1986
|
+
{
|
|
1987
|
+
method: "post",
|
|
1988
|
+
path: "/customers",
|
|
1989
|
+
operationId: "createCustomer",
|
|
1990
|
+
tags: ["customers"],
|
|
1991
|
+
input: jsonSchema(customer),
|
|
1992
|
+
output: jsonSchema(customer),
|
|
1993
|
+
status: 201
|
|
1994
|
+
},
|
|
1995
|
+
{
|
|
1996
|
+
method: "get",
|
|
1997
|
+
path: "/customers/changes",
|
|
1998
|
+
operationId: "streamCustomerChanges",
|
|
1999
|
+
stream: { item: jsonSchema(customer) }
|
|
2000
|
+
}
|
|
2001
|
+
],
|
|
2002
|
+
webhooks: [{
|
|
2003
|
+
id: "customer.created",
|
|
2004
|
+
payload: customer,
|
|
2005
|
+
standardWebhooks: true
|
|
2006
|
+
}],
|
|
2007
|
+
realtimeUrl: "wss://abc.supabase.co/realtime/v1/websocket",
|
|
2008
|
+
messages: { customerChanged: {
|
|
2009
|
+
id: "customerChanged",
|
|
2010
|
+
payload: customer
|
|
2011
|
+
} },
|
|
2012
|
+
channels: [{
|
|
2013
|
+
id: "customers",
|
|
2014
|
+
address: "bs:t:public.customers",
|
|
2015
|
+
protocol: "ws",
|
|
2016
|
+
messages: ["customerChanged"],
|
|
2017
|
+
operations: [{
|
|
2018
|
+
id: "onCustomerChanged",
|
|
2019
|
+
action: "receive"
|
|
2020
|
+
}]
|
|
2021
|
+
}],
|
|
2022
|
+
workflows: [{
|
|
2023
|
+
id: "createThenGet",
|
|
2024
|
+
steps: [{
|
|
2025
|
+
id: "create",
|
|
2026
|
+
operationId: "createCustomer",
|
|
2027
|
+
outputs: { id: "$response.body#/id" }
|
|
2028
|
+
}, {
|
|
2029
|
+
id: "get",
|
|
2030
|
+
operationId: "getCustomer",
|
|
2031
|
+
parameters: [{
|
|
2032
|
+
name: "id",
|
|
2033
|
+
in: "path",
|
|
2034
|
+
value: "$steps.create.outputs.id"
|
|
2035
|
+
}]
|
|
2036
|
+
}]
|
|
2037
|
+
}],
|
|
2038
|
+
security: ["bearer"]
|
|
2039
|
+
}).model;
|
|
2040
|
+
}
|
|
2041
|
+
function isPlainJson(value) {
|
|
2042
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
2043
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
2044
|
+
if (Array.isArray(value)) return value.every(isPlainJson);
|
|
2045
|
+
if (typeof value !== "object") return false;
|
|
2046
|
+
const prototype = Object.getPrototypeOf(value);
|
|
2047
|
+
if (prototype !== Object.prototype && prototype !== null) return false;
|
|
2048
|
+
return Object.values(value).every((entry) => entry !== void 0 && isPlainJson(entry));
|
|
2049
|
+
}
|
|
2050
|
+
function diagnosticProblem(diagnostic) {
|
|
2051
|
+
if (typeof diagnostic.code !== "string" || !CODE.test(diagnostic.code)) return `the code ${JSON.stringify(diagnostic.code)} is not kebab-case`;
|
|
2052
|
+
const severity = diagnostic.severity;
|
|
2053
|
+
if (typeof severity !== "string" || !SEVERITIES.has(severity)) return `${diagnostic.code} has the severity ${String(severity)}`;
|
|
2054
|
+
if (typeof diagnostic.message !== "string" || diagnostic.message === "") return `${diagnostic.code} has no message`;
|
|
2055
|
+
if (diagnostic.pointer !== void 0 && !/^(?:\/|$)/.test(diagnostic.pointer)) return `${diagnostic.code} has the pointer ${diagnostic.pointer}, which is not a JSON Pointer`;
|
|
2056
|
+
}
|
|
2057
|
+
/**
|
|
2058
|
+
* Proves a `DocumentFormat` targets API v1, lists versions with pins and a
|
|
2059
|
+
* known status, defaults to a version it lists that is not a preview, and,
|
|
2060
|
+
* for every version: lowers the model without mutating it (also when the
|
|
2061
|
+
* model is frozen), renders the same document twice, renders plain JSON or
|
|
2062
|
+
* text, reports diagnostics with a kebab-case code, a severity and a
|
|
2063
|
+
* message, finds no error in its own output with `lint`, and names a bare,
|
|
2064
|
+
* stable file.
|
|
2065
|
+
*/
|
|
2066
|
+
function testDocumentFormat(format, options = {}) {
|
|
2067
|
+
const subject = format;
|
|
2068
|
+
const id = subject.id;
|
|
2069
|
+
const defaultVersion = subject.defaultVersion;
|
|
2070
|
+
const versions = options.versions ?? Object.keys(subject.versions);
|
|
2071
|
+
let model;
|
|
2072
|
+
const modelOf = () => model ??= options.model ?? sampleModel();
|
|
2073
|
+
const lower = (version, input) => {
|
|
2074
|
+
const diagnostics = [];
|
|
2075
|
+
return {
|
|
2076
|
+
document: subject.lower(input, version, { report: (diagnostic) => {
|
|
2077
|
+
diagnostics.push(diagnostic);
|
|
2078
|
+
} }),
|
|
2079
|
+
diagnostics
|
|
2080
|
+
};
|
|
2081
|
+
};
|
|
2082
|
+
return conform(`DocumentFormat "${String(id)}"`, [
|
|
2083
|
+
["targets API v1 with an id", () => {
|
|
2084
|
+
const version = subject.apiVersion;
|
|
2085
|
+
expect(version === 1, `apiVersion is ${String(version)}`);
|
|
2086
|
+
expect(typeof id === "string" && id.length > 0, "id must be a non-empty string");
|
|
2087
|
+
}],
|
|
2088
|
+
["lists versions with a pin and a known status", () => {
|
|
2089
|
+
const listed = Object.entries(subject.versions);
|
|
2090
|
+
expect(listed.length > 0, "versions lists no version");
|
|
2091
|
+
for (const [version, entry] of listed) {
|
|
2092
|
+
const status = entry.status;
|
|
2093
|
+
expect(typeof entry.pin === "string" && entry.pin.length > 0, `version ${version} has no pin`);
|
|
2094
|
+
expect(typeof status === "string" && STATUSES$1.has(status), `version ${version} has the status ${String(status)}`);
|
|
2095
|
+
}
|
|
2096
|
+
for (const version of versions) expect(Object.hasOwn(subject.versions, version), `options.versions names ${version}, which the format does not list`);
|
|
2097
|
+
}],
|
|
2098
|
+
["defaults to a listed version that is not a preview", () => {
|
|
2099
|
+
const entry = Object.hasOwn(subject.versions, subject.defaultVersion) ? subject.versions[subject.defaultVersion] : void 0;
|
|
2100
|
+
expect(entry !== void 0, `the default version ${String(defaultVersion)} is not in versions`);
|
|
2101
|
+
expect(entry.status !== "preview", `the default version ${String(defaultVersion)} is a preview`);
|
|
2102
|
+
}],
|
|
2103
|
+
["lowers every version without mutating the model", () => {
|
|
2104
|
+
const original = modelOf();
|
|
2105
|
+
const before = JSON.stringify(original);
|
|
2106
|
+
for (const version of versions) {
|
|
2107
|
+
lower(version, original);
|
|
2108
|
+
expect(JSON.stringify(original) === before, `lower(model, "${version}") changed the model`);
|
|
2109
|
+
lower(version, frozenCopy(original));
|
|
2110
|
+
}
|
|
2111
|
+
}],
|
|
2112
|
+
["renders the same document twice", () => {
|
|
2113
|
+
for (const version of versions) {
|
|
2114
|
+
const first = lower(version, structuredClone(modelOf()));
|
|
2115
|
+
const second = lower(version, structuredClone(modelOf()));
|
|
2116
|
+
expect(same(first.document, second.document), `lower(model, "${version}") is not deterministic`);
|
|
2117
|
+
expect(same(first.diagnostics, second.diagnostics), `lower(model, "${version}") reports different diagnostics each time`);
|
|
2118
|
+
}
|
|
2119
|
+
}],
|
|
2120
|
+
["renders plain JSON or text", () => {
|
|
2121
|
+
for (const version of versions) {
|
|
2122
|
+
const { document } = lower(version, modelOf());
|
|
2123
|
+
expect(typeof document === "string" || typeof document === "object" && document !== null && isPlainJson(document), `lower(model, "${version}") returned something other than a JSON object or a string`);
|
|
2124
|
+
}
|
|
2125
|
+
}],
|
|
2126
|
+
["reports diagnostics with a code, a severity and a message", () => {
|
|
2127
|
+
for (const version of versions) {
|
|
2128
|
+
const { document, diagnostics } = lower(version, modelOf());
|
|
2129
|
+
const linted = subject.lint?.(document, version) ?? [];
|
|
2130
|
+
for (const diagnostic of [...diagnostics, ...linted]) {
|
|
2131
|
+
const problem = diagnosticProblem(diagnostic);
|
|
2132
|
+
expect(problem === void 0, `${version}: ${problem ?? ""}`);
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
}],
|
|
2136
|
+
subject.lint !== void 0 && ["finds no error in its own output", () => {
|
|
2137
|
+
for (const version of versions) {
|
|
2138
|
+
const { document, diagnostics } = lower(version, modelOf());
|
|
2139
|
+
if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) continue;
|
|
2140
|
+
const errors = (subject.lint?.(document, version) ?? []).filter((diagnostic) => diagnostic.severity === "error");
|
|
2141
|
+
expect(errors.length === 0, `lint(lower(model, "${version}")) reports ${errors.map((error) => error.code).join(", ")}`);
|
|
2142
|
+
}
|
|
2143
|
+
}],
|
|
2144
|
+
["names a bare, stable file per version", () => {
|
|
2145
|
+
for (const version of versions) {
|
|
2146
|
+
const name = subject.fileName(version);
|
|
2147
|
+
expect(typeof name === "string" && /^[^/\\]+\.[A-Za-z0-9]+$/.test(name), `fileName("${version}") is ${JSON.stringify(name)}, not a file name with an extension`);
|
|
2148
|
+
expect(subject.fileName(version) === name, `fileName("${version}") changes between calls`);
|
|
2149
|
+
}
|
|
2150
|
+
}]
|
|
2151
|
+
]);
|
|
2152
|
+
}
|
|
2153
|
+
//#endregion
|
|
1606
2154
|
//#region src/testing/generator.ts
|
|
1607
2155
|
function relativeImport(from, to) {
|
|
1608
2156
|
const fromParts = from.split("/").slice(0, -1);
|
|
@@ -2350,4 +2898,4 @@ function testCredentialProvider(provider, options) {
|
|
|
2350
2898
|
]);
|
|
2351
2899
|
}
|
|
2352
2900
|
//#endregion
|
|
2353
|
-
export { ConformanceError, LOCAL_JWT_SECRET, asUser, createTestSigner, defineSeed, expectDbBudget, expectInstant, expectTenantIsolation, isSeed, localAuth, localSigningKey, signLocalJwt, signTestJwt, signTestJwtWithKey, supabaseClaimFixtures, testAdapter, testAiTaskRunner, testAuthResolver, testAuthorizationProvider, testBlockTransportMiddleware, testBuilderStarter, testCacheAdapter, testChatState, testCredentialProvider, testEmbedder, testEveDocumentBackend, testEventSink, testExecutor, testGenerator, testGraphCompiler, testNotificationChannel, testPlugin, testQueueBackend, testStreamStore, testSupportSessionStore, testWebhookSecretStore, testWebhookSigner, testWebhookTransport };
|
|
2901
|
+
export { ConformanceError, LOCAL_JWT_SECRET, asUser, createTestSigner, defineSeed, expectDbBudget, expectInstant, expectTenantIsolation, isSeed, liteStack, localAuth, localSigningKey, signLocalJwt, signTestJwt, signTestJwtWithKey, staticAuthorizer, supabaseClaimFixtures, testAdapter, testAiTaskRunner, testAuthResolver, testAuthorizationProvider, testAuthorizer, testBlockTransportMiddleware, testBuilderStarter, testCacheAdapter, testChatState, testCredentialProvider, testDocumentFormat, testEmbedder, testEveDocumentBackend, testEventSink, testExecutor, testGenerator, testGraphCompiler, testNotificationChannel, testPlugin, testQueueBackend, testStreamStore, testSupportSessionStore, testWebhookSecretStore, testWebhookSigner, testWebhookTransport };
|